@forwardimpact/basecamp 1.0.0 → 2.2.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 (42) hide show
  1. package/README.md +1 -1
  2. package/config/scheduler.json +18 -17
  3. package/package.json +3 -3
  4. package/src/basecamp.js +532 -259
  5. package/template/.claude/agents/chief-of-staff.md +103 -0
  6. package/template/.claude/agents/concierge.md +75 -0
  7. package/template/.claude/agents/librarian.md +59 -0
  8. package/template/.claude/agents/postman.md +73 -0
  9. package/template/.claude/agents/recruiter.md +222 -0
  10. package/template/.claude/settings.json +0 -4
  11. package/template/.claude/skills/analyze-cv/SKILL.md +267 -0
  12. package/template/.claude/skills/create-presentations/SKILL.md +2 -2
  13. package/template/.claude/skills/create-presentations/references/slide.css +1 -1
  14. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.mjs +47 -0
  15. package/template/.claude/skills/draft-emails/SKILL.md +85 -123
  16. package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
  17. package/template/.claude/skills/draft-emails/scripts/send-email.mjs +118 -0
  18. package/template/.claude/skills/extract-entities/SKILL.md +2 -2
  19. package/template/.claude/skills/extract-entities/scripts/state.mjs +130 -0
  20. package/template/.claude/skills/manage-tasks/SKILL.md +242 -0
  21. package/template/.claude/skills/organize-files/SKILL.md +3 -3
  22. package/template/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
  23. package/template/.claude/skills/organize-files/scripts/summarize.mjs +84 -0
  24. package/template/.claude/skills/process-hyprnote/SKILL.md +2 -2
  25. package/template/.claude/skills/send-chat/SKILL.md +170 -0
  26. package/template/.claude/skills/sync-apple-calendar/SKILL.md +5 -5
  27. package/template/.claude/skills/sync-apple-calendar/scripts/sync.mjs +325 -0
  28. package/template/.claude/skills/sync-apple-mail/SKILL.md +6 -6
  29. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +374 -0
  30. package/template/.claude/skills/sync-apple-mail/scripts/sync.mjs +629 -0
  31. package/template/.claude/skills/track-candidates/SKILL.md +375 -0
  32. package/template/.claude/skills/weekly-update/SKILL.md +250 -0
  33. package/template/CLAUDE.md +73 -29
  34. package/template/knowledge/Briefings/.gitkeep +0 -0
  35. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +0 -32
  36. package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +0 -34
  37. package/template/.claude/skills/extract-entities/scripts/state.py +0 -100
  38. package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +0 -42
  39. package/template/.claude/skills/organize-files/scripts/summarize.sh +0 -21
  40. package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +0 -242
  41. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +0 -104
  42. package/template/.claude/skills/sync-apple-mail/scripts/sync.py +0 -455
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Summarize the contents of ~/Desktop/ and ~/Downloads/.
4
+ *
5
+ * Counts top-level files in both directories by type (Screenshots, PDFs,
6
+ * Images, Documents, Archives, Installers, Other) and prints a human-readable
7
+ * table for each. Used by the organize-files skill to preview directory
8
+ * contents before organizing.
9
+ */
10
+
11
+ import { existsSync, readdirSync, statSync } from "node:fs";
12
+ import { extname, join } from "node:path";
13
+ import { homedir } from "node:os";
14
+
15
+ const HELP = `summarize — count files by type in ~/Desktop/ and ~/Downloads/
16
+
17
+ Usage: node scripts/summarize.mjs [-h|--help]
18
+
19
+ Prints a summary of file types found at the top level of each directory.`;
20
+
21
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
22
+ console.log(HELP);
23
+ process.exit(0);
24
+ }
25
+
26
+ const HOME = homedir();
27
+
28
+ function countFiles(dir) {
29
+ if (!existsSync(dir)) return null;
30
+
31
+ const counts = {
32
+ Screenshots: 0,
33
+ PDFs: 0,
34
+ Images: 0,
35
+ Documents: 0,
36
+ Archives: 0,
37
+ Installers: 0,
38
+ Other: 0,
39
+ };
40
+
41
+ for (const name of readdirSync(dir)) {
42
+ const fullPath = join(dir, name);
43
+ const stat = statSync(fullPath, { throwIfNoEntry: false });
44
+ if (!stat || !stat.isFile()) continue;
45
+ if (name.startsWith(".")) continue;
46
+
47
+ const ext = extname(name).toLowerCase();
48
+ if (name.startsWith("Screenshot") || name.startsWith("Screen Shot")) {
49
+ counts.Screenshots++;
50
+ } else if (ext === ".pdf") {
51
+ counts.PDFs++;
52
+ } else if ([".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(ext)) {
53
+ counts.Images++;
54
+ } else if (
55
+ [".doc", ".docx", ".txt", ".md", ".rtf", ".csv", ".xlsx"].includes(ext)
56
+ ) {
57
+ counts.Documents++;
58
+ } else if ([".zip", ".rar"].includes(ext) || name.endsWith(".tar.gz")) {
59
+ counts.Archives++;
60
+ } else if (ext === ".dmg") {
61
+ counts.Installers++;
62
+ } else {
63
+ counts.Other++;
64
+ }
65
+ }
66
+
67
+ return counts;
68
+ }
69
+
70
+ function main() {
71
+ for (const dirName of ["Desktop", "Downloads"]) {
72
+ const dir = join(HOME, dirName);
73
+ const counts = countFiles(dir);
74
+ if (!counts) continue;
75
+
76
+ console.log(`=== ${dirName} ===`);
77
+ for (const [label, count] of Object.entries(counts)) {
78
+ console.log(`${label.padEnd(12)} ${count}`);
79
+ }
80
+ console.log("");
81
+ }
82
+ }
83
+
84
+ main();
@@ -308,11 +308,11 @@ absolute paths: `[[People/Name]]`, `[[Organizations/Name]]`,
308
308
  After processing each session, mark its files as processed:
309
309
 
310
310
  ```bash
311
- python3 .claude/skills/extract-entities/scripts/state.py update \
311
+ node .claude/skills/extract-entities/scripts/state.mjs update \
312
312
  "$HOME/Library/Application Support/hyprnote/sessions/{uuid}/_memo.md"
313
313
 
314
314
  # Also mark _summary.md if it exists
315
- python3 .claude/skills/extract-entities/scripts/state.py update \
315
+ node .claude/skills/extract-entities/scripts/state.mjs update \
316
316
  "$HOME/Library/Application Support/hyprnote/sessions/{uuid}/_summary.md"
317
317
  ```
318
318
 
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: send-chat
3
+ description: Send messages to people via chat platforms (e.g. Microsoft Teams, Slack) using browser automation. Resolves people by name using the knowledge graph, drafts messages for approval, and sends via the web app. Use when the user asks to message, ping, or chat with someone.
4
+ compatibility:
5
+ requires:
6
+ - browser-automation
7
+ ---
8
+
9
+ # Send Chat
10
+
11
+ Send chat messages to people using browser automation against a web-based chat
12
+ platform (Microsoft Teams, Slack, or similar). Resolves recipients by name from
13
+ the knowledge graph so the user can say "message Sarah about the standup"
14
+ without needing exact display names.
15
+
16
+ ## Trigger
17
+
18
+ Run when the user asks to:
19
+
20
+ - Send a message on Teams / Slack / chat
21
+ - Ping / chat / DM someone
22
+ - Follow up with someone via chat
23
+ - Send a message about a topic
24
+
25
+ ## Prerequisites
26
+
27
+ - Chat platform web app open and authenticated in the browser
28
+ - Browser automation available (e.g. Chrome MCP, Playwright)
29
+ - Knowledge base populated with people notes
30
+
31
+ ## Critical: Always Look Up Context First
32
+
33
+ **BEFORE messaging anyone, you MUST look up the person in the knowledge base.**
34
+
35
+ When the user mentions ANY person:
36
+
37
+ 1. **STOP** — Do not open the chat platform yet
38
+ 2. **SEARCH** — Look them up: `rg -l "{name}" knowledge/People/`
39
+ 3. **READ** — Read their note to understand context, role, recent interactions
40
+ 4. **UNDERSTAND** — Know who they are, what you've been working on together
41
+ 5. **THEN PROCEED** — Only now compose the message and use browser automation
42
+
43
+ This context is essential for:
44
+
45
+ - Finding the right person if the name is ambiguous
46
+ - Drafting an appropriate message if the user gave a loose prompt
47
+ - Knowing the person's role and relationship for tone
48
+
49
+ ## Resolving People
50
+
51
+ The user will refer to people by first name, last name, or nickname. Resolve to
52
+ a full name using the knowledge graph:
53
+
54
+ ```bash
55
+ # Find person by partial name
56
+ rg -l -i "{name}" knowledge/People/
57
+
58
+ # If ambiguous, read candidates to disambiguate
59
+ cat "knowledge/People/{Candidate}.md"
60
+ ```
61
+
62
+ **If ambiguous** (multiple matches), ask the user which person they mean — list
63
+ the matches with roles/orgs to help them pick.
64
+
65
+ **If no match**, tell the user you don't have this person in the knowledge base
66
+ and ask for their full name as it appears in the chat platform.
67
+
68
+ ## Composing the Message
69
+
70
+ **Every message MUST be drafted as a text file first.** This ensures the user
71
+ can review and edit the exact message before it's sent.
72
+
73
+ ### Draft Workflow
74
+
75
+ 1. **Compose the message** based on context and user intent.
76
+ 2. **Write it to a draft file** at `drafts/chat-{recipient-slug}-{date}.md`
77
+ - `{recipient-slug}` = lowercase, hyphenated full name (e.g. `sarah-chen`)
78
+ - `{date}` = ISO date (e.g. `2026-02-19`)
79
+ 3. **Show the user the draft** — display the file path and contents.
80
+ 4. **Wait for approval** — the user may edit the file or ask for changes.
81
+ 5. **Only after approval**, proceed to send.
82
+
83
+ **Draft file format:**
84
+
85
+ ```markdown
86
+ To: {Full Name}
87
+ Via: {Platform name}
88
+ Date: {YYYY-MM-DD}
89
+
90
+ ---
91
+
92
+ {message body}
93
+ ```
94
+
95
+ The message body (everything below the `---` separator) is what gets pasted into
96
+ the chat.
97
+
98
+ **Message guidelines:**
99
+
100
+ - Match the user's usual tone — casual for peers, professional for leadership
101
+ - Keep it concise — chat is informal, not email
102
+ - Reference specific context naturally (project names, recent decisions)
103
+ - If the user provides exact wording, use it verbatim
104
+ - If the user said "ping {name}" without detail, ask what they want to say
105
+ - Draft one message based on context — don't offer multiple options
106
+ - **Keep messages on a single line with no formatting.** No line breaks, no
107
+ markdown. Use inline separators (e.g. `•`, `—`) to keep structure. Multi-line
108
+ formatting is unreliable via browser automation.
109
+
110
+ ## Browser Automation Flow
111
+
112
+ Once the user has approved the draft, send it as a **single submission** — paste
113
+ the entire message at once rather than typing line by line.
114
+
115
+ ### Step 1: Identify the Chat Platform
116
+
117
+ Check which platform is available:
118
+
119
+ - Look for an open tab matching the configured chat URL
120
+ - If no tab is open, ask the user which platform to use and navigate to it
121
+
122
+ ### Step 2: Open a Chat with the Recipient
123
+
124
+ 1. Use the platform's search or "New chat" feature
125
+ 2. Type the recipient's full name
126
+ 3. Wait for search results to populate (take a screenshot to verify)
127
+ 4. Click the correct person from the results
128
+
129
+ If the person doesn't appear in search, inform the user — they may not be in the
130
+ same organization.
131
+
132
+ ### Step 3: Send the Approved Message
133
+
134
+ 1. Read the approved draft file to get the message body (below the `---`)
135
+ 2. Click the message compose box
136
+ 3. Paste the entire message as a single submission
137
+ 4. Press Enter or click Send
138
+ 5. Take a screenshot to confirm the message was sent
139
+
140
+ ### Step 4: Update Knowledge Graph (Optional)
141
+
142
+ If the message is substantive (not just "hey" or "thanks"), note the interaction
143
+ on the person's knowledge note:
144
+
145
+ ```markdown
146
+ - {YYYY-MM-DD}: Messaged on {Platform} re: {topic}
147
+ ```
148
+
149
+ ## Error Handling
150
+
151
+ - **Platform not loaded / auth required:** Tell the user to sign in first, then
152
+ retry
153
+ - **Person not found in search:** Report back — they may be external or using a
154
+ different display name. Ask the user for the exact name
155
+ - **Chat already open:** If a chat with this person is already visible, use it
156
+ directly
157
+ - **UI not as expected:** Take a screenshot and describe what you see. Don't
158
+ click blindly
159
+
160
+ ## Constraints
161
+
162
+ - **Always confirm before sending.** Never send a message without explicit user
163
+ approval — this is a hard requirement
164
+ - **One message at a time.** Don't batch-send to multiple people without
165
+ confirming each one
166
+ - **No file attachments.** This skill handles text messages only
167
+ - **No group chats.** Targets 1:1 chats only
168
+ - **No message deletion or editing.** Once sent, it's sent
169
+ - **Respect ethics rules.** Never send messages that contain personal judgments,
170
+ gossip, or sensitive information per the knowledge base ethics policy
@@ -37,10 +37,11 @@ their calendar.
37
37
 
38
38
  ## Implementation
39
39
 
40
- Run the sync as a single Python script. This avoids N+1 sqlite3 invocations (one
41
- per event for attendees) and handles all data transformation in one pass:
40
+ Run the sync as a single Node.js script with embedded SQLite. This avoids N+1
41
+ process invocations (one per event for attendees) and handles all data
42
+ transformation in one pass:
42
43
 
43
- python3 scripts/sync.py [--days N]
44
+ node scripts/sync.mjs [--days N]
44
45
 
45
46
  - `--days N` — how many days back to sync (default: 30)
46
47
 
@@ -97,8 +98,7 @@ Each `{event_id}.json` file:
97
98
 
98
99
  ## Constraints
99
100
 
100
- - Open database read-only (`-readonly`)
101
+ - Open database read-only (`readOnly: true`)
101
102
  - This sync is stateless — always queries the current sliding window
102
103
  - All-day events may have null end times — use start date as end date
103
104
  - All-day events have timezone `_float` — omit timezone from output
104
- - Output format matches Google Calendar event format for downstream consistency
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Sync Apple Calendar events to ~/.cache/fit/basecamp/apple_calendar/ as JSON.
4
+ *
5
+ * Queries the macOS Calendar SQLite database (via node:sqlite) for events in a
6
+ * sliding window — N days in the past through 14 days in the future. Writes one
7
+ * JSON file per event and removes files for events that fall outside the window.
8
+ * Attendee details (name, email, status, role) are batch-fetched and included.
9
+ *
10
+ * Requires macOS with Calendar app configured and Full Disk Access granted.
11
+ */
12
+
13
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
14
+ console.log(`sync-apple-calendar — sync calendar events to JSON
15
+
16
+ Usage: node scripts/sync.mjs [--days N] [-h|--help]
17
+
18
+ Options:
19
+ --days N Days back to sync (default: 30)
20
+ -h, --help Show this help message and exit
21
+
22
+ Requires macOS with Calendar configured and Full Disk Access granted.`);
23
+ process.exit(0);
24
+ }
25
+
26
+ import { DatabaseSync } from "node:sqlite";
27
+ import {
28
+ existsSync,
29
+ mkdirSync,
30
+ readdirSync,
31
+ unlinkSync,
32
+ writeFileSync,
33
+ } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { homedir } from "node:os";
36
+
37
+ const HOME = homedir();
38
+ const OUTDIR = join(HOME, ".cache/fit/basecamp/apple_calendar");
39
+
40
+ /** Core Data epoch: 2001-01-01T00:00:00Z */
41
+ const EPOCH_MS = Date.UTC(2001, 0, 1);
42
+
43
+ const DB_PATHS = [
44
+ join(
45
+ HOME,
46
+ "Library/Group Containers/group.com.apple.calendar/Calendar.sqlitedb",
47
+ ),
48
+ join(HOME, "Library/Calendars/Calendar.sqlitedb"),
49
+ ];
50
+
51
+ const STATUS_MAP = {
52
+ 0: "unknown",
53
+ 1: "pending",
54
+ 2: "accepted",
55
+ 3: "declined",
56
+ 4: "tentative",
57
+ 5: "delegated",
58
+ 6: "completed",
59
+ 7: "in-process",
60
+ };
61
+
62
+ const ROLE_MAP = {
63
+ 0: "unknown",
64
+ 1: "required",
65
+ 2: "optional",
66
+ 3: "chair",
67
+ };
68
+
69
+ /**
70
+ * Find the Apple Calendar database.
71
+ * @returns {string}
72
+ */
73
+ function findDb() {
74
+ const db = DB_PATHS.find((p) => existsSync(p));
75
+ if (!db) {
76
+ console.error(
77
+ "Error: Apple Calendar database not found. Is Calendar configured?",
78
+ );
79
+ process.exit(1);
80
+ }
81
+ return db;
82
+ }
83
+
84
+ /**
85
+ * Open the database in read-only mode with retry on lock.
86
+ * @param {string} dbPath
87
+ * @returns {import("node:sqlite").DatabaseSync}
88
+ */
89
+ function openDb(dbPath) {
90
+ try {
91
+ return new DatabaseSync(dbPath, { readOnly: true });
92
+ } catch (err) {
93
+ if (err.message.includes("locked")) {
94
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
95
+ return new DatabaseSync(dbPath, { readOnly: true });
96
+ }
97
+ throw err;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Execute a read-only query and return results.
103
+ * @param {import("node:sqlite").DatabaseSync} db
104
+ * @param {string} sql
105
+ * @returns {Array<Record<string, any>>}
106
+ */
107
+ function query(db, sql) {
108
+ try {
109
+ return db.prepare(sql).all();
110
+ } catch (err) {
111
+ console.error(`SQLite error: ${err.message}`);
112
+ return [];
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Convert Core Data timestamp to ISO 8601.
118
+ * @param {number | null} ts - Seconds since 2001-01-01
119
+ * @param {string | null} tzName
120
+ * @returns {string | null}
121
+ */
122
+ function coredataToIso(ts, tzName) {
123
+ if (ts == null) return null;
124
+ const ms = EPOCH_MS + ts * 1000;
125
+ const dt = new Date(ms);
126
+
127
+ if (tzName && tzName !== "_float") {
128
+ try {
129
+ return (
130
+ dt.toLocaleString("sv-SE", { timeZone: tzName }).replace(" ", "T") +
131
+ getUtcOffset(dt, tzName)
132
+ );
133
+ } catch {
134
+ // Fall through to UTC
135
+ }
136
+ }
137
+ return dt.toISOString();
138
+ }
139
+
140
+ /**
141
+ * Get UTC offset string for a timezone at a given instant.
142
+ * @param {Date} dt
143
+ * @param {string} tzName
144
+ * @returns {string} e.g. "+02:00" or "-05:00"
145
+ */
146
+ function getUtcOffset(dt, tzName) {
147
+ try {
148
+ const parts = new Intl.DateTimeFormat("en-US", {
149
+ timeZone: tzName,
150
+ timeZoneName: "longOffset",
151
+ }).formatToParts(dt);
152
+ const tzPart = parts.find((p) => p.type === "timeZoneName");
153
+ if (tzPart) {
154
+ // "GMT+2" → "+02:00", "GMT-5:30" → "-05:30", "GMT" → "+00:00"
155
+ const match = tzPart.value.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
156
+ if (match) {
157
+ const sign = match[1];
158
+ const hours = match[2].padStart(2, "0");
159
+ const mins = (match[3] ?? "00").padStart(2, "0");
160
+ return `${sign}${hours}:${mins}`;
161
+ }
162
+ return "+00:00"; // GMT with no offset
163
+ }
164
+ } catch {
165
+ // Fallback
166
+ }
167
+ return "Z";
168
+ }
169
+
170
+ // --- Main ---
171
+
172
+ function main() {
173
+ let daysBack = 30;
174
+ const daysIdx = process.argv.indexOf("--days");
175
+ if (daysIdx !== -1 && process.argv[daysIdx + 1]) {
176
+ daysBack = parseInt(process.argv[daysIdx + 1], 10);
177
+ }
178
+
179
+ const dbPath = findDb();
180
+ mkdirSync(OUTDIR, { recursive: true });
181
+
182
+ const now = Date.now();
183
+ const start = new Date(now - daysBack * 86400000);
184
+ const end = new Date(now + 14 * 86400000);
185
+ const startTs = (start.getTime() - EPOCH_MS) / 1000;
186
+ const endTs = (end.getTime() - EPOCH_MS) / 1000;
187
+
188
+ const db = openDb(dbPath);
189
+
190
+ try {
191
+ // Fetch events with a single query
192
+ const events = query(
193
+ db,
194
+ `
195
+ SELECT
196
+ ci.ROWID AS id,
197
+ ci.summary,
198
+ ci.start_date,
199
+ ci.end_date,
200
+ ci.start_tz,
201
+ ci.end_tz,
202
+ ci.all_day,
203
+ ci.description,
204
+ ci.has_attendees,
205
+ ci.conference_url,
206
+ loc.title AS location,
207
+ cal.title AS calendar_name,
208
+ org.address AS organizer_email,
209
+ org.display_name AS organizer_name
210
+ FROM CalendarItem ci
211
+ LEFT JOIN Location loc ON loc.ROWID = ci.location_id
212
+ LEFT JOIN Calendar cal ON cal.ROWID = ci.calendar_id
213
+ LEFT JOIN Identity org ON org.ROWID = ci.organizer_id
214
+ WHERE ci.start_date <= ${endTs}
215
+ AND COALESCE(ci.end_date, ci.start_date) >= ${startTs}
216
+ AND ci.summary IS NOT NULL
217
+ AND ci.summary != ''
218
+ ORDER BY ci.start_date ASC
219
+ LIMIT 1000;
220
+ `,
221
+ );
222
+
223
+ // Collect event IDs for batch attendee query
224
+ const eventIds = events.map((ev) => String(ev.id));
225
+
226
+ // Batch-fetch all attendees in one query
227
+ const attendeesByEvent = {};
228
+ if (eventIds.length > 0) {
229
+ const idList = eventIds.join(",");
230
+ const attendeesRaw = query(
231
+ db,
232
+ `
233
+ SELECT
234
+ p.owner_id,
235
+ p.email,
236
+ p.status,
237
+ p.role,
238
+ p.is_self,
239
+ p.entity_type,
240
+ i.display_name
241
+ FROM Participant p
242
+ LEFT JOIN Identity i ON i.ROWID = p.identity_id
243
+ WHERE p.owner_id IN (${idList})
244
+ AND p.entity_type = 7;
245
+ `,
246
+ );
247
+ for (const a of attendeesRaw) {
248
+ attendeesByEvent[a.owner_id] ??= [];
249
+ attendeesByEvent[a.owner_id].push(a);
250
+ }
251
+ }
252
+
253
+ // Write event JSON files
254
+ const writtenIds = new Set();
255
+ for (const ev of events) {
256
+ const eid = ev.id;
257
+
258
+ // Organizer — strip mailto: prefix
259
+ let orgEmail = ev.organizer_email ?? null;
260
+ if (orgEmail?.startsWith("mailto:")) orgEmail = orgEmail.slice(7);
261
+
262
+ // Attendees
263
+ const attendees = [];
264
+ for (const a of attendeesByEvent[eid] ?? []) {
265
+ if (!a.email) continue;
266
+ attendees.push({
267
+ email: a.email,
268
+ name: (a.display_name ?? "").trim() || null,
269
+ status: STATUS_MAP[a.status] ?? "unknown",
270
+ role: ROLE_MAP[a.role] ?? "unknown",
271
+ self: Boolean(a.is_self),
272
+ });
273
+ }
274
+
275
+ const isAllDay = Boolean(ev.all_day);
276
+
277
+ const eventJson = {
278
+ id: `apple_cal_${eid}`,
279
+ summary: ev.summary,
280
+ start: {
281
+ dateTime: coredataToIso(ev.start_date, ev.start_tz),
282
+ timeZone: ev.start_tz !== "_float" ? ev.start_tz : null,
283
+ },
284
+ end: {
285
+ dateTime: coredataToIso(ev.end_date ?? ev.start_date, ev.end_tz),
286
+ timeZone: ev.end_tz !== "_float" ? ev.end_tz : null,
287
+ },
288
+ allDay: isAllDay,
289
+ location: ev.location || null,
290
+ description: ev.description || null,
291
+ conferenceUrl: ev.conference_url || null,
292
+ calendar: ev.calendar_name || null,
293
+ organizer: orgEmail
294
+ ? { email: orgEmail, name: (ev.organizer_name ?? "").trim() || null }
295
+ : null,
296
+ attendees: attendees.length > 0 ? attendees : null,
297
+ };
298
+
299
+ const filename = `${eid}.json`;
300
+ writeFileSync(join(OUTDIR, filename), JSON.stringify(eventJson, null, 2));
301
+ writtenIds.add(filename);
302
+ }
303
+
304
+ // Clean up events outside the window
305
+ let removed = 0;
306
+ for (const fname of readdirSync(OUTDIR)) {
307
+ if (fname.endsWith(".json") && !writtenIds.has(fname)) {
308
+ unlinkSync(join(OUTDIR, fname));
309
+ removed++;
310
+ }
311
+ }
312
+
313
+ console.log("Apple Calendar Sync Complete");
314
+ console.log(`Events synced: ${writtenIds.size}`);
315
+ console.log(
316
+ `Time window: ${start.toISOString().slice(0, 10)} to ${end.toISOString().slice(0, 10)}`,
317
+ );
318
+ console.log(`Files cleaned up: ${removed} (outside window)`);
319
+ console.log(`Output: ${OUTDIR}`);
320
+ } finally {
321
+ db.close();
322
+ }
323
+ }
324
+
325
+ main();
@@ -41,10 +41,10 @@ their email.
41
41
 
42
42
  ## Implementation
43
43
 
44
- Run the sync as a single Python script. This avoids N+1 shell invocations and
45
- handles all data transformation in one pass:
44
+ Run the sync as a single Node.js script with embedded SQLite. This avoids N+1
45
+ process invocations and handles all data transformation in one pass:
46
46
 
47
- python3 scripts/sync.py [--days N]
47
+ node scripts/sync.mjs [--days N]
48
48
 
49
49
  - `--days N` — how many days back to look on first sync (default: 30)
50
50
 
@@ -61,7 +61,7 @@ The script:
61
61
  7. Updates sync state timestamp
62
62
  8. Reports summary (threads processed, files written)
63
63
 
64
- The script calls `scripts/parse-emlx.py` to extract plain text bodies from
64
+ The script imports `scripts/parse-emlx.mjs` to extract plain text bodies from
65
65
  `.emlx` / `.partial.emlx` files (handles HTML-only emails by stripping tags).
66
66
 
67
67
  ## Database Schema
@@ -128,7 +128,7 @@ Rules:
128
128
  - `.emlx` / `.partial.emlx` not found → fall back to database summary field
129
129
  - `.emlx` parse error → fall back to database summary field
130
130
  - HTML-only email → strip tags and use as plain text body (handled by
131
- parse-emlx.py)
131
+ parse-emlx.mjs)
132
132
  - `find` timeout → skip that message's body, use summary; attachment index empty
133
133
  - Attachment file not found on disk → listed as `*(not available)*` in markdown
134
134
  - Attachment copy fails (permissions, disk full) → listed as `*(not available)*`
@@ -137,7 +137,7 @@ Rules:
137
137
 
138
138
  ## Constraints
139
139
 
140
- - Open database read-only (`-readonly`)
140
+ - Open database read-only (`readOnly: true`)
141
141
  - Only sync Inbox and Sent folders
142
142
  - Limit to 500 threads per run
143
143
  - Incremental: only threads with new messages since last sync