@pellux/goodvibes-agent 1.4.4 → 1.5.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 (227) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -7
  3. package/dist/package/main.js +7424 -12241
  4. package/docs/README.md +2 -2
  5. package/docs/channels-remote-and-api.md +1 -1
  6. package/docs/getting-started.md +2 -2
  7. package/docs/release-and-publishing.md +1 -1
  8. package/docs/tools-and-commands.md +5 -5
  9. package/package.json +4 -2
  10. package/release/performance-snapshot.json +2 -2
  11. package/release/release-notes.md +11 -7
  12. package/release/release-readiness.json +6 -6
  13. package/src/agent/behavior-discovery-summary.ts +4 -18
  14. package/src/agent/calendar-registry.ts +322 -0
  15. package/src/agent/competitive-feature-inventory.ts +268 -130
  16. package/src/agent/document-registry.ts +5 -1
  17. package/src/agent/email/email-service.ts +350 -0
  18. package/src/agent/email/imap-client.ts +596 -0
  19. package/src/agent/email/smtp-client.ts +453 -0
  20. package/src/agent/ics-calendar.ts +662 -0
  21. package/src/agent/ics-timezone.ts +172 -0
  22. package/src/agent/markdown-frontmatter.ts +31 -0
  23. package/src/agent/memory-safety.ts +1 -1
  24. package/src/agent/note-registry.ts +3 -9
  25. package/src/agent/persona-discovery.ts +3 -15
  26. package/src/agent/persona-registry.ts +1 -10
  27. package/src/agent/research-source-registry.ts +5 -1
  28. package/src/agent/routine-discovery.ts +3 -15
  29. package/src/agent/runtime-profile.ts +3 -0
  30. package/src/agent/skill-discovery.ts +3 -15
  31. package/src/agent/skill-draft-proposer.ts +203 -0
  32. package/src/agent/skill-draft-runner.ts +201 -0
  33. package/src/agent/skill-registry.ts +36 -1
  34. package/src/agent/skill-standard.ts +99 -0
  35. package/src/agent/vibe-file.ts +7 -22
  36. package/src/cli/completion.ts +1 -1
  37. package/src/cli/config-overrides.ts +10 -1
  38. package/src/cli/redaction.ts +17 -5
  39. package/src/config/provider-model.ts +2 -1
  40. package/src/config/secret-config.ts +13 -6
  41. package/src/core/activity-feed.ts +97 -0
  42. package/src/core/away-digest.ts +161 -0
  43. package/src/core/conversation-rendering.ts +7 -3
  44. package/src/core/conversation.ts +22 -15
  45. package/src/core/hardware-profile.ts +362 -0
  46. package/src/core/last-seen-store.ts +78 -0
  47. package/src/core/plain-language.ts +52 -0
  48. package/src/core/setup-incomplete-hint.ts +90 -0
  49. package/src/core/system-message-router.ts +38 -87
  50. package/src/input/agent-workspace-basic-command-editor-submission.ts +60 -220
  51. package/src/input/agent-workspace-basic-command-editors.ts +39 -141
  52. package/src/input/agent-workspace-categories.ts +40 -125
  53. package/src/input/agent-workspace-command-editor.ts +4 -0
  54. package/src/input/agent-workspace-host-category.ts +0 -5
  55. package/src/input/agent-workspace-local-editor-submission.ts +3 -1
  56. package/src/input/agent-workspace-navigation.ts +10 -3
  57. package/src/input/agent-workspace-onboarding-actions.ts +132 -0
  58. package/src/input/agent-workspace-onboarding-categories.ts +29 -26
  59. package/src/input/agent-workspace-onboarding-finish.ts +11 -4
  60. package/src/input/agent-workspace-onboarding-state.ts +111 -0
  61. package/src/input/agent-workspace-profile-editor-submission.ts +260 -0
  62. package/src/input/agent-workspace-profile-editors.ts +155 -0
  63. package/src/input/agent-workspace-subscription-editor.ts +7 -0
  64. package/src/input/agent-workspace-types.ts +5 -1
  65. package/src/input/agent-workspace.ts +65 -39
  66. package/src/input/command-registry.ts +18 -5
  67. package/src/input/commands/agent-runtime-profile-runtime.ts +2 -1
  68. package/src/input/commands/agent-skills-runtime.ts +60 -3
  69. package/src/input/commands/calendar-runtime.ts +209 -0
  70. package/src/input/commands/channels-runtime.ts +1 -0
  71. package/src/input/commands/command-error.ts +9 -0
  72. package/src/input/commands/compat-runtime.ts +1 -0
  73. package/src/input/commands/config.ts +1 -0
  74. package/src/input/commands/conversation-runtime.ts +1 -1
  75. package/src/input/commands/delegation-runtime.ts +5 -6
  76. package/src/input/commands/email-runtime.ts +387 -0
  77. package/src/input/commands/experience-runtime.ts +17 -4
  78. package/src/input/commands/guidance-runtime.ts +1 -1
  79. package/src/input/commands/health-runtime.ts +6 -1
  80. package/src/input/commands/knowledge-format.ts +73 -0
  81. package/src/input/commands/knowledge.ts +9 -74
  82. package/src/input/commands/local-provider-runtime.ts +2 -1
  83. package/src/input/commands/local-runtime.ts +10 -4
  84. package/src/input/commands/mcp-runtime.ts +7 -0
  85. package/src/input/commands/notify-runtime.ts +17 -1
  86. package/src/input/commands/onboarding-runtime.ts +1 -0
  87. package/src/input/commands/operator-actions-runtime.ts +1 -0
  88. package/src/input/commands/operator-runtime.ts +3 -0
  89. package/src/input/commands/personas-runtime.ts +1 -0
  90. package/src/input/commands/platform-access-runtime.ts +40 -17
  91. package/src/input/commands/product-runtime.ts +6 -1
  92. package/src/input/commands/provider-accounts-runtime.ts +3 -2
  93. package/src/input/commands/qrcode-runtime.ts +1 -0
  94. package/src/input/commands/routines-runtime.ts +1 -0
  95. package/src/input/commands/runtime-services.ts +0 -13
  96. package/src/input/commands/security-runtime.ts +1 -0
  97. package/src/input/commands/session-content.ts +1 -0
  98. package/src/input/commands/shell-core.ts +5 -2
  99. package/src/input/commands/subscription-runtime.ts +33 -9
  100. package/src/input/commands/support-bundle-runtime.ts +8 -1
  101. package/src/input/commands/tasks-runtime.ts +1 -0
  102. package/src/input/commands/tts-runtime.ts +1 -0
  103. package/src/input/commands/vibe-runtime.ts +1 -0
  104. package/src/input/commands.ts +4 -0
  105. package/src/input/feed-context-factory.ts +3 -12
  106. package/src/input/handler-command-route.ts +7 -60
  107. package/src/input/handler-feed-routes.ts +0 -194
  108. package/src/input/handler-feed.ts +2 -54
  109. package/src/input/handler-interactions.ts +0 -2
  110. package/src/input/handler-modal-stack.ts +0 -9
  111. package/src/input/handler-picker-routes.ts +24 -1
  112. package/src/input/handler-shortcuts.ts +11 -40
  113. package/src/input/handler-ui-state.ts +13 -0
  114. package/src/input/handler.ts +8 -35
  115. package/src/input/keybindings.ts +40 -17
  116. package/src/input/model-picker-local-fit.ts +130 -0
  117. package/src/input/submission-router.ts +0 -5
  118. package/src/main.ts +111 -89
  119. package/src/renderer/activity-sidebar.ts +186 -0
  120. package/src/renderer/agent-workspace-context-lines.ts +5 -48
  121. package/src/renderer/agent-workspace-style.ts +1 -1
  122. package/src/renderer/agent-workspace.ts +14 -2
  123. package/src/renderer/compositor.ts +37 -125
  124. package/src/renderer/conversation-overlays.ts +3 -3
  125. package/src/renderer/help-overlay.ts +7 -5
  126. package/src/renderer/model-workspace.ts +101 -86
  127. package/src/{panels → renderer}/polish.ts +3 -3
  128. package/src/renderer/shell-surface.ts +4 -5
  129. package/src/renderer/status-token.ts +31 -11
  130. package/src/renderer/tab-strip.ts +1 -1
  131. package/src/renderer/tool-call.ts +7 -8
  132. package/src/renderer/tool-labels.ts +92 -0
  133. package/src/renderer/ui-factory.ts +48 -96
  134. package/src/runtime/bootstrap-command-context.ts +0 -5
  135. package/src/runtime/bootstrap-command-parts.ts +6 -15
  136. package/src/runtime/bootstrap-core.ts +34 -13
  137. package/src/runtime/bootstrap-hook-bridge.ts +3 -1
  138. package/src/runtime/bootstrap-shell.ts +3 -50
  139. package/src/runtime/bootstrap.ts +3 -4
  140. package/src/runtime/context.ts +1 -1
  141. package/src/runtime/diagnostics/panels/index.ts +0 -1
  142. package/src/runtime/diagnostics/panels/policy.ts +1 -1
  143. package/src/runtime/execution-ledger.ts +16 -1
  144. package/src/runtime/index.ts +6 -1
  145. package/src/runtime/onboarding/index.ts +1 -0
  146. package/src/runtime/onboarding/onboarding-state.ts +200 -0
  147. package/src/{panels → runtime}/provider-account-snapshot.ts +5 -2
  148. package/src/runtime/services.ts +16 -4
  149. package/src/runtime/terminal-output-guard.ts +7 -0
  150. package/src/runtime/tool-permission-safety.ts +1 -1
  151. package/src/runtime/ui/model-picker/data-provider.ts +1 -1
  152. package/src/runtime/ui/model-picker/health-enrichment.ts +2 -2
  153. package/src/runtime/ui/model-picker/types.ts +2 -2
  154. package/src/runtime/ui/provider-health/data-provider.ts +3 -3
  155. package/src/runtime/ui/provider-health/types.ts +2 -2
  156. package/src/runtime/ui-services.ts +0 -2
  157. package/src/shell/agent-workspace-fullscreen.ts +0 -1
  158. package/src/shell/autonomy-surfacing.ts +272 -0
  159. package/src/shell/session-continuity-hints.ts +0 -6
  160. package/src/shell/startup-wiring.ts +221 -0
  161. package/src/shell/ui-openers.ts +18 -29
  162. package/src/tools/agent-context-policy.ts +1 -1
  163. package/src/tools/agent-harness-background-processes.ts +4 -4
  164. package/src/tools/agent-harness-browser-cockpit-route.ts +3 -3
  165. package/src/tools/agent-harness-command-runner.ts +6 -1
  166. package/src/tools/agent-harness-document-ops.ts +26 -53
  167. package/src/tools/agent-harness-execution-history.ts +1 -13
  168. package/src/tools/agent-harness-execution-posture.ts +0 -15
  169. package/src/tools/agent-harness-keybinding-metadata.ts +28 -29
  170. package/src/tools/agent-harness-local-model-cookbook.ts +24 -1
  171. package/src/tools/agent-harness-metadata.ts +16 -0
  172. package/src/tools/agent-harness-mode-catalog.ts +2 -9
  173. package/src/tools/agent-harness-provider-account-metadata.ts +2 -2
  174. package/src/tools/agent-harness-setup-posture-utils.ts +1 -1
  175. package/src/tools/agent-harness-tool-schema.ts +13 -14
  176. package/src/tools/agent-harness-tool.ts +27 -23
  177. package/src/tools/agent-harness-ui-surface-metadata.ts +10 -20
  178. package/src/tools/agent-harness-workspace-action-runner.ts +3 -4
  179. package/src/tools/agent-workspace-tool.ts +2 -33
  180. package/src/utils/terminal-width.ts +9 -3
  181. package/src/version.ts +1 -1
  182. package/src/input/agent-workspace-panel-route.ts +0 -44
  183. package/src/input/panel-integration-actions.ts +0 -26
  184. package/src/panels/approval-panel.ts +0 -149
  185. package/src/panels/automation-control-panel.ts +0 -212
  186. package/src/panels/base-panel.ts +0 -254
  187. package/src/panels/builtin/agent.ts +0 -58
  188. package/src/panels/builtin/knowledge.ts +0 -26
  189. package/src/panels/builtin/operations.ts +0 -121
  190. package/src/panels/builtin/session.ts +0 -138
  191. package/src/panels/builtin/shared.ts +0 -275
  192. package/src/panels/builtin/usage.ts +0 -21
  193. package/src/panels/builtin-panels.ts +0 -23
  194. package/src/panels/confirm-state.ts +0 -61
  195. package/src/panels/context-visualizer-panel.ts +0 -204
  196. package/src/panels/cost-tracker-panel.ts +0 -444
  197. package/src/panels/docs-panel.ts +0 -285
  198. package/src/panels/index.ts +0 -25
  199. package/src/panels/knowledge-panel.ts +0 -417
  200. package/src/panels/memory-panel.ts +0 -226
  201. package/src/panels/panel-list-panel.ts +0 -464
  202. package/src/panels/panel-manager.ts +0 -570
  203. package/src/panels/provider-accounts-panel.ts +0 -233
  204. package/src/panels/provider-health-domains.ts +0 -208
  205. package/src/panels/provider-health-panel.ts +0 -720
  206. package/src/panels/provider-health-tracker.ts +0 -115
  207. package/src/panels/provider-stats-panel.ts +0 -366
  208. package/src/panels/qr-panel.ts +0 -207
  209. package/src/panels/schedule-panel.ts +0 -321
  210. package/src/panels/scrollable-list-panel.ts +0 -491
  211. package/src/panels/search-focus.ts +0 -32
  212. package/src/panels/security-panel.ts +0 -295
  213. package/src/panels/session-browser-panel.ts +0 -395
  214. package/src/panels/session-maintenance.ts +0 -125
  215. package/src/panels/subscription-panel.ts +0 -263
  216. package/src/panels/system-messages-panel.ts +0 -230
  217. package/src/panels/tasks-panel.ts +0 -344
  218. package/src/panels/thinking-panel.ts +0 -304
  219. package/src/panels/token-budget-panel.ts +0 -475
  220. package/src/panels/tool-inspector-panel.ts +0 -436
  221. package/src/panels/types.ts +0 -54
  222. package/src/renderer/panel-composite.ts +0 -158
  223. package/src/renderer/panel-tab-bar.ts +0 -69
  224. package/src/renderer/panel-workspace-bar.ts +0 -42
  225. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  226. package/src/tools/agent-harness-panel-metadata.ts +0 -211
  227. /package/src/runtime/perf/{panel-health-monitor.ts → component-health.ts} +0 -0
@@ -0,0 +1,662 @@
1
+ /**
2
+ * ics-calendar.ts — Dependency-free iCalendar (RFC 5545) parse and render.
3
+ *
4
+ * RRULE boundary: only FREQ=DAILY, FREQ=WEEKLY, and FREQ=MONTHLY are expanded.
5
+ * COUNT and UNTIL (DATE and DATE-TIME forms) are honoured. INTERVAL, BYDAY,
6
+ * BYMONTHDAY, EXDATE, and all other recurrence keywords are NOT processed —
7
+ * occurrences that would require them are silently omitted. Callers should
8
+ * treat the `rrule` field as informational for any rule that includes those
9
+ * keywords. Expansion is capped at 90 days from the reference date passed to
10
+ * `expandRecurringEvent`.
11
+ *
12
+ * Timezone handling:
13
+ * - UTC form (DTSTART:...Z): stored and emitted in UTC. No conversion needed.
14
+ * - Floating form (no TZID): treated as UTC for comparison purposes (existing
15
+ * behaviour, documented). Emitted without Z suffix.
16
+ * - TZID-qualified form: converted to a correct UTC instant via Intl-based
17
+ * offset derivation (see ics-timezone.ts). The TZID and original wall-clock
18
+ * are stored and re-emitted on render so round-trips preserve zone identity.
19
+ * Note: we emit DTSTART;TZID=Zone:YYYYMMDDTHHMMSS without a VTIMEZONE block.
20
+ * Tolerant readers (Google Calendar, Apple Calendar, Outlook) accept TZID
21
+ * without VTIMEZONE when the name is a valid IANA zone identifier; conformant
22
+ * strict readers may reject it. This is a documented limitation.
23
+ */
24
+
25
+ import { wallClockToUtcMs, utcMsToWallClock } from './ics-timezone.ts';
26
+
27
+ // ──────────────────────────────────────────────────────────────────
28
+ // Types
29
+ // ──────────────────────────────────────────────────────────────────
30
+
31
+ export interface IcsEvent {
32
+ /** UID property value */
33
+ readonly uid: string;
34
+ /** SUMMARY text (unescaped) */
35
+ readonly summary: string;
36
+ /** DESCRIPTION text (unescaped), if present */
37
+ readonly description?: string;
38
+ /** LOCATION text (unescaped), if present */
39
+ readonly location?: string;
40
+ /**
41
+ * DTSTART as an ISO-8601 string.
42
+ * All-day events use YYYY-MM-DD form.
43
+ * UTC events use YYYY-MM-DDTHH:MM:SSZ form.
44
+ * TZID-qualified events use YYYY-MM-DDTHH:MM:SS (wall-clock, no Z suffix).
45
+ * Floating events (no TZID, no Z) use YYYY-MM-DDTHH:MM:SS (treated as UTC).
46
+ */
47
+ readonly dtstart: string;
48
+ /**
49
+ * DTEND as an ISO-8601 string, if present.
50
+ * Follows same convention as dtstart.
51
+ */
52
+ readonly dtend?: string;
53
+ /** True when DTSTART is a DATE (all-day) value */
54
+ readonly allDay: boolean;
55
+ /** TZID parameter from DTSTART, if any */
56
+ readonly tzid?: string;
57
+ /**
58
+ * Corrected UTC instant (ms since epoch) for the DTSTART.
59
+ * Populated for TZID-qualified events via Intl-based offset derivation.
60
+ * Undefined for all-day events, UTC events (use Date.parse(dtstart) instead),
61
+ * and floating events.
62
+ */
63
+ readonly dtstartUtcMs?: number;
64
+ /** Raw RRULE string, if any */
65
+ readonly rrule?: string;
66
+ }
67
+
68
+ // ──────────────────────────────────────────────────────────────────
69
+ // Internal helpers
70
+ // ──────────────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Unfold continuation lines: CRLF (or bare LF) followed by a single space or
74
+ * tab is a logical-line continuation per RFC 5545 §3.1.
75
+ */
76
+ function unfoldLines(content: string): string {
77
+ return content.replace(/\r?\n[ \t]/g, '');
78
+ }
79
+
80
+ /** Split content into logical lines after unfolding. */
81
+ function logicalLines(content: string): string[] {
82
+ return unfoldLines(content).split(/\r?\n/);
83
+ }
84
+
85
+ /** Unescape RFC 5545 text-value escapes: \\n → newline, \\\\ → \\, \\; → ;, \\, → , */
86
+ function unescapeText(value: string): string {
87
+ return value
88
+ .replace(/\\n/gi, '\n')
89
+ .replace(/\\;/g, ';')
90
+ .replace(/\\,/g, ',')
91
+ .replace(/\\\\/g, '\\');
92
+ }
93
+
94
+ /** Escape text for iCalendar output. */
95
+ function escapeText(value: string): string {
96
+ return value
97
+ .replace(/\\/g, '\\\\')
98
+ .replace(/;/g, '\\;')
99
+ .replace(/,/g, '\\,')
100
+ .replace(/\n/g, '\\n');
101
+ }
102
+
103
+ /**
104
+ * Fold a single content line at 75 octets (UTF-8 bytes), continuing with
105
+ * CRLF + SPACE per RFC 5545 §3.1.
106
+ */
107
+ export function foldLine(line: string): string {
108
+ const encoder = new TextEncoder();
109
+ const bytes = encoder.encode(line);
110
+ if (bytes.length <= 75) return line;
111
+
112
+ const decoder = new TextDecoder();
113
+ const chunks: string[] = [];
114
+ let offset = 0;
115
+ let firstChunk = true;
116
+ while (offset < bytes.length) {
117
+ const limit = firstChunk ? 75 : 74; // first line has no leading space
118
+ // Slice at limit, then back off if we'd split a multi-byte sequence.
119
+ let end = Math.min(offset + limit, bytes.length);
120
+ while (end > offset + 1 && (bytes[end]! & 0xc0) === 0x80) end--;
121
+ chunks.push(decoder.decode(bytes.slice(offset, end)));
122
+ offset = end;
123
+ firstChunk = false;
124
+ }
125
+ return chunks.join('\r\n ');
126
+ }
127
+
128
+ /**
129
+ * Parse a DTSTART / DTEND value string (with optional TZID parameter).
130
+ * Returns { iso, allDay, tzid, utcMs }.
131
+ *
132
+ * Supported value forms:
133
+ * DATE: YYYYMMDD
134
+ * DATE-TIME: YYYYMMDDTHHMMSS (local / TZID)
135
+ * DATE-TIME UTC: YYYYMMDDTHHMMSSZ
136
+ *
137
+ * When a TZID is present, utcMs is set to the correctly converted UTC instant
138
+ * using Intl-based offset derivation. For unknown TZIDs, utcMs falls back to
139
+ * treating the wall-clock as UTC (documented limitation).
140
+ */
141
+ function parseDateValue(
142
+ value: string,
143
+ tzid?: string,
144
+ ): { iso: string; allDay: boolean; tzid?: string; utcMs?: number } {
145
+ const trimmed = value.trim();
146
+ // All-day: pure 8-digit date
147
+ if (/^\d{8}$/.test(trimmed)) {
148
+ const iso = `${trimmed.slice(0, 4)}-${trimmed.slice(4, 6)}-${trimmed.slice(6, 8)}`;
149
+ return { iso, allDay: true };
150
+ }
151
+ // Date-time forms: YYYYMMDDTHHMMSS[Z]
152
+ const m = trimmed.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/);
153
+ if (m) {
154
+ const isUtc = m[7] === 'Z';
155
+ const utcSuffix = isUtc ? 'Z' : '';
156
+ const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${utcSuffix}`;
157
+ if (isUtc) {
158
+ // UTC form: no TZID, utcMs derivable directly from iso
159
+ return { iso, allDay: false };
160
+ }
161
+ if (tzid) {
162
+ // TZID-qualified: convert wall-clock to correct UTC instant
163
+ const utcMs = wallClockToUtcMs(iso, tzid);
164
+ return { iso, allDay: false, tzid, utcMs };
165
+ }
166
+ // Floating: no TZID, no Z suffix — treated as UTC for comparison
167
+ return { iso, allDay: false };
168
+ }
169
+ // Fallback: return as-is
170
+ return { iso: trimmed, allDay: false, tzid };
171
+ }
172
+
173
+ /**
174
+ * Parse a property line into name, params map, and value.
175
+ * Handles "DTSTART;TZID=America/New_York:20240101T090000" etc.
176
+ */
177
+ function parsePropLine(line: string): { name: string; params: Map<string, string>; value: string } | null {
178
+ const colonIdx = line.indexOf(':');
179
+ if (colonIdx < 0) return null;
180
+ const head = line.slice(0, colonIdx);
181
+ const value = line.slice(colonIdx + 1);
182
+ const parts = head.split(';');
183
+ const name = (parts[0] ?? '').toUpperCase();
184
+ const params = new Map<string, string>();
185
+ for (let i = 1; i < parts.length; i++) {
186
+ const eq = parts[i]!.indexOf('=');
187
+ if (eq >= 0) {
188
+ params.set(parts[i]!.slice(0, eq).toUpperCase(), parts[i]!.slice(eq + 1));
189
+ }
190
+ }
191
+ return { name, params, value };
192
+ }
193
+
194
+ // ──────────────────────────────────────────────────────────────────
195
+ // Parser
196
+ // ──────────────────────────────────────────────────────────────────
197
+
198
+ /**
199
+ * Parse iCalendar text into an array of IcsEvent objects.
200
+ *
201
+ * Tolerant of: unknown component types, unknown properties, missing DTEND,
202
+ * malformed property lines, and non-standard line endings.
203
+ */
204
+ export function parseIcs(content: string): IcsEvent[] {
205
+ const events: IcsEvent[] = [];
206
+ const lines = logicalLines(content);
207
+
208
+ let inVevent = false;
209
+ let uid = '';
210
+ let summary = '';
211
+ let description: string | undefined;
212
+ let location: string | undefined;
213
+ let dtstart = '';
214
+ let dtend: string | undefined;
215
+ let allDay = false;
216
+ let tzid: string | undefined;
217
+ let dtstartUtcMs: number | undefined;
218
+ let rrule: string | undefined;
219
+ let depth = 0; // tracks nested BEGIN/END for unknown components
220
+
221
+ for (const raw of lines) {
222
+ const line = raw.trim();
223
+ if (!line) continue;
224
+
225
+ if (/^BEGIN:/i.test(line)) {
226
+ const component = line.slice(6).toUpperCase();
227
+ if (component === 'VEVENT') {
228
+ inVevent = true;
229
+ depth = 0;
230
+ uid = '';
231
+ summary = '';
232
+ description = undefined;
233
+ location = undefined;
234
+ dtstart = '';
235
+ dtend = undefined;
236
+ allDay = false;
237
+ tzid = undefined;
238
+ dtstartUtcMs = undefined;
239
+ rrule = undefined;
240
+ } else if (inVevent) {
241
+ depth++; // nested component inside VEVENT (e.g. VALARM)
242
+ }
243
+ continue;
244
+ }
245
+
246
+ if (/^END:/i.test(line)) {
247
+ const component = line.slice(4).toUpperCase();
248
+ if (component === 'VEVENT') {
249
+ if (dtstart && summary) {
250
+ events.push({
251
+ uid: uid || dtstart,
252
+ summary,
253
+ description,
254
+ location,
255
+ dtstart,
256
+ dtend,
257
+ allDay,
258
+ tzid: tzid || undefined,
259
+ dtstartUtcMs,
260
+ rrule,
261
+ });
262
+ }
263
+ inVevent = false;
264
+ } else if (inVevent && depth > 0) {
265
+ depth--;
266
+ }
267
+ continue;
268
+ }
269
+
270
+ if (!inVevent || depth > 0) continue;
271
+
272
+ const prop = parsePropLine(line);
273
+ if (!prop) continue;
274
+
275
+ switch (prop.name) {
276
+ case 'UID': {
277
+ uid = prop.value.trim();
278
+ break;
279
+ }
280
+ case 'SUMMARY': {
281
+ summary = unescapeText(prop.value);
282
+ break;
283
+ }
284
+ case 'DESCRIPTION': {
285
+ description = unescapeText(prop.value);
286
+ break;
287
+ }
288
+ case 'LOCATION': {
289
+ location = unescapeText(prop.value);
290
+ break;
291
+ }
292
+ case 'DTSTART': {
293
+ const paramTzid = prop.params.get('TZID');
294
+ const parsed = parseDateValue(prop.value, paramTzid);
295
+ dtstart = parsed.iso;
296
+ allDay = parsed.allDay;
297
+ tzid = parsed.tzid;
298
+ dtstartUtcMs = parsed.utcMs;
299
+ break;
300
+ }
301
+ case 'DTEND': {
302
+ const paramTzid = prop.params.get('TZID');
303
+ const parsed = parseDateValue(prop.value, paramTzid);
304
+ dtend = parsed.iso;
305
+ break;
306
+ }
307
+ case 'RRULE': {
308
+ rrule = prop.value.trim();
309
+ break;
310
+ }
311
+ // All other properties are silently ignored.
312
+ }
313
+ }
314
+
315
+ return events;
316
+ }
317
+
318
+ // ──────────────────────────────────────────────────────────────────
319
+ // RRULE expansion (simple subset only)
320
+ // ──────────────────────────────────────────────────────────────────
321
+
322
+ /**
323
+ * Parse RRULE string into parts map.
324
+ * Example: "FREQ=WEEKLY;COUNT=5;BYDAY=MO" → { FREQ: 'WEEKLY', COUNT: '5', BYDAY: 'MO' }
325
+ */
326
+ function parseRrule(rrule: string): Map<string, string> {
327
+ const map = new Map<string, string>();
328
+ for (const part of rrule.split(';')) {
329
+ const eq = part.indexOf('=');
330
+ if (eq >= 0) map.set(part.slice(0, eq).toUpperCase(), part.slice(eq + 1));
331
+ }
332
+ return map;
333
+ }
334
+
335
+ /** Add `n` months to UTC-based year/month/day components (returns new ms). */
336
+ function addMonthsToMs(utcMs: number, n: number): number {
337
+ const d = new Date(utcMs);
338
+ const target = d.getUTCMonth() + n;
339
+ d.setUTCMonth(target);
340
+ return d.getTime();
341
+ }
342
+
343
+ /** Parse UNTIL value (DATE or DATE-TIME, local or UTC). Returns a Date or null. */
344
+ function parseUntil(until: string): Date | null {
345
+ const v = until.trim();
346
+ if (/^\d{8}$/.test(v)) {
347
+ return new Date(`${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}T00:00:00Z`);
348
+ }
349
+ const m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/);
350
+ if (m) {
351
+ const suffix = 'Z'; // floating UNTIL coerced to UTC for expansion (RFC 5545 §3.3.10 compliance note)
352
+ return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${suffix}`);
353
+ }
354
+ return null;
355
+ }
356
+
357
+ /**
358
+ * Convert an IcsEvent dtstart to a UTC timestamp (ms since epoch).
359
+ *
360
+ * Priority:
361
+ * 1. dtstartUtcMs (pre-computed during parse, TZID-qualified events)
362
+ * 2. All-day events: UTC noon on the date (stable anchor)
363
+ * 3. UTC events (dtstart ends with Z): Date.parse
364
+ * 4. Floating events (no Z, no TZID): treated as UTC
365
+ */
366
+ function eventStartMs(event: IcsEvent): number | null {
367
+ if (event.dtstartUtcMs !== undefined) return event.dtstartUtcMs;
368
+ if (event.allDay) {
369
+ const d = new Date(`${event.dtstart}T12:00:00Z`);
370
+ return isNaN(d.getTime()) ? null : d.getTime();
371
+ }
372
+ // TZID-qualified wall-clock without a pre-computed utcMs (e.g. a synthetic
373
+ // endEvent constructed from event.dtend): convert wall-clock → UTC via Intl.
374
+ if (event.tzid && !event.dtstart.endsWith('Z')) {
375
+ return wallClockToUtcMs(event.dtstart, event.tzid);
376
+ }
377
+ const normalized = event.dtstart.endsWith('Z') || event.dtstart.includes('+')
378
+ ? event.dtstart
379
+ : `${event.dtstart}Z`;
380
+ const d = new Date(normalized);
381
+ return isNaN(d.getTime()) ? null : d.getTime();
382
+ }
383
+
384
+ /** Format a UTC ms timestamp to ISO string matching allDay convention. */
385
+ function utcMsToIso(utcMs: number, allDay: boolean): string {
386
+ const d = new Date(utcMs);
387
+ if (allDay) {
388
+ const y = d.getUTCFullYear();
389
+ const mo = String(d.getUTCMonth() + 1).padStart(2, '0');
390
+ const da = String(d.getUTCDate()).padStart(2, '0');
391
+ return `${y}-${mo}-${da}`;
392
+ }
393
+ return d.toISOString().replace('.000', '');
394
+ }
395
+
396
+ /**
397
+ * Format a UTC ms timestamp as a wall-clock ISO string in the given IANA timezone.
398
+ * Returns "YYYY-MM-DDTHH:MM:SS" (no Z suffix) for use as a TZID-qualified DTSTART.
399
+ * Falls back to UTC ISO string if the timezone is unavailable.
400
+ */
401
+ function utcMsToTzWallClock(utcMs: number, tzid: string): string {
402
+ const wc = utcMsToWallClock(utcMs, tzid);
403
+ return wc ?? new Date(utcMs).toISOString().replace('.000Z', 'Z');
404
+ }
405
+
406
+ /**
407
+ * Expand a recurring event into individual occurrences for the next
408
+ * `horizonDays` days from `referenceDate`.
409
+ *
410
+ * Only FREQ=DAILY, FREQ=WEEKLY, and FREQ=MONTHLY with optional COUNT / UNTIL
411
+ * are supported. INTERVAL defaults to 1. Any other RRULE keywords
412
+ * (BYDAY, BYMONTHDAY, EXDATE, etc.) are present on the rule but NOT
413
+ * processed — their presence does not cause an error, but the expansion will
414
+ * not reflect their constraints.
415
+ *
416
+ * Returns an empty array for any rule with FREQ other than those three.
417
+ *
418
+ * Timezone-correct stepping (TZID-qualified events):
419
+ * Each occurrence is stepped by computing the NEXT wall-clock time in the
420
+ * event's timezone (e.g. DAILY 09:00 NY stays 09:00 NY across DST). This
421
+ * means the UTC instant shifts by one hour across a DST boundary — which
422
+ * is the correct behaviour.
423
+ */
424
+ export function expandRecurringEvent(event: IcsEvent, referenceDate: Date, horizonDays = 90): IcsEvent[] {
425
+ if (!event.rrule) return [];
426
+ const parts = parseRrule(event.rrule);
427
+ const freq = parts.get('FREQ')?.toUpperCase();
428
+ if (freq !== 'DAILY' && freq !== 'WEEKLY' && freq !== 'MONTHLY') return [];
429
+
430
+ // If the rule contains keywords we cannot faithfully expand, return only the
431
+ // single base occurrence so callers get accurate data rather than wrong dates.
432
+ const UNSUPPORTED_PARTS = ['BYDAY', 'BYMONTHDAY', 'BYMONTH', 'BYWEEKNO', 'BYYEARDAY', 'BYSETPOS', 'BYHOUR', 'BYMINUTE', 'WKST'] as const;
433
+ if (UNSUPPORTED_PARTS.some((key) => parts.has(key))) {
434
+ return [{ ...event }];
435
+ }
436
+
437
+ const interval = Math.max(1, parseInt(parts.get('INTERVAL') ?? '1', 10) || 1);
438
+ const maxCount = parts.has('COUNT') ? Math.max(1, parseInt(parts.get('COUNT')!, 10) || 1) : Infinity;
439
+ const until = parts.has('UNTIL') ? parseUntil(parts.get('UNTIL')!) : null;
440
+
441
+ const horizonEnd = new Date(referenceDate.getTime() + horizonDays * 86_400_000);
442
+
443
+ const startMs = eventStartMs(event);
444
+ if (startMs === null) return [];
445
+
446
+ // Duration offset between dtstart and dtend (ms)
447
+ let durationMs = 0;
448
+ if (event.dtend) {
449
+ const endEvent = { ...event, dtstart: event.dtend, dtstartUtcMs: undefined, tzid: event.tzid };
450
+ const endMs = eventStartMs(endEvent);
451
+ if (endMs !== null) durationMs = endMs - startMs;
452
+ }
453
+
454
+ const results: IcsEvent[] = [];
455
+ let cursorMs = startMs;
456
+ let count = 0;
457
+ let totalIterations = 0;
458
+ const MAX_TOTAL_ITERATIONS = 100_000;
459
+
460
+ while (count < maxCount) {
461
+ // MIN-4: hard backstop on total loop iterations (independent of cursor monotonicity)
462
+ if (totalIterations++ >= MAX_TOTAL_ITERATIONS) break;
463
+ // Enforce UNTIL boundary
464
+ if (until && cursorMs > until.getTime()) break;
465
+ // Enforce horizon
466
+ if (cursorMs > horizonEnd.getTime()) break;
467
+ // Only include occurrences from referenceDate onward
468
+ if (cursorMs >= referenceDate.getTime()) {
469
+ let occStart: string;
470
+ if (event.tzid && !event.allDay) {
471
+ // Emit as wall-clock in the event's timezone (no Z)
472
+ occStart = utcMsToTzWallClock(cursorMs, event.tzid);
473
+ } else {
474
+ occStart = utcMsToIso(cursorMs, event.allDay);
475
+ }
476
+ const occEnd = durationMs > 0
477
+ ? (event.tzid && !event.allDay
478
+ ? utcMsToTzWallClock(cursorMs + durationMs, event.tzid)
479
+ : utcMsToIso(cursorMs + durationMs, event.allDay))
480
+ : event.dtend;
481
+ results.push({ ...event, dtstart: occStart, dtend: occEnd, dtstartUtcMs: cursorMs });
482
+ count++;
483
+ }
484
+
485
+ // Advance cursor in the event's timezone (DST-correct stepping)
486
+ if (event.tzid && !event.allDay) {
487
+ cursorMs = advanceTzMs(cursorMs, event.tzid, freq, interval);
488
+ } else if (freq === 'DAILY') {
489
+ cursorMs += interval * 86_400_000;
490
+ } else if (freq === 'WEEKLY') {
491
+ cursorMs += interval * 7 * 86_400_000;
492
+ } else {
493
+ cursorMs = addMonthsToMs(cursorMs, interval);
494
+ }
495
+
496
+ // Safety: bail after 10000 iterations regardless
497
+ if (count > 10000) break;
498
+ }
499
+
500
+ return results;
501
+ }
502
+
503
+ /**
504
+ * Advance a UTC ms instant by the given frequency/interval stepping IN the
505
+ * specified IANA timezone. This keeps wall-clock time stable across DST:
506
+ * - DAILY: get next-day wall-clock at same H:M:S in tzid, convert back to UTC.
507
+ * - WEEKLY: same but 7 days later.
508
+ * - MONTHLY: advance the month component of the wall-clock, keep H:M:S.
509
+ *
510
+ * Falls back to fixed-ms arithmetic when the timezone is unavailable.
511
+ */
512
+ function advanceTzMs(utcMs: number, tzid: string, freq: string, interval: number): number {
513
+ const wc = utcMsToWallClock(utcMs, tzid);
514
+ if (!wc) {
515
+ // Unknown TZID fallback
516
+ if (freq === 'DAILY') return utcMs + interval * 86_400_000;
517
+ if (freq === 'WEEKLY') return utcMs + interval * 7 * 86_400_000;
518
+ return addMonthsToMs(utcMs, interval);
519
+ }
520
+
521
+ // Parse wall-clock components
522
+ const m = wc.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/);
523
+ if (!m) return utcMs + 86_400_000; // unexpected format
524
+
525
+ let year = parseInt(m[1]!, 10);
526
+ let month = parseInt(m[2]!, 10); // 1-12
527
+ let day = parseInt(m[3]!, 10);
528
+ const hour = parseInt(m[4]!, 10);
529
+ const minute = parseInt(m[5]!, 10);
530
+ const second = parseInt(m[6]!, 10);
531
+
532
+ if (freq === 'DAILY') {
533
+ // Increment day; handle month/year overflow via Date
534
+ const next = new Date(Date.UTC(year, month - 1, day + interval * 1, hour, minute, second));
535
+ const nextWc = `${next.getUTCFullYear()}-${String(next.getUTCMonth() + 1).padStart(2, '0')}-${String(next.getUTCDate()).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
536
+ return wallClockToUtcMs(nextWc, tzid);
537
+ }
538
+
539
+ if (freq === 'WEEKLY') {
540
+ const next = new Date(Date.UTC(year, month - 1, day + interval * 7, hour, minute, second));
541
+ const nextWc = `${next.getUTCFullYear()}-${String(next.getUTCMonth() + 1).padStart(2, '0')}-${String(next.getUTCDate()).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
542
+ return wallClockToUtcMs(nextWc, tzid);
543
+ }
544
+
545
+ // MONTHLY: advance month, keep day/time (clamp to month end)
546
+ month += interval;
547
+ year += Math.floor((month - 1) / 12);
548
+ month = ((month - 1) % 12) + 1;
549
+ // Clamp day to end of target month
550
+ const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
551
+ day = Math.min(day, daysInMonth);
552
+ const nextWc = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
553
+ return wallClockToUtcMs(nextWc, tzid);
554
+ }
555
+
556
+ // ──────────────────────────────────────────────────────────────────
557
+ // Renderer
558
+ // ──────────────────────────────────────────────────────────────────
559
+
560
+ /** Format a Date as iCalendar DATE value: YYYYMMDD */
561
+ function formatIcsDate(date: Date): string {
562
+ const y = date.getUTCFullYear();
563
+ const m = String(date.getUTCMonth() + 1).padStart(2, '0');
564
+ const d = String(date.getUTCDate()).padStart(2, '0');
565
+ return `${y}${m}${d}`;
566
+ }
567
+
568
+ /** Format an ISO string as iCalendar DATE-TIME UTC value: YYYYMMDDTHHMMSSZ */
569
+ function formatIcsDateTimeUtc(iso: string): string {
570
+ const normalized = iso.endsWith('Z') ? iso : `${iso}Z`;
571
+ const d = new Date(normalized);
572
+ if (isNaN(d.getTime())) return iso.replace(/[-:]/g, '').replace(' ', 'T');
573
+ const y = d.getUTCFullYear();
574
+ const mo = String(d.getUTCMonth() + 1).padStart(2, '0');
575
+ const da = String(d.getUTCDate()).padStart(2, '0');
576
+ const h = String(d.getUTCHours()).padStart(2, '0');
577
+ const mi = String(d.getUTCMinutes()).padStart(2, '0');
578
+ const s = String(d.getUTCSeconds()).padStart(2, '0');
579
+ return `${y}${mo}${da}T${h}${mi}${s}Z`;
580
+ }
581
+
582
+ /**
583
+ * Format a wall-clock ISO string ("YYYY-MM-DDTHH:MM:SS") as compact iCalendar
584
+ * local form: YYYYMMDDTHHMMSS (no Z suffix, for use with TZID parameter).
585
+ */
586
+ function formatIcsDateTimeLocal(iso: string): string {
587
+ // iso is already in "YYYY-MM-DDTHH:MM:SS" form (no Z)
588
+ return iso.replace(/[-:]/g, '').replace(' ', 'T').replace('Z', '');
589
+ }
590
+
591
+ /** Produce a folded iCalendar content line from a name=value pair. */
592
+ function icsLine(name: string, value: string, params?: string): string {
593
+ const head = params ? `${name};${params}` : name;
594
+ return foldLine(`${head}:${value}`);
595
+ }
596
+
597
+ /**
598
+ * Render an array of IcsEvent objects into a valid VCALENDAR string.
599
+ *
600
+ * Text properties are escaped and lines are folded at 75 octets per RFC 5545.
601
+ *
602
+ * DTSTART rendering rules:
603
+ * - All-day: VALUE=DATE form (YYYYMMDD), no TZID.
604
+ * - UTC (dtstart ends with Z): YYYYMMDDTHHMMSSZ.
605
+ * - TZID-qualified: DTSTART;TZID=Zone:YYYYMMDDTHHMMSS (wall-clock, no Z).
606
+ * Note: emitted without VTIMEZONE block. Tolerant readers accept this;
607
+ * strict RFC 5545 readers may reject it (documented limitation).
608
+ * - Floating: YYYYMMDDTHHMMSS (no Z, no TZID).
609
+ */
610
+ export function renderIcs(events: readonly IcsEvent[]): string {
611
+ const lines: string[] = [
612
+ 'BEGIN:VCALENDAR',
613
+ 'PRODID:-//GoodVibes Agent//Calendar//EN',
614
+ 'VERSION:2.0',
615
+ 'CALSCALE:GREGORIAN',
616
+ 'METHOD:PUBLISH',
617
+ ];
618
+
619
+ for (const event of events) {
620
+ lines.push('BEGIN:VEVENT');
621
+ lines.push(icsLine('UID', escapeText(event.uid)));
622
+ lines.push(icsLine('SUMMARY', escapeText(event.summary)));
623
+
624
+ if (event.allDay) {
625
+ // All-day: strip dashes and emit as DATE
626
+ const raw = event.dtstart.replace(/-/g, '');
627
+ lines.push(icsLine('DTSTART', raw, 'VALUE=DATE'));
628
+ if (event.dtend) {
629
+ lines.push(icsLine('DTEND', event.dtend.replace(/-/g, ''), 'VALUE=DATE'));
630
+ }
631
+ } else if (event.tzid) {
632
+ // TZID-qualified: emit wall-clock local time with TZID parameter.
633
+ // The dtstart field already holds the wall-clock (no Z suffix) from parse.
634
+ const localCompact = formatIcsDateTimeLocal(event.dtstart);
635
+ lines.push(icsLine('DTSTART', localCompact, `TZID=${event.tzid}`));
636
+ if (event.dtend) {
637
+ const endCompact = formatIcsDateTimeLocal(event.dtend);
638
+ lines.push(icsLine('DTEND', endCompact, `TZID=${event.tzid}`));
639
+ }
640
+ } else {
641
+ // UTC or floating: emit with Z suffix (or as-is for floating)
642
+ lines.push(icsLine('DTSTART', formatIcsDateTimeUtc(event.dtstart)));
643
+ if (event.dtend) {
644
+ lines.push(icsLine('DTEND', formatIcsDateTimeUtc(event.dtend)));
645
+ }
646
+ }
647
+
648
+ if (event.description) {
649
+ lines.push(icsLine('DESCRIPTION', escapeText(event.description)));
650
+ }
651
+ if (event.location) {
652
+ lines.push(icsLine('LOCATION', escapeText(event.location)));
653
+ }
654
+ if (event.rrule) {
655
+ lines.push(icsLine('RRULE', event.rrule));
656
+ }
657
+ lines.push('END:VEVENT');
658
+ }
659
+
660
+ lines.push('END:VCALENDAR');
661
+ return lines.map((l) => `${l}\r\n`).join('');
662
+ }