dsh-tdai-memory 0.2.13 → 0.3.0

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.
Files changed (72) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +15 -13
  3. package/README.zh.md +9 -7
  4. package/THIRD-PARTY-NOTICES.md +20 -20
  5. package/client.js +21 -4
  6. package/cordis.patch.yml +8 -8
  7. package/index.js +36 -14
  8. package/package.json +77 -78
  9. package/recall-inject.js +86 -86
  10. package/vendor/tdai/adapters/index.js +15 -15
  11. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  12. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  13. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  14. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  15. package/vendor/tdai/adapters/standalone/index.js +5 -5
  16. package/vendor/tdai/adapters/standalone/llm-runner.js +34 -6
  17. package/vendor/tdai/config.js +3 -0
  18. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  19. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  20. package/vendor/tdai/core/hooks/auto-recall.js +640 -640
  21. package/vendor/tdai/core/index.js +8 -8
  22. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  23. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  24. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  25. package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
  26. package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
  27. package/vendor/tdai/core/prompts/persona-generation.js +35 -35
  28. package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
  29. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  30. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  31. package/vendor/tdai/core/record/l1-reader.js +178 -178
  32. package/vendor/tdai/core/record/l1-writer.js +141 -141
  33. package/vendor/tdai/core/report/reporter.js +86 -86
  34. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  35. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  36. package/vendor/tdai/core/scene/scene-format.js +53 -53
  37. package/vendor/tdai/core/scene/scene-index.js +82 -82
  38. package/vendor/tdai/core/scene/scene-navigation.js +57 -57
  39. package/vendor/tdai/core/seed/input.js +404 -404
  40. package/vendor/tdai/core/seed/seed-runtime.js +4 -0
  41. package/vendor/tdai/core/seed/types.js +9 -9
  42. package/vendor/tdai/core/store/bm25-client.js +117 -117
  43. package/vendor/tdai/core/store/bm25-local.js +68 -68
  44. package/vendor/tdai/core/store/embedding.js +632 -632
  45. package/vendor/tdai/core/store/factory.js +95 -95
  46. package/vendor/tdai/core/store/search-utils.js +54 -54
  47. package/vendor/tdai/core/store/sqlite.js +1632 -1632
  48. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  49. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  50. package/vendor/tdai/core/store/types.js +17 -17
  51. package/vendor/tdai/core/tdai-core.js +4 -0
  52. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  53. package/vendor/tdai/core/tools/memory-search.js +207 -207
  54. package/vendor/tdai/core/types.js +12 -12
  55. package/vendor/tdai/utils/backup.js +189 -189
  56. package/vendor/tdai/utils/checkpoint.js +360 -360
  57. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  58. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  59. package/vendor/tdai/utils/env.js +13 -13
  60. package/vendor/tdai/utils/managed-timer.js +127 -127
  61. package/vendor/tdai/utils/manifest.js +91 -91
  62. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  63. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  64. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  65. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  66. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  67. package/vendor/tdai/utils/sanitize.js +360 -360
  68. package/vendor/tdai/utils/serial-queue.js +103 -103
  69. package/vendor/tdai/utils/session-filter.js +88 -88
  70. package/vendor/tdai/utils/session-header-fetch.js +88 -0
  71. package/vendor/tdai/utils/text-utils.js +29 -29
  72. 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
+ }