dsh-tdai-memory 0.2.13 → 0.2.14

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