dsh-tdai-memory 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +15 -19
- package/README.zh.md +7 -1
- package/THIRD-PARTY-NOTICES.md +20 -20
- package/client.js +2 -2
- package/cordis.patch.yml +8 -8
- package/index.js +13 -22
- package/package.json +94 -78
- package/recall-inject.js +86 -86
- package/vendor/tdai/adapters/index.js +15 -15
- package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
- package/vendor/tdai/adapters/openclaw/index.js +5 -5
- package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
- package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
- package/vendor/tdai/adapters/standalone/index.js +5 -5
- package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
- package/vendor/tdai/core/hooks/auto-capture.js +236 -236
- package/vendor/tdai/core/hooks/auto-recall.js +640 -640
- package/vendor/tdai/core/index.js +8 -8
- package/vendor/tdai/core/persona/persona-generator.js +184 -184
- package/vendor/tdai/core/persona/persona-trigger.js +94 -94
- package/vendor/tdai/core/profile/profile-sync.js +200 -200
- package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
- package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
- package/vendor/tdai/core/prompts/persona-generation.js +35 -35
- package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
- package/vendor/tdai/core/record/l1-dedup.js +318 -318
- package/vendor/tdai/core/record/l1-extractor.js +379 -379
- package/vendor/tdai/core/record/l1-reader.js +178 -178
- package/vendor/tdai/core/record/l1-writer.js +141 -141
- package/vendor/tdai/core/report/reporter.js +86 -86
- package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
- package/vendor/tdai/core/scene/scene-extractor.js +410 -410
- package/vendor/tdai/core/scene/scene-format.js +53 -53
- package/vendor/tdai/core/scene/scene-index.js +82 -82
- package/vendor/tdai/core/scene/scene-navigation.js +57 -57
- package/vendor/tdai/core/seed/input.js +404 -404
- package/vendor/tdai/core/seed/types.js +9 -9
- package/vendor/tdai/core/store/bm25-client.js +117 -117
- package/vendor/tdai/core/store/bm25-local.js +68 -68
- package/vendor/tdai/core/store/embedding.js +632 -632
- package/vendor/tdai/core/store/factory.js +95 -95
- package/vendor/tdai/core/store/search-utils.js +54 -54
- package/vendor/tdai/core/store/sqlite.js +1632 -1632
- package/vendor/tdai/core/store/tcvdb-client.js +221 -221
- package/vendor/tdai/core/store/tcvdb.js +1047 -1047
- package/vendor/tdai/core/store/types.js +17 -17
- package/vendor/tdai/core/tools/conversation-search.js +199 -199
- package/vendor/tdai/core/tools/memory-search.js +207 -207
- package/vendor/tdai/core/types.js +12 -12
- package/vendor/tdai/utils/backup.js +189 -189
- package/vendor/tdai/utils/checkpoint.js +360 -360
- package/vendor/tdai/utils/clean-context-runner.js +472 -472
- package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
- package/vendor/tdai/utils/env.js +13 -13
- package/vendor/tdai/utils/managed-timer.js +127 -127
- package/vendor/tdai/utils/manifest.js +91 -91
- package/vendor/tdai/utils/memory-cleaner.js +300 -300
- package/vendor/tdai/utils/no-think-fetch.js +109 -109
- package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
- package/vendor/tdai/utils/pipeline-factory.js +517 -517
- package/vendor/tdai/utils/pipeline-manager.js +892 -892
- package/vendor/tdai/utils/sanitize.js +360 -360
- package/vendor/tdai/utils/serial-queue.js +103 -103
- package/vendor/tdai/utils/session-filter.js +88 -88
- package/vendor/tdai/utils/text-utils.js +29 -29
- package/vendor/tdai/utils/time.js +252 -252
|
@@ -1,252 +1,252 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unified time module — the single source of truth for all timezone-aware
|
|
3
|
-
* timestamp formatting in the plugin.
|
|
4
|
-
*
|
|
5
|
-
* Other modules MUST NOT directly call `toISOString()`, `getHours()`, or
|
|
6
|
-
* `Intl.DateTimeFormat` for user/LLM-facing timestamps. Import from here instead.
|
|
7
|
-
*
|
|
8
|
-
* Design: module-level singleton. `initTimeModule()` is called once during
|
|
9
|
-
* plugin registration; all subsequent calls read the resolved timezone.
|
|
10
|
-
*/
|
|
11
|
-
// ============================
|
|
12
|
-
// Internal state
|
|
13
|
-
// ============================
|
|
14
|
-
let _resolvedTz = "UTC"; // default, overwritten by initTimeModule()
|
|
15
|
-
let _logger;
|
|
16
|
-
// ============================
|
|
17
|
-
// Initialization
|
|
18
|
-
// ============================
|
|
19
|
-
/**
|
|
20
|
-
* Initialize the time module. Called once during plugin register.
|
|
21
|
-
* Subsequent hot-reloads also go through here.
|
|
22
|
-
*/
|
|
23
|
-
export function initTimeModule(cfg, logger) {
|
|
24
|
-
_resolvedTz = resolveTimeZone(cfg.timezone, logger);
|
|
25
|
-
_logger = logger;
|
|
26
|
-
_logger?.debug?.(`[time] Timezone resolved: "${_resolvedTz}"`);
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Returns the currently active IANA timezone name (or offset string).
|
|
30
|
-
* Useful for diagnostics and prompt generation.
|
|
31
|
-
*/
|
|
32
|
-
export function getActiveTimeZone() {
|
|
33
|
-
return _resolvedTz;
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* @internal test-only — reset to pre-init state.
|
|
37
|
-
* Avoids cross-test pollution when vitest runs multiple tests in the same process.
|
|
38
|
-
*/
|
|
39
|
-
export function _resetTimeModuleForTest() {
|
|
40
|
-
_resolvedTz = "UTC";
|
|
41
|
-
_logger = undefined;
|
|
42
|
-
}
|
|
43
|
-
// ============================
|
|
44
|
-
// A-type: UTC instants (for storage)
|
|
45
|
-
// ============================
|
|
46
|
-
/**
|
|
47
|
-
* Current time as UTC ISO 8601 string with "Z" suffix.
|
|
48
|
-
* Used for SQLite/TCVDB timestamps, cursors, and any machine-compared instants.
|
|
49
|
-
*/
|
|
50
|
-
export function nowInstantISO() {
|
|
51
|
-
return new Date().toISOString();
|
|
52
|
-
}
|
|
53
|
-
// ============================
|
|
54
|
-
// B-type: Local date/datetime (follows configured tz)
|
|
55
|
-
// ============================
|
|
56
|
-
/**
|
|
57
|
-
* Format a Date as "YYYY-MM-DD" in the configured timezone.
|
|
58
|
-
* Used for L0 JSONL shard filenames and cleaner day boundaries.
|
|
59
|
-
*/
|
|
60
|
-
export function formatLocalDate(d = new Date()) {
|
|
61
|
-
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
62
|
-
timeZone: _resolvedTz,
|
|
63
|
-
year: "numeric",
|
|
64
|
-
month: "2-digit",
|
|
65
|
-
day: "2-digit",
|
|
66
|
-
}).formatToParts(d);
|
|
67
|
-
const year = parts.find((p) => p.type === "year").value;
|
|
68
|
-
const month = parts.find((p) => p.type === "month").value;
|
|
69
|
-
const day = parts.find((p) => p.type === "day").value;
|
|
70
|
-
return `${year}-${month}-${day}`;
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Format a Date as "YYYY-MM-DD HH:mm:ss" in the configured timezone.
|
|
74
|
-
* Used for cleaner audit logs and human-readable local timestamps.
|
|
75
|
-
*/
|
|
76
|
-
export function formatLocalDateTime(d = new Date()) {
|
|
77
|
-
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
78
|
-
timeZone: _resolvedTz,
|
|
79
|
-
year: "numeric",
|
|
80
|
-
month: "2-digit",
|
|
81
|
-
day: "2-digit",
|
|
82
|
-
hour: "2-digit",
|
|
83
|
-
minute: "2-digit",
|
|
84
|
-
second: "2-digit",
|
|
85
|
-
hour12: false,
|
|
86
|
-
}).formatToParts(d);
|
|
87
|
-
const get = (type) => parts.find((p) => p.type === type).value;
|
|
88
|
-
return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}:${get("second")}`;
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Compute the start-of-day (00:00:00.000) in the configured timezone for a given date.
|
|
92
|
-
* Returns a UTC millisecond timestamp.
|
|
93
|
-
* Used by memory-cleaner for cutoff calculations.
|
|
94
|
-
*/
|
|
95
|
-
export function startOfLocalDay(d = new Date()) {
|
|
96
|
-
// Get the local date components in the configured timezone
|
|
97
|
-
const dateStr = formatLocalDate(d);
|
|
98
|
-
// Parse as midnight in the configured timezone
|
|
99
|
-
// Use a trick: format "YYYY-MM-DDT00:00:00" and find the UTC equivalent
|
|
100
|
-
const midnightLocal = new Date(`${dateStr}T00:00:00`);
|
|
101
|
-
// We need to find the UTC instant that corresponds to midnight in _resolvedTz.
|
|
102
|
-
// Approach: binary search isn't needed — we can use the timezone offset.
|
|
103
|
-
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
104
|
-
timeZone: _resolvedTz,
|
|
105
|
-
year: "numeric",
|
|
106
|
-
month: "2-digit",
|
|
107
|
-
day: "2-digit",
|
|
108
|
-
hour: "2-digit",
|
|
109
|
-
minute: "2-digit",
|
|
110
|
-
second: "2-digit",
|
|
111
|
-
hour12: false,
|
|
112
|
-
});
|
|
113
|
-
// Start with an estimate: the date at UTC midnight
|
|
114
|
-
const utcMidnight = new Date(`${dateStr}T00:00:00Z`);
|
|
115
|
-
// Check what local time that corresponds to
|
|
116
|
-
const localParts = formatter.formatToParts(utcMidnight);
|
|
117
|
-
const localHour = parseInt(localParts.find((p) => p.type === "hour").value, 10);
|
|
118
|
-
const localMinute = parseInt(localParts.find((p) => p.type === "minute").value, 10);
|
|
119
|
-
const localDay = localParts.find((p) => p.type === "day").value;
|
|
120
|
-
const targetDay = dateStr.slice(8, 10); // DD from YYYY-MM-DD
|
|
121
|
-
// The offset from UTC to local is: if UTC midnight shows as localHour:localMinute on localDay
|
|
122
|
-
// then local midnight = UTC midnight - (localHour*60 + localMinute) minutes
|
|
123
|
-
// But we need to handle day boundary crossings
|
|
124
|
-
let offsetMinutes = localHour * 60 + localMinute;
|
|
125
|
-
if (localDay !== targetDay) {
|
|
126
|
-
// Day wrapped — means local is behind UTC (negative offset zones)
|
|
127
|
-
// e.g. UTC midnight = previous day 19:00 in America/New_York
|
|
128
|
-
offsetMinutes = offsetMinutes - 24 * 60;
|
|
129
|
-
}
|
|
130
|
-
// Local midnight in UTC = UTC midnight - offset
|
|
131
|
-
return utcMidnight.getTime() - offsetMinutes * 60 * 1000;
|
|
132
|
-
}
|
|
133
|
-
// ============================
|
|
134
|
-
// C-type: LLM-facing timestamps (ISO 8601 with offset)
|
|
135
|
-
// ============================
|
|
136
|
-
/**
|
|
137
|
-
* Format a timestamp for LLM consumption: ISO 8601 with explicit UTC offset.
|
|
138
|
-
* Example: "2026-04-07T11:04:45+08:00"
|
|
139
|
-
*
|
|
140
|
-
* Handles:
|
|
141
|
-
* - Date objects
|
|
142
|
-
* - ISO 8601 strings (with or without "Z")
|
|
143
|
-
* - Unix millisecond timestamps (numbers)
|
|
144
|
-
*
|
|
145
|
-
* Old UTC data ("Z" suffix) is correctly converted to the configured timezone.
|
|
146
|
-
*/
|
|
147
|
-
export function formatForLLM(input) {
|
|
148
|
-
const d = input instanceof Date ? input : new Date(input);
|
|
149
|
-
if (isNaN(d.getTime())) {
|
|
150
|
-
return String(input); // pass-through for unparseable values
|
|
151
|
-
}
|
|
152
|
-
// Get components in the configured timezone
|
|
153
|
-
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
154
|
-
timeZone: _resolvedTz,
|
|
155
|
-
year: "numeric",
|
|
156
|
-
month: "2-digit",
|
|
157
|
-
day: "2-digit",
|
|
158
|
-
hour: "2-digit",
|
|
159
|
-
minute: "2-digit",
|
|
160
|
-
second: "2-digit",
|
|
161
|
-
hour12: false,
|
|
162
|
-
}).formatToParts(d);
|
|
163
|
-
const get = (type) => parts.find((p) => p.type === type).value;
|
|
164
|
-
const dateTime = `${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}`;
|
|
165
|
-
// Compute UTC offset for this instant in the configured timezone
|
|
166
|
-
const offset = getUtcOffset(d);
|
|
167
|
-
return `${dateTime}${offset}`;
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Generate a timezone declaration string for system prompts.
|
|
171
|
-
* Example: "All timestamps below are in Asia/Shanghai (UTC+08:00). When reasoning about time, use this timezone."
|
|
172
|
-
*/
|
|
173
|
-
export function describeTimeZoneForPrompt() {
|
|
174
|
-
const offset = getUtcOffset(new Date());
|
|
175
|
-
return `All timestamps below are in ${_resolvedTz} (UTC${offset}). When reasoning about "yesterday", "last week", or time differences, use this timezone.`;
|
|
176
|
-
}
|
|
177
|
-
// ============================
|
|
178
|
-
// Internal helpers
|
|
179
|
-
// ============================
|
|
180
|
-
/**
|
|
181
|
-
* Resolve the timezone configuration string to a validated timezone identifier.
|
|
182
|
-
*
|
|
183
|
-
* Accepts:
|
|
184
|
-
* - "system" or undefined → process system timezone
|
|
185
|
-
* - IANA names: "Asia/Shanghai", "Europe/Berlin", "UTC"
|
|
186
|
-
* - UTC offset strings: "+08:00", "-05:30" (ECMA-402 2024)
|
|
187
|
-
*
|
|
188
|
-
* Invalid values fall back to system timezone with a warning.
|
|
189
|
-
*/
|
|
190
|
-
function resolveTimeZone(cfg, logger) {
|
|
191
|
-
if (!cfg || cfg === "system") {
|
|
192
|
-
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
193
|
-
}
|
|
194
|
-
// Node 22+ Intl natively supports IANA names and UTC offset strings
|
|
195
|
-
// per ECMA-402 2024 — no manual regex/Etc/GMT conversion needed.
|
|
196
|
-
if (validateTimeZone(cfg))
|
|
197
|
-
return cfg;
|
|
198
|
-
logger?.warn?.(`[time] Invalid timezone "${cfg}", falling back to system timezone`);
|
|
199
|
-
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Validate a timezone string using the Intl API.
|
|
203
|
-
* Works for IANA names and UTC offset strings ("+05:30", "-08:00").
|
|
204
|
-
*/
|
|
205
|
-
function validateTimeZone(tz) {
|
|
206
|
-
try {
|
|
207
|
-
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
208
|
-
return true;
|
|
209
|
-
}
|
|
210
|
-
catch {
|
|
211
|
-
return false;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Compute the UTC offset string (e.g. "+08:00", "-05:30", "+00:00")
|
|
216
|
-
* for a given instant in the configured timezone.
|
|
217
|
-
*/
|
|
218
|
-
function getUtcOffset(d) {
|
|
219
|
-
// Strategy: compare the "wall clock" time in the target tz vs UTC
|
|
220
|
-
// to derive the offset for this specific instant (handles DST).
|
|
221
|
-
const utcParts = new Intl.DateTimeFormat("en-CA", {
|
|
222
|
-
timeZone: "UTC",
|
|
223
|
-
year: "numeric",
|
|
224
|
-
month: "2-digit",
|
|
225
|
-
day: "2-digit",
|
|
226
|
-
hour: "2-digit",
|
|
227
|
-
minute: "2-digit",
|
|
228
|
-
hour12: false,
|
|
229
|
-
}).formatToParts(d);
|
|
230
|
-
const localParts = new Intl.DateTimeFormat("en-CA", {
|
|
231
|
-
timeZone: _resolvedTz,
|
|
232
|
-
year: "numeric",
|
|
233
|
-
month: "2-digit",
|
|
234
|
-
day: "2-digit",
|
|
235
|
-
hour: "2-digit",
|
|
236
|
-
minute: "2-digit",
|
|
237
|
-
hour12: false,
|
|
238
|
-
}).formatToParts(d);
|
|
239
|
-
const toMinutes = (parts) => {
|
|
240
|
-
const get = (type) => parseInt(parts.find((p) => p.type === type).value, 10);
|
|
241
|
-
const y = get("year"), mo = get("month"), day = get("day");
|
|
242
|
-
const h = get("hour"), mi = get("minute");
|
|
243
|
-
// Convert to a comparable minute-of-epoch (approximate, good enough for offset calc)
|
|
244
|
-
return ((y * 12 + mo) * 31 + day) * 24 * 60 + h * 60 + mi;
|
|
245
|
-
};
|
|
246
|
-
const diffMinutes = toMinutes(localParts) - toMinutes(utcParts);
|
|
247
|
-
const sign = diffMinutes >= 0 ? "+" : "-";
|
|
248
|
-
const abs = Math.abs(diffMinutes);
|
|
249
|
-
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
|
|
250
|
-
const mm = String(abs % 60).padStart(2, "0");
|
|
251
|
-
return `${sign}${hh}:${mm}`;
|
|
252
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Unified time module — the single source of truth for all timezone-aware
|
|
3
|
+
* timestamp formatting in the plugin.
|
|
4
|
+
*
|
|
5
|
+
* Other modules MUST NOT directly call `toISOString()`, `getHours()`, or
|
|
6
|
+
* `Intl.DateTimeFormat` for user/LLM-facing timestamps. Import from here instead.
|
|
7
|
+
*
|
|
8
|
+
* Design: module-level singleton. `initTimeModule()` is called once during
|
|
9
|
+
* plugin registration; all subsequent calls read the resolved timezone.
|
|
10
|
+
*/
|
|
11
|
+
// ============================
|
|
12
|
+
// Internal state
|
|
13
|
+
// ============================
|
|
14
|
+
let _resolvedTz = "UTC"; // default, overwritten by initTimeModule()
|
|
15
|
+
let _logger;
|
|
16
|
+
// ============================
|
|
17
|
+
// Initialization
|
|
18
|
+
// ============================
|
|
19
|
+
/**
|
|
20
|
+
* Initialize the time module. Called once during plugin register.
|
|
21
|
+
* Subsequent hot-reloads also go through here.
|
|
22
|
+
*/
|
|
23
|
+
export function initTimeModule(cfg, logger) {
|
|
24
|
+
_resolvedTz = resolveTimeZone(cfg.timezone, logger);
|
|
25
|
+
_logger = logger;
|
|
26
|
+
_logger?.debug?.(`[time] Timezone resolved: "${_resolvedTz}"`);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Returns the currently active IANA timezone name (or offset string).
|
|
30
|
+
* Useful for diagnostics and prompt generation.
|
|
31
|
+
*/
|
|
32
|
+
export function getActiveTimeZone() {
|
|
33
|
+
return _resolvedTz;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* @internal test-only — reset to pre-init state.
|
|
37
|
+
* Avoids cross-test pollution when vitest runs multiple tests in the same process.
|
|
38
|
+
*/
|
|
39
|
+
export function _resetTimeModuleForTest() {
|
|
40
|
+
_resolvedTz = "UTC";
|
|
41
|
+
_logger = undefined;
|
|
42
|
+
}
|
|
43
|
+
// ============================
|
|
44
|
+
// A-type: UTC instants (for storage)
|
|
45
|
+
// ============================
|
|
46
|
+
/**
|
|
47
|
+
* Current time as UTC ISO 8601 string with "Z" suffix.
|
|
48
|
+
* Used for SQLite/TCVDB timestamps, cursors, and any machine-compared instants.
|
|
49
|
+
*/
|
|
50
|
+
export function nowInstantISO() {
|
|
51
|
+
return new Date().toISOString();
|
|
52
|
+
}
|
|
53
|
+
// ============================
|
|
54
|
+
// B-type: Local date/datetime (follows configured tz)
|
|
55
|
+
// ============================
|
|
56
|
+
/**
|
|
57
|
+
* Format a Date as "YYYY-MM-DD" in the configured timezone.
|
|
58
|
+
* Used for L0 JSONL shard filenames and cleaner day boundaries.
|
|
59
|
+
*/
|
|
60
|
+
export function formatLocalDate(d = new Date()) {
|
|
61
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
62
|
+
timeZone: _resolvedTz,
|
|
63
|
+
year: "numeric",
|
|
64
|
+
month: "2-digit",
|
|
65
|
+
day: "2-digit",
|
|
66
|
+
}).formatToParts(d);
|
|
67
|
+
const year = parts.find((p) => p.type === "year").value;
|
|
68
|
+
const month = parts.find((p) => p.type === "month").value;
|
|
69
|
+
const day = parts.find((p) => p.type === "day").value;
|
|
70
|
+
return `${year}-${month}-${day}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Format a Date as "YYYY-MM-DD HH:mm:ss" in the configured timezone.
|
|
74
|
+
* Used for cleaner audit logs and human-readable local timestamps.
|
|
75
|
+
*/
|
|
76
|
+
export function formatLocalDateTime(d = new Date()) {
|
|
77
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
78
|
+
timeZone: _resolvedTz,
|
|
79
|
+
year: "numeric",
|
|
80
|
+
month: "2-digit",
|
|
81
|
+
day: "2-digit",
|
|
82
|
+
hour: "2-digit",
|
|
83
|
+
minute: "2-digit",
|
|
84
|
+
second: "2-digit",
|
|
85
|
+
hour12: false,
|
|
86
|
+
}).formatToParts(d);
|
|
87
|
+
const get = (type) => parts.find((p) => p.type === type).value;
|
|
88
|
+
return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}:${get("second")}`;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Compute the start-of-day (00:00:00.000) in the configured timezone for a given date.
|
|
92
|
+
* Returns a UTC millisecond timestamp.
|
|
93
|
+
* Used by memory-cleaner for cutoff calculations.
|
|
94
|
+
*/
|
|
95
|
+
export function startOfLocalDay(d = new Date()) {
|
|
96
|
+
// Get the local date components in the configured timezone
|
|
97
|
+
const dateStr = formatLocalDate(d);
|
|
98
|
+
// Parse as midnight in the configured timezone
|
|
99
|
+
// Use a trick: format "YYYY-MM-DDT00:00:00" and find the UTC equivalent
|
|
100
|
+
const midnightLocal = new Date(`${dateStr}T00:00:00`);
|
|
101
|
+
// We need to find the UTC instant that corresponds to midnight in _resolvedTz.
|
|
102
|
+
// Approach: binary search isn't needed — we can use the timezone offset.
|
|
103
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
104
|
+
timeZone: _resolvedTz,
|
|
105
|
+
year: "numeric",
|
|
106
|
+
month: "2-digit",
|
|
107
|
+
day: "2-digit",
|
|
108
|
+
hour: "2-digit",
|
|
109
|
+
minute: "2-digit",
|
|
110
|
+
second: "2-digit",
|
|
111
|
+
hour12: false,
|
|
112
|
+
});
|
|
113
|
+
// Start with an estimate: the date at UTC midnight
|
|
114
|
+
const utcMidnight = new Date(`${dateStr}T00:00:00Z`);
|
|
115
|
+
// Check what local time that corresponds to
|
|
116
|
+
const localParts = formatter.formatToParts(utcMidnight);
|
|
117
|
+
const localHour = parseInt(localParts.find((p) => p.type === "hour").value, 10);
|
|
118
|
+
const localMinute = parseInt(localParts.find((p) => p.type === "minute").value, 10);
|
|
119
|
+
const localDay = localParts.find((p) => p.type === "day").value;
|
|
120
|
+
const targetDay = dateStr.slice(8, 10); // DD from YYYY-MM-DD
|
|
121
|
+
// The offset from UTC to local is: if UTC midnight shows as localHour:localMinute on localDay
|
|
122
|
+
// then local midnight = UTC midnight - (localHour*60 + localMinute) minutes
|
|
123
|
+
// But we need to handle day boundary crossings
|
|
124
|
+
let offsetMinutes = localHour * 60 + localMinute;
|
|
125
|
+
if (localDay !== targetDay) {
|
|
126
|
+
// Day wrapped — means local is behind UTC (negative offset zones)
|
|
127
|
+
// e.g. UTC midnight = previous day 19:00 in America/New_York
|
|
128
|
+
offsetMinutes = offsetMinutes - 24 * 60;
|
|
129
|
+
}
|
|
130
|
+
// Local midnight in UTC = UTC midnight - offset
|
|
131
|
+
return utcMidnight.getTime() - offsetMinutes * 60 * 1000;
|
|
132
|
+
}
|
|
133
|
+
// ============================
|
|
134
|
+
// C-type: LLM-facing timestamps (ISO 8601 with offset)
|
|
135
|
+
// ============================
|
|
136
|
+
/**
|
|
137
|
+
* Format a timestamp for LLM consumption: ISO 8601 with explicit UTC offset.
|
|
138
|
+
* Example: "2026-04-07T11:04:45+08:00"
|
|
139
|
+
*
|
|
140
|
+
* Handles:
|
|
141
|
+
* - Date objects
|
|
142
|
+
* - ISO 8601 strings (with or without "Z")
|
|
143
|
+
* - Unix millisecond timestamps (numbers)
|
|
144
|
+
*
|
|
145
|
+
* Old UTC data ("Z" suffix) is correctly converted to the configured timezone.
|
|
146
|
+
*/
|
|
147
|
+
export function formatForLLM(input) {
|
|
148
|
+
const d = input instanceof Date ? input : new Date(input);
|
|
149
|
+
if (isNaN(d.getTime())) {
|
|
150
|
+
return String(input); // pass-through for unparseable values
|
|
151
|
+
}
|
|
152
|
+
// Get components in the configured timezone
|
|
153
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
154
|
+
timeZone: _resolvedTz,
|
|
155
|
+
year: "numeric",
|
|
156
|
+
month: "2-digit",
|
|
157
|
+
day: "2-digit",
|
|
158
|
+
hour: "2-digit",
|
|
159
|
+
minute: "2-digit",
|
|
160
|
+
second: "2-digit",
|
|
161
|
+
hour12: false,
|
|
162
|
+
}).formatToParts(d);
|
|
163
|
+
const get = (type) => parts.find((p) => p.type === type).value;
|
|
164
|
+
const dateTime = `${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}`;
|
|
165
|
+
// Compute UTC offset for this instant in the configured timezone
|
|
166
|
+
const offset = getUtcOffset(d);
|
|
167
|
+
return `${dateTime}${offset}`;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Generate a timezone declaration string for system prompts.
|
|
171
|
+
* Example: "All timestamps below are in Asia/Shanghai (UTC+08:00). When reasoning about time, use this timezone."
|
|
172
|
+
*/
|
|
173
|
+
export function describeTimeZoneForPrompt() {
|
|
174
|
+
const offset = getUtcOffset(new Date());
|
|
175
|
+
return `All timestamps below are in ${_resolvedTz} (UTC${offset}). When reasoning about "yesterday", "last week", or time differences, use this timezone.`;
|
|
176
|
+
}
|
|
177
|
+
// ============================
|
|
178
|
+
// Internal helpers
|
|
179
|
+
// ============================
|
|
180
|
+
/**
|
|
181
|
+
* Resolve the timezone configuration string to a validated timezone identifier.
|
|
182
|
+
*
|
|
183
|
+
* Accepts:
|
|
184
|
+
* - "system" or undefined → process system timezone
|
|
185
|
+
* - IANA names: "Asia/Shanghai", "Europe/Berlin", "UTC"
|
|
186
|
+
* - UTC offset strings: "+08:00", "-05:30" (ECMA-402 2024)
|
|
187
|
+
*
|
|
188
|
+
* Invalid values fall back to system timezone with a warning.
|
|
189
|
+
*/
|
|
190
|
+
function resolveTimeZone(cfg, logger) {
|
|
191
|
+
if (!cfg || cfg === "system") {
|
|
192
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
193
|
+
}
|
|
194
|
+
// Node 22+ Intl natively supports IANA names and UTC offset strings
|
|
195
|
+
// per ECMA-402 2024 — no manual regex/Etc/GMT conversion needed.
|
|
196
|
+
if (validateTimeZone(cfg))
|
|
197
|
+
return cfg;
|
|
198
|
+
logger?.warn?.(`[time] Invalid timezone "${cfg}", falling back to system timezone`);
|
|
199
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Validate a timezone string using the Intl API.
|
|
203
|
+
* Works for IANA names and UTC offset strings ("+05:30", "-08:00").
|
|
204
|
+
*/
|
|
205
|
+
function validateTimeZone(tz) {
|
|
206
|
+
try {
|
|
207
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Compute the UTC offset string (e.g. "+08:00", "-05:30", "+00:00")
|
|
216
|
+
* for a given instant in the configured timezone.
|
|
217
|
+
*/
|
|
218
|
+
function getUtcOffset(d) {
|
|
219
|
+
// Strategy: compare the "wall clock" time in the target tz vs UTC
|
|
220
|
+
// to derive the offset for this specific instant (handles DST).
|
|
221
|
+
const utcParts = new Intl.DateTimeFormat("en-CA", {
|
|
222
|
+
timeZone: "UTC",
|
|
223
|
+
year: "numeric",
|
|
224
|
+
month: "2-digit",
|
|
225
|
+
day: "2-digit",
|
|
226
|
+
hour: "2-digit",
|
|
227
|
+
minute: "2-digit",
|
|
228
|
+
hour12: false,
|
|
229
|
+
}).formatToParts(d);
|
|
230
|
+
const localParts = new Intl.DateTimeFormat("en-CA", {
|
|
231
|
+
timeZone: _resolvedTz,
|
|
232
|
+
year: "numeric",
|
|
233
|
+
month: "2-digit",
|
|
234
|
+
day: "2-digit",
|
|
235
|
+
hour: "2-digit",
|
|
236
|
+
minute: "2-digit",
|
|
237
|
+
hour12: false,
|
|
238
|
+
}).formatToParts(d);
|
|
239
|
+
const toMinutes = (parts) => {
|
|
240
|
+
const get = (type) => parseInt(parts.find((p) => p.type === type).value, 10);
|
|
241
|
+
const y = get("year"), mo = get("month"), day = get("day");
|
|
242
|
+
const h = get("hour"), mi = get("minute");
|
|
243
|
+
// Convert to a comparable minute-of-epoch (approximate, good enough for offset calc)
|
|
244
|
+
return ((y * 12 + mo) * 31 + day) * 24 * 60 + h * 60 + mi;
|
|
245
|
+
};
|
|
246
|
+
const diffMinutes = toMinutes(localParts) - toMinutes(utcParts);
|
|
247
|
+
const sign = diffMinutes >= 0 ? "+" : "-";
|
|
248
|
+
const abs = Math.abs(diffMinutes);
|
|
249
|
+
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
|
|
250
|
+
const mm = String(abs % 60).padStart(2, "0");
|
|
251
|
+
return `${sign}${hh}:${mm}`;
|
|
252
|
+
}
|