From 83ac5184c69821bbefadc9317017e9b184f144d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Sat, 23 May 2026 22:34:26 +0800 Subject: [PATCH] feat(capture): make timestamps timezone configurable --- README.md | 1 + README_CN.md | 1 + openclaw.plugin.json | 1 + package.json | 2 - src/config.test.ts | 31 ++++++++++++ src/config.ts | 5 ++ src/core/conversation/l0-recorder.test.ts | 42 +++++++++++++++ src/core/conversation/l0-recorder.ts | 22 +++----- src/core/hooks/auto-capture.ts | 4 +- src/core/prompts/l1-extraction.test.ts | 31 ++++++++++++ src/core/prompts/l1-extraction.ts | 10 ++-- src/core/record/l1-extractor.ts | 7 ++- src/core/scene/scene-extractor.test.ts | 48 ++++++++++++++++++ src/core/scene/scene-extractor.ts | 11 ++-- src/core/store/sqlite.test.ts | 48 ++++++++++++++++++ src/core/store/sqlite.ts | 9 ++-- src/offload/time-utils.ts | 15 +----- src/utils/pipeline-factory.ts | 2 + src/utils/timezone.test.ts | 36 +++++++++++++ src/utils/timezone.ts | 62 +++++++++++++++++++++++ 20 files changed, 345 insertions(+), 43 deletions(-) create mode 100644 src/config.test.ts create mode 100644 src/core/conversation/l0-recorder.test.ts create mode 100644 src/core/prompts/l1-extraction.test.ts create mode 100644 src/core/scene/scene-extractor.test.ts create mode 100644 src/core/store/sqlite.test.ts create mode 100644 src/utils/timezone.test.ts create mode 100644 src/utils/timezone.ts diff --git a/README.md b/README.md index 58aa74f..fdccc2c 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,7 @@ docker exec -it hermes-memory hermes | `recall.timeoutMs` | `5000` | Recall timeout; on timeout, skip injection without blocking the conversation | | `extraction.enableDedup` | `true` | L1 vector dedup / conflict detection | | `capture.excludeAgents` | `[]` | Glob patterns to exclude specific agents (e.g. `bench-judge-*`) | +| `capture.timezoneOffsetMinutes` | `480` | Timezone offset used for captured timestamps and extraction prompts; `480` = UTC+08:00 | | `capture.l0l1RetentionDays` | `0` | Local retention days for L0 / L1 files; `0` = never clean up | | `offload.mildOffloadRatio` | `0.5` | Mild compression trigger ratio (of context window) | | `offload.aggressiveCompressRatio` | `0.85` | Aggressive compression trigger ratio | diff --git a/README_CN.md b/README_CN.md index 16ef697..52fe07c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -281,6 +281,7 @@ docker exec -it hermes-memory hermes | `recall.timeoutMs` | `5000` | 召回超时阈值,超时跳过注入不阻塞对话 | | `extraction.enableDedup` | `true` | L1 向量去重 / 冲突检测 | | `capture.excludeAgents` | `[]` | Glob 模式排除特定 Agent(如 `bench-judge-*`) | +| `capture.timezoneOffsetMinutes` | `480` | 捕获时间戳和提取提示词使用的时区偏移分钟数;`480` = UTC+08:00 | | `capture.l0l1RetentionDays` | `0` | L0/L1 本地文件保留天数,`0` = 永不清理 | | `offload.mildOffloadRatio` | `0.5` | 温和压缩触发比例(占 context window) | | `offload.aggressiveCompressRatio` | `0.85` | 激进压缩触发比例 | diff --git a/openclaw.plugin.json b/openclaw.plugin.json index f6ea5fd..171b5cc 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -24,6 +24,7 @@ "properties": { "enabled": { "type": "boolean", "default": true, "description": "是否启用自动对话捕获" }, "excludeAgents": { "type": "array", "items": { "type": "string" }, "default": [], "description": "排除的 Agent glob 模式列表(如 bench-judge-*),匹配的 agent 不会被捕获、召回或参与 pipeline 调度" }, + "timezoneOffsetMinutes": { "type": "number", "default": 480, "description": "捕获与提取提示词中时间戳使用的时区偏移分钟数,范围 -840 到 840;默认 480 表示 UTC+08:00" }, "l0l1RetentionDays": { "type": "number", "default": 0, "description": "L0/L1 本地文件保留天数(单位天)。0=不清理;非0时默认必须>=3。若为1或2,需显式开启 allowAggressiveCleanup" }, "allowAggressiveCleanup": { "type": "boolean", "default": false, "description": "是否允许高风险清理配置(l0l1RetentionDays=1或2)" }, "cleanTime": { "type": "string", "default": "03:00", "description": "每日清理执行时间(HH:mm)" } diff --git a/package.json b/package.json index 0609611..ea6cb4e 100644 --- a/package.json +++ b/package.json @@ -34,14 +34,12 @@ "files": [ "dist/", "bin/", - "index.ts", "scripts/migrate-sqlite-to-tcvdb/dist/", "scripts/export-tencent-vdb/dist/", "scripts/read-local-memory/dist/", "scripts/memory-tencentdb-ctl.sh", "scripts/install_hermes_memory_tencentdb.sh", "scripts/README.memory-tencentdb-ctl.md", - "src/", "scripts/openclaw-after-tool-call-messages.patch.sh", "scripts/setup-offload.sh", "hermes-plugin/", diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..7f44ab3 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { parseConfig } from "./config.js"; + +describe("parseConfig capture timezone", () => { + it("defaults capture timestamps to UTC+08:00", () => { + const cfg = parseConfig({}); + + expect(cfg.capture.timezoneOffsetMinutes).toBe(480); + }); + + it("accepts a custom capture timezone offset in minutes", () => { + const cfg = parseConfig({ + capture: { + timezoneOffsetMinutes: -300, + }, + }); + + expect(cfg.capture.timezoneOffsetMinutes).toBe(-300); + }); + + it("falls back to UTC+08:00 when the timezone offset is outside the supported range", () => { + const cfg = parseConfig({ + capture: { + timezoneOffsetMinutes: 15 * 60, + }, + }); + + expect(cfg.capture.timezoneOffsetMinutes).toBe(480); + }); +}); diff --git a/src/config.ts b/src/config.ts index e09cff5..d7efcae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,8 @@ * Minimal config (zero config): {} — all fields have sensible defaults. */ +import { normalizeTimezoneOffsetMinutes } from "./utils/timezone.js"; + // ============================ // Type definitions // ============================ @@ -17,6 +19,8 @@ export interface CaptureConfig { enabled: boolean; /** Glob patterns to exclude agents (e.g. "bench-judge-*"); matched agents are fully ignored */ excludeAgents: string[]; + /** Timezone offset, in minutes, used when formatting captured timestamps (default: 480, UTC+08:00) */ + timezoneOffsetMinutes: number; /** * L0/L1 local file retention days used as TTL switch. * 0 means cleanup disabled.(default: 0) @@ -458,6 +462,7 @@ export function parseConfig(raw: Record | undefined): MemoryTda capture: { enabled: bool(captureGroup, "enabled") ?? true, excludeAgents: strArray(captureGroup, "excludeAgents") ?? [], + timezoneOffsetMinutes: normalizeTimezoneOffsetMinutes(num(captureGroup, "timezoneOffsetMinutes")), l0l1RetentionDays: retentionDays ?? 0, allowAggressiveCleanup, }, diff --git a/src/core/conversation/l0-recorder.test.ts b/src/core/conversation/l0-recorder.test.ts new file mode 100644 index 0000000..6153f55 --- /dev/null +++ b/src/core/conversation/l0-recorder.test.ts @@ -0,0 +1,42 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { recordConversation } from "./l0-recorder.js"; + +describe("recordConversation timezone", () => { + it("writes recordedAt and shard filename in the configured timezone", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T02:30:00.000Z")); + const baseDir = await mkdtemp(path.join(tmpdir(), "tdai-l0-tz-")); + + try { + await recordConversation({ + sessionKey: "session-a", + sessionId: "sid-a", + rawMessages: [ + { + id: "msg-1", + role: "user", + content: "User wants local timezone timestamps in recorded memory data.", + timestamp: Date.parse("2026-01-01T02:29:00.000Z"), + }, + ], + baseDir, + timezoneOffsetMinutes: -300, + }); + + const raw = await readFile( + path.join(baseDir, "conversations", "2025-12-31.jsonl"), + "utf-8", + ); + const record = JSON.parse(raw.trim()) as { recordedAt: string }; + + expect(record.recordedAt).toBe("2025-12-31T21:30:00.000-05:00"); + } finally { + vi.useRealTimers(); + await rm(baseDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/core/conversation/l0-recorder.ts b/src/core/conversation/l0-recorder.ts index 026a876..1ba3897 100644 --- a/src/core/conversation/l0-recorder.ts +++ b/src/core/conversation/l0-recorder.ts @@ -18,6 +18,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import crypto from "node:crypto"; import { sanitizeText, stripCodeBlocks, shouldCaptureL0 } from "../../utils/sanitize.js"; +import { formatTimezoneDate, formatTimezoneISO } from "../../utils/timezone.js"; // ============================ // Types @@ -101,6 +102,8 @@ export async function recordConversation(params: { originalUserText?: string; /** Epoch ms cursor: only process messages with timestamp strictly greater than this. */ afterTimestamp?: number; + /** Timezone offset, in minutes, used for recordedAt and daily shard filenames. */ + timezoneOffsetMinutes?: number; /** * Number of messages in the session at before_prompt_build time. * Used to locate the exact user message that originalUserText corresponds to: @@ -109,7 +112,7 @@ export async function recordConversation(params: { */ originalUserMessageCount?: number; }): Promise { - const { sessionKey, sessionId, rawMessages, baseDir, logger, originalUserText, afterTimestamp, originalUserMessageCount } = params; + const { sessionKey, sessionId, rawMessages, baseDir, logger, originalUserText, afterTimestamp, originalUserMessageCount, timezoneOffsetMinutes } = params; // Step 1: Position slice + extract user/assistant messages. // @@ -268,7 +271,8 @@ export async function recordConversation(params: { } // Step 4: Write to JSONL file — one message per line (flat format) - const now = new Date().toISOString(); + const nowDate = new Date(); + const now = formatTimezoneISO(nowDate, timezoneOffsetMinutes); const lines: string[] = []; for (const msg of filtered) { const record: L0MessageRecord = { @@ -283,7 +287,7 @@ export async function recordConversation(params: { lines.push(JSON.stringify(record)); } - const shardDate = formatLocalDate(new Date()); + const shardDate = formatTimezoneDate(nowDate, timezoneOffsetMinutes); const outDir = path.join(baseDir, "conversations"); const outPath = path.join(outDir, `${shardDate}.jsonl`); @@ -371,7 +375,7 @@ export async function readConversationRecords( records.push({ sessionKey: (parsed.sessionKey as string) || sessionKey, sessionId: (parsed.sessionId as string) || "", - recordedAt: (parsed.recordedAt as string) || new Date().toISOString(), + recordedAt: (parsed.recordedAt as string) || formatTimezoneISO(new Date()), messageCount: 1, messages: [msg], }); @@ -570,13 +574,3 @@ 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-capture.ts b/src/core/hooks/auto-capture.ts index 18eaaee..16b91f5 100644 --- a/src/core/hooks/auto-capture.ts +++ b/src/core/hooks/auto-capture.ts @@ -18,6 +18,7 @@ import { recordConversation } from "../conversation/l0-recorder.js"; import type { ConversationMessage } from "../conversation/l0-recorder.js"; import type { IMemoryStore, L0Record } from "../store/types.js"; import type { EmbeddingService } from "../store/embedding.js"; +import { formatTimezoneISO } from "../../utils/timezone.js"; const TAG = "[memory-tdai] [capture]"; @@ -130,6 +131,7 @@ export async function performAutoCapture(params: { originalUserText, afterTimestamp, originalUserMessageCount, + timezoneOffsetMinutes: cfg.capture.timezoneOffsetMinutes, }); if (filteredMessages.length === 0) { @@ -172,7 +174,7 @@ export async function performAutoCapture(params: { const supportsBgEmbed = vectorStore?.supportsDeferredEmbedding === true; if (filteredMessages.length > 0 && vectorStore) { - const now = new Date().toISOString(); + const now = formatTimezoneISO(new Date(), cfg.capture.timezoneOffsetMinutes); const bgRecords: Array<{ recordId: string; content: string }> = []; logger?.debug?.( `${TAG} [L0-vec-index] START indexing ${filteredMessages.length} message(s) for session ${sessionKey} ` + diff --git a/src/core/prompts/l1-extraction.test.ts b/src/core/prompts/l1-extraction.test.ts new file mode 100644 index 0000000..36add8c --- /dev/null +++ b/src/core/prompts/l1-extraction.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { formatExtractionPrompt } from "./l1-extraction.js"; + +describe("formatExtractionPrompt", () => { + it("formats message timestamps with the configured timezone offset", () => { + const prompt = formatExtractionPrompt({ + timezoneOffsetMinutes: -300, + backgroundMessages: [ + { + id: "bg-1", + role: "assistant", + content: "Background message", + timestamp: Date.parse("2026-01-01T02:00:00.000Z"), + }, + ], + newMessages: [ + { + id: "new-1", + role: "user", + content: "User prefers local timestamps in memory prompts.", + timestamp: Date.parse("2026-01-01T02:30:00.000Z"), + }, + ], + }); + + expect(prompt).toContain("[2025-12-31T21:00:00.000-05:00]"); + expect(prompt).toContain("[2025-12-31T21:30:00.000-05:00]"); + expect(prompt).not.toContain("2026-01-01T02:30:00.000Z"); + }); +}); diff --git a/src/core/prompts/l1-extraction.ts b/src/core/prompts/l1-extraction.ts index 6378598..91fb9b4 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 { formatTimezoneISO } from "../../utils/timezone.js"; // ============================ // System Prompt @@ -112,17 +113,20 @@ export function formatExtractionPrompt(params: { newMessages: ConversationMessage[]; backgroundMessages?: ConversationMessage[]; previousSceneName?: string; + timezoneOffsetMinutes?: number; }): string { - const { newMessages, backgroundMessages = [], previousSceneName = "无" } = params; + const { newMessages, backgroundMessages = [], previousSceneName = "无", timezoneOffsetMinutes } = params; + const formatMessageTimestamp = (timestamp: number) => + formatTimezoneISO(new Date(timestamp), timezoneOffsetMinutes); 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}] [${formatMessageTimestamp(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}] [${formatMessageTimestamp(m.timestamp)}]: ${m.content}`) .join("\n\n"); return `【上一个情境】:${previousSceneName} diff --git a/src/core/record/l1-extractor.ts b/src/core/record/l1-extractor.ts index ad0a8f1..c6b0583 100644 --- a/src/core/record/l1-extractor.ts +++ b/src/core/record/l1-extractor.ts @@ -106,6 +106,8 @@ export async function extractL1Memories(params: { conflictRecallTopK?: number; /** Override embedding timeout for capture-path calls (milliseconds) */ embeddingTimeoutMs?: number; + /** Timezone offset, in minutes, used when formatting prompt timestamps. */ + timezoneOffsetMinutes?: number; /** * Host-neutral LLM runner. When provided, used instead of creating * a CleanContextRunner (decouples from OpenClaw runtime). @@ -165,6 +167,7 @@ export async function extractL1Memories(params: { config, logger, model: options.model, + timezoneOffsetMinutes: options.timezoneOffsetMinutes, llmRunner: options.llmRunner, }); logger?.debug?.(`${TAG} LLM detected ${scenes.length} scene(s)`); @@ -307,15 +310,17 @@ async function callLlmExtraction(params: { config: unknown; logger?: Logger; model?: string; + timezoneOffsetMinutes?: number; /** Host-neutral LLM runner — when provided, used instead of CleanContextRunner. */ llmRunner?: LLMRunner; }): Promise { - const { newMessages, backgroundMessages, previousSceneName, config, logger, model, llmRunner } = params; + const { newMessages, backgroundMessages, previousSceneName, config, logger, model, timezoneOffsetMinutes, llmRunner } = params; const userPrompt = formatExtractionPrompt({ newMessages, backgroundMessages, previousSceneName, + timezoneOffsetMinutes, }); // [l1-debug] ENTRY — what are we about to ask the LLM to extract? diff --git a/src/core/scene/scene-extractor.test.ts b/src/core/scene/scene-extractor.test.ts new file mode 100644 index 0000000..2d6ce18 --- /dev/null +++ b/src/core/scene/scene-extractor.test.ts @@ -0,0 +1,48 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import type { LLMRunParams, LLMRunner } from "../types.js"; +import { SceneExtractor } from "./scene-extractor.js"; + +describe("SceneExtractor timezone", () => { + it("injects the current timestamp with the configured timezone offset", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T02:30:00.000Z")); + const baseDir = await mkdtemp(path.join(tmpdir(), "tdai-scene-tz-")); + let capturedPrompt = ""; + + const llmRunner: LLMRunner = { + async run(params: LLMRunParams): Promise { + capturedPrompt = params.prompt; + return ""; + }, + }; + + try { + const extractor = new SceneExtractor({ + dataDir: baseDir, + config: {}, + maxScenes: 5, + llmRunner, + timezoneOffsetMinutes: -300, + }); + + const result = await extractor.extract([ + { + id: "memory-1", + content: "User enabled configurable timezone timestamps.", + created_at: "2026-01-01T02:00:00.000Z", + }, + ]); + + expect(result.success).toBe(true); + expect(capturedPrompt).toContain("2025-12-31T21:30:00.000-05:00"); + expect(capturedPrompt).not.toContain("2026-01-01T02:30:00.000Z"); + } finally { + vi.useRealTimers(); + await rm(baseDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/core/scene/scene-extractor.ts b/src/core/scene/scene-extractor.ts index 71288f4..c89d1ea 100644 --- a/src/core/scene/scene-extractor.ts +++ b/src/core/scene/scene-extractor.ts @@ -28,6 +28,7 @@ import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-na import { buildSceneExtractionPrompt } from "../prompts/scene-extraction.js"; import { report } from "../report/reporter.js"; import type { LLMRunner } from "../types.js"; +import { formatTimezoneISO } from "../../utils/timezone.js"; const TAG = "[memory-tdai] [extractor]"; @@ -51,6 +52,8 @@ export interface SceneExtractorOptions { maxScenes?: number; sceneBackupCount?: number; timeoutMs?: number; + /** Timezone offset, in minutes, used when formatting prompt timestamps. */ + timezoneOffsetMinutes?: number; logger?: ExtractorLogger; /** Plugin instance ID for metric reporting (optional) */ instanceId?: string; @@ -91,6 +94,7 @@ export class SceneExtractor { private maxScenes: number; private sceneBackupCount: number; private timeoutMs: number; + private timezoneOffsetMinutes: number | undefined; private logger: ExtractorLogger | undefined; private instanceId: string | undefined; @@ -99,6 +103,7 @@ export class SceneExtractor { this.maxScenes = opts.maxScenes ?? 15; this.sceneBackupCount = opts.sceneBackupCount ?? 10; this.timeoutMs = opts.timeoutMs ?? 300_000; // 5 min — LLM may do multiple tool calls + this.timezoneOffsetMinutes = opts.timezoneOffsetMinutes; this.logger = opts.logger; this.instanceId = opts.instanceId; @@ -190,7 +195,7 @@ export class SceneExtractor { 2, ); - const currentTimestamp = formatTimestamp(new Date()); + const currentTimestamp = formatTimezoneISO(new Date(), this.timezoneOffsetMinutes); const { systemPrompt, userPrompt } = buildSceneExtractionPrompt({ memoriesJson, @@ -434,7 +439,3 @@ export class SceneExtractor { await fs.writeFile(personaPath, updated, "utf-8"); } } - -function formatTimestamp(d: Date): string { - return d.toISOString(); -} diff --git a/src/core/store/sqlite.test.ts b/src/core/store/sqlite.test.ts new file mode 100644 index 0000000..dd3a153 --- /dev/null +++ b/src/core/store/sqlite.test.ts @@ -0,0 +1,48 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { VectorStore } from "./sqlite.js"; + +describe("VectorStore L0 cursor queries", () => { + it("compares recorded_at cursors by instant instead of ISO string order", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "tdai-sqlite-tz-")); + const store = new VectorStore(path.join(dir, "memory.db"), 0); + + try { + store.init({ provider: "none", model: "", dimensions: 0 }); + expect(store.isDegraded()).toBe(false); + + expect(store.upsertL0({ + id: "l0-1", + sessionKey: "session-a", + sessionId: "sid-a", + role: "user", + messageText: "First message with a negative timezone offset.", + recordedAt: "2025-12-31T21:30:00.000-05:00", + timestamp: Date.parse("2026-01-01T02:29:00.000Z"), + })).toBe(true); + expect(store.upsertL0({ + id: "l0-2", + sessionKey: "session-a", + sessionId: "sid-a", + role: "assistant", + messageText: "Second message should be returned after the cursor.", + recordedAt: "2025-12-31T21:31:00.000-05:00", + timestamp: Date.parse("2026-01-01T02:30:00.000Z"), + })).toBe(true); + + const rows = store.queryL0ForL1( + "session-a", + Date.parse("2025-12-31T21:30:00.000-05:00"), + 10, + ); + + expect(rows.map((r) => r.record_id)).toEqual(["l0-2"]); + } finally { + store.close(); + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/core/store/sqlite.ts b/src/core/store/sqlite.ts index 6252419..1cb7014 100644 --- a/src/core/store/sqlite.ts +++ b/src/core/store/sqlite.ts @@ -721,7 +721,7 @@ export class VectorStore implements IMemoryStore { // L0 query statements for L1 runner (newest-first + LIMIT to bound memory) // Sort/filter by recorded_at (write time) instead of timestamp (conversation time) - // because L1 cursor uses recorded_at semantics. ISO 8601 string comparison preserves time order. + // because L1 cursor uses recorded_at semantics. this.stmtL0QueryAll = this.db.prepare(` SELECT record_id, session_key, session_id, role, message_text, recorded_at, timestamp FROM l0_conversations @@ -733,8 +733,8 @@ export class VectorStore implements IMemoryStore { this.stmtL0QueryAfter = this.db.prepare(` SELECT record_id, session_key, session_id, role, message_text, recorded_at, timestamp FROM l0_conversations - WHERE session_key = ? AND recorded_at > ? - ORDER BY recorded_at DESC + WHERE session_key = ? AND julianday(recorded_at) > julianday(?) + ORDER BY julianday(recorded_at) DESC LIMIT ? `); @@ -1881,7 +1881,8 @@ export class VectorStore implements IMemoryStore { // Query newest-first (DESC) with LIMIT, then reverse to chronological order let rows: Array>; if (afterRecordedAtMs && afterRecordedAtMs > 0) { - // Convert epoch ms to ISO string for recorded_at comparison + // Use SQLite's instant-aware datetime parser instead of raw ISO string + // comparison so offsets like -05:00 and +08:00 remain cursor-safe. const afterRecordedAtIso = new Date(afterRecordedAtMs).toISOString(); rows = this.stmtL0QueryAfter.all(sessionKey, afterRecordedAtIso, limit) as Array>; } else { diff --git a/src/offload/time-utils.ts b/src/offload/time-utils.ts index 539cb7b..d1631d5 100644 --- a/src/offload/time-utils.ts +++ b/src/offload/time-utils.ts @@ -2,8 +2,7 @@ * Time utilities — all ISO 8601 timestamps use China Standard Time (UTC+08:00). */ -/** China timezone offset in minutes (+8 hours) */ -const CST_OFFSET_MINUTES = 8 * 60; +import { DEFAULT_TIMEZONE_OFFSET_MINUTES, formatTimezoneISO } from "../utils/timezone.js"; /** * Get the current time as an ISO 8601 string in China Standard Time (UTC+08:00). @@ -18,15 +17,5 @@ export function nowChinaISO(): string { * Format: "YYYY-MM-DDTHH:mm:ss.SSS+08:00" */ export function toChinaISO(date: Date): string { - const utcMs = date.getTime(); - const cstMs = utcMs + CST_OFFSET_MINUTES * 60 * 1000; - const cst = new Date(cstMs); - const year = cst.getUTCFullYear(); - const month = String(cst.getUTCMonth() + 1).padStart(2, "0"); - const day = String(cst.getUTCDate()).padStart(2, "0"); - const hours = String(cst.getUTCHours()).padStart(2, "0"); - const minutes = String(cst.getUTCMinutes()).padStart(2, "0"); - const seconds = String(cst.getUTCSeconds()).padStart(2, "0"); - const ms = String(cst.getUTCMilliseconds()).padStart(3, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${ms}+08:00`; + return formatTimezoneISO(date, DEFAULT_TIMEZONE_OFFSET_MINUTES); } diff --git a/src/utils/pipeline-factory.ts b/src/utils/pipeline-factory.ts index 4595753..391ae0d 100644 --- a/src/utils/pipeline-factory.ts +++ b/src/utils/pipeline-factory.ts @@ -368,6 +368,7 @@ export function createL1Runner(opts: { embeddingService, conflictRecallTopK: cfg.embedding.conflictRecallTopK, embeddingTimeoutMs: cfg.embedding.captureTimeoutMs ?? cfg.embedding.timeoutMs, + timezoneOffsetMinutes: cfg.capture.timezoneOffsetMinutes, llmRunner, }, logger, @@ -514,6 +515,7 @@ export function createL2Runner(opts: { model: cfg.persona.model, maxScenes: cfg.persona.maxScenes, sceneBackupCount: cfg.persona.sceneBackupCount, + timezoneOffsetMinutes: cfg.capture.timezoneOffsetMinutes, logger, instanceId, llmRunner, diff --git a/src/utils/timezone.test.ts b/src/utils/timezone.test.ts new file mode 100644 index 0000000..84ac181 --- /dev/null +++ b/src/utils/timezone.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_TIMEZONE_OFFSET_MINUTES, + formatTimezoneDate, + formatTimezoneISO, +} from "./timezone.js"; + +describe("timezone formatting", () => { + it("formats ISO timestamps with the default UTC+08:00 offset", () => { + const value = formatTimezoneISO( + new Date("2026-01-01T02:30:00.123Z"), + DEFAULT_TIMEZONE_OFFSET_MINUTES, + ); + + expect(value).toBe("2026-01-01T10:30:00.123+08:00"); + }); + + it("formats ISO timestamps with a negative offset", () => { + const value = formatTimezoneISO( + new Date("2026-01-01T02:30:00.000Z"), + -300, + ); + + expect(value).toBe("2025-12-31T21:30:00.000-05:00"); + }); + + it("formats shard dates in the configured timezone", () => { + const value = formatTimezoneDate( + new Date("2026-01-01T02:30:00.000Z"), + -300, + ); + + expect(value).toBe("2025-12-31"); + }); +}); diff --git a/src/utils/timezone.ts b/src/utils/timezone.ts new file mode 100644 index 0000000..24f40c2 --- /dev/null +++ b/src/utils/timezone.ts @@ -0,0 +1,62 @@ +export const DEFAULT_TIMEZONE_OFFSET_MINUTES = 8 * 60; + +const MIN_TIMEZONE_OFFSET_MINUTES = -14 * 60; +const MAX_TIMEZONE_OFFSET_MINUTES = 14 * 60; + +export function normalizeTimezoneOffsetMinutes( + value: number | undefined, +): number { + if ( + value == null || + !Number.isInteger(value) || + value < MIN_TIMEZONE_OFFSET_MINUTES || + value > MAX_TIMEZONE_OFFSET_MINUTES + ) { + return DEFAULT_TIMEZONE_OFFSET_MINUTES; + } + return value; +} + +export function formatTimezoneISO( + date: Date, + timezoneOffsetMinutes = DEFAULT_TIMEZONE_OFFSET_MINUTES, +): string { + const offsetMinutes = normalizeTimezoneOffsetMinutes(timezoneOffsetMinutes); + const shifted = shiftToOffset(date, offsetMinutes); + return `${formatDatePart(shifted)}T${formatTimePart(shifted)}${formatOffset(offsetMinutes)}`; +} + +export function formatTimezoneDate( + date: Date, + timezoneOffsetMinutes = DEFAULT_TIMEZONE_OFFSET_MINUTES, +): string { + const offsetMinutes = normalizeTimezoneOffsetMinutes(timezoneOffsetMinutes); + return formatDatePart(shiftToOffset(date, offsetMinutes)); +} + +function shiftToOffset(date: Date, offsetMinutes: number): Date { + return new Date(date.getTime() + offsetMinutes * 60_000); +} + +function formatDatePart(date: Date): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function formatTimePart(date: Date): string { + const hours = String(date.getUTCHours()).padStart(2, "0"); + const minutes = String(date.getUTCMinutes()).padStart(2, "0"); + const seconds = String(date.getUTCSeconds()).padStart(2, "0"); + const ms = String(date.getUTCMilliseconds()).padStart(3, "0"); + return `${hours}:${minutes}:${seconds}.${ms}`; +} + +function formatOffset(offsetMinutes: number): string { + const sign = offsetMinutes >= 0 ? "+" : "-"; + const abs = Math.abs(offsetMinutes); + const hours = String(Math.floor(abs / 60)).padStart(2, "0"); + const minutes = String(abs % 60).padStart(2, "0"); + return `${sign}${hours}:${minutes}`; +}