@forwardimpact/basecamp 2.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 (39) hide show
  1. package/config/scheduler.json +5 -0
  2. package/package.json +1 -1
  3. package/src/basecamp.js +288 -57
  4. package/template/.claude/agents/chief-of-staff.md +6 -2
  5. package/template/.claude/agents/concierge.md +2 -3
  6. package/template/.claude/agents/librarian.md +4 -6
  7. package/template/.claude/agents/recruiter.md +222 -0
  8. package/template/.claude/settings.json +0 -4
  9. package/template/.claude/skills/analyze-cv/SKILL.md +267 -0
  10. package/template/.claude/skills/create-presentations/SKILL.md +2 -2
  11. package/template/.claude/skills/create-presentations/references/slide.css +1 -1
  12. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.mjs +47 -0
  13. package/template/.claude/skills/draft-emails/SKILL.md +85 -123
  14. package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
  15. package/template/.claude/skills/draft-emails/scripts/send-email.mjs +118 -0
  16. package/template/.claude/skills/extract-entities/SKILL.md +2 -2
  17. package/template/.claude/skills/extract-entities/scripts/state.mjs +130 -0
  18. package/template/.claude/skills/manage-tasks/SKILL.md +242 -0
  19. package/template/.claude/skills/organize-files/SKILL.md +3 -3
  20. package/template/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
  21. package/template/.claude/skills/organize-files/scripts/summarize.mjs +84 -0
  22. package/template/.claude/skills/process-hyprnote/SKILL.md +2 -2
  23. package/template/.claude/skills/send-chat/SKILL.md +170 -0
  24. package/template/.claude/skills/sync-apple-calendar/SKILL.md +5 -5
  25. package/template/.claude/skills/sync-apple-calendar/scripts/sync.mjs +325 -0
  26. package/template/.claude/skills/sync-apple-mail/SKILL.md +6 -6
  27. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +374 -0
  28. package/template/.claude/skills/sync-apple-mail/scripts/sync.mjs +629 -0
  29. package/template/.claude/skills/track-candidates/SKILL.md +375 -0
  30. package/template/.claude/skills/weekly-update/SKILL.md +250 -0
  31. package/template/CLAUDE.md +63 -40
  32. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +0 -32
  33. package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +0 -34
  34. package/template/.claude/skills/extract-entities/scripts/state.py +0 -100
  35. package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +0 -42
  36. package/template/.claude/skills/organize-files/scripts/summarize.sh +0 -21
  37. package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +0 -242
  38. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +0 -104
  39. package/template/.claude/skills/sync-apple-mail/scripts/sync.py +0 -455
@@ -1,104 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Parse a macOS Mail .emlx or .partial.emlx file and output the plain text body.
3
-
4
- Usage: python3 scripts/parse-emlx.py <path-to-emlx-file>
5
-
6
- The .emlx format is: first line = byte count, then RFC822 message, then Apple
7
- plist. This script extracts and prints the plain text body.
8
-
9
- If the email has no text/plain part (HTML-only), falls back to stripping HTML
10
- tags and outputting as plain text.
11
-
12
- Exit codes:
13
- 0 — success (body printed to stdout)
14
- 1 — file not found or parse error (message on stderr)
15
- """
16
-
17
- import email
18
- import html as html_mod
19
- import re
20
- import sys
21
-
22
-
23
- def html_to_text(html):
24
- """Strip HTML tags and convert to plain text. Uses only stdlib."""
25
- # Remove style and script blocks
26
- text = re.sub(
27
- r"<(style|script)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE
28
- )
29
- # Replace br and p tags with newlines
30
- text = re.sub(r"<br\s*/?\s*>", "\n", text, flags=re.IGNORECASE)
31
- text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
32
- # Strip remaining tags
33
- text = re.sub(r"<[^>]+>", "", text)
34
- # Decode HTML entities
35
- text = html_mod.unescape(text)
36
- # Collapse whitespace
37
- text = re.sub(r"[ \t]+", " ", text)
38
- text = re.sub(r"\n{3,}", "\n\n", text)
39
- return text.strip()
40
-
41
-
42
- def extract_body(msg):
43
- """Extract plain text body from an email message, with HTML fallback."""
44
- body = None
45
- html_body = None
46
-
47
- if msg.is_multipart():
48
- for part in msg.walk():
49
- ct = part.get_content_type()
50
- if ct == "text/plain" and body is None:
51
- charset = part.get_content_charset() or "utf-8"
52
- payload = part.get_payload(decode=True)
53
- if payload:
54
- body = payload.decode(charset, errors="replace")
55
- elif ct == "text/html" and html_body is None:
56
- charset = part.get_content_charset() or "utf-8"
57
- payload = part.get_payload(decode=True)
58
- if payload:
59
- html_body = payload.decode(charset, errors="replace")
60
- else:
61
- ct = msg.get_content_type()
62
- charset = msg.get_content_charset() or "utf-8"
63
- payload = msg.get_payload(decode=True)
64
- if payload:
65
- text = payload.decode(charset, errors="replace")
66
- if ct == "text/plain":
67
- body = text
68
- elif ct == "text/html":
69
- html_body = text
70
-
71
- if body:
72
- return body
73
- elif html_body:
74
- return html_to_text(html_body)
75
- return None
76
-
77
-
78
- def parse_emlx(path):
79
- try:
80
- with open(path, "rb") as f:
81
- byte_count = int(f.readline())
82
- raw = f.read(byte_count)
83
- msg = email.message_from_bytes(raw)
84
-
85
- print(f"From: {msg.get('From', 'Unknown')}")
86
- print(f"Date: {msg.get('Date', '')}")
87
- print("---")
88
-
89
- body = extract_body(msg)
90
- if body:
91
- print(body)
92
- except FileNotFoundError:
93
- print(f"Error: File not found: {path}", file=sys.stderr)
94
- sys.exit(1)
95
- except Exception as e:
96
- print(f"Error parsing {path}: {e}", file=sys.stderr)
97
- sys.exit(1)
98
-
99
-
100
- if __name__ == "__main__":
101
- if len(sys.argv) != 2:
102
- print("Usage: python3 scripts/parse-emlx.py <path>", file=sys.stderr)
103
- sys.exit(1)
104
- parse_emlx(sys.argv[1])
@@ -1,455 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Sync Apple Mail threads to ~/.cache/fit/basecamp/apple_mail/ as markdown.
3
-
4
- Queries the macOS Mail SQLite database for threads with new messages since
5
- the last sync and writes one markdown file per thread.
6
-
7
- Usage: python3 scripts/sync.py [--days N]
8
-
9
- Options:
10
- --days N How many days back to sync on first run (default: 30)
11
-
12
- Requires: macOS with Mail app configured and Full Disk Access granted.
13
- """
14
-
15
- import argparse
16
- import importlib.util
17
- import json
18
- import os
19
- import shutil
20
- import subprocess
21
- import sys
22
- import time
23
- from datetime import datetime, timezone, timedelta
24
- from pathlib import Path
25
-
26
- OUTDIR = os.path.expanduser("~/.cache/fit/basecamp/apple_mail")
27
- ATTACHMENTS_DIR = os.path.join(OUTDIR, "attachments")
28
- STATE_DIR = os.path.expanduser("~/.cache/fit/basecamp/state")
29
- STATE_FILE = os.path.join(STATE_DIR, "apple_mail_last_sync")
30
-
31
- # Import parse-emlx module directly (avoids subprocess per message)
32
- _emlx_spec = importlib.util.spec_from_file_location(
33
- "parse_emlx", os.path.join(os.path.dirname(__file__), "parse-emlx.py")
34
- )
35
- _emlx_mod = importlib.util.module_from_spec(_emlx_spec)
36
- _emlx_spec.loader.exec_module(_emlx_mod)
37
-
38
- MAX_THREADS = 500
39
-
40
-
41
- def find_db():
42
- """Find the Apple Mail Envelope Index database."""
43
- import glob
44
- mail_dir = os.path.expanduser("~/Library/Mail")
45
- # V10, V9, etc. — always in {version}/MailData/Envelope Index
46
- paths = sorted(glob.glob(os.path.join(mail_dir, "V*/MailData/Envelope Index")),
47
- reverse=True)
48
- if not paths:
49
- print("Error: Apple Mail database not found. Is Mail configured?")
50
- sys.exit(1)
51
- db = paths[0]
52
- if not os.access(db, os.R_OK):
53
- print("Error: Cannot read Mail database. Grant Full Disk Access to terminal.")
54
- sys.exit(1)
55
- return db
56
-
57
-
58
- def query(db, sql, retry=True):
59
- """Execute a read-only SQLite query and return JSON results."""
60
- result = subprocess.run(
61
- ["sqlite3", "-readonly", "-json", db, sql],
62
- capture_output=True, text=True
63
- )
64
- if result.returncode != 0:
65
- if retry and "database is locked" in result.stderr:
66
- time.sleep(2)
67
- return query(db, sql, retry=False)
68
- print(f"SQLite error: {result.stderr.strip()}", file=sys.stderr)
69
- return []
70
- return json.loads(result.stdout) if result.stdout.strip() else []
71
-
72
-
73
- def load_last_sync(days_back=30):
74
- """Load the last sync timestamp. Returns Unix timestamp.
75
-
76
- Args:
77
- days_back: How many days back to look on first sync (default: 30).
78
- """
79
- try:
80
- iso = Path(STATE_FILE).read_text().strip()
81
- if iso:
82
- dt = datetime.fromisoformat(iso)
83
- if dt.tzinfo is None:
84
- dt = dt.replace(tzinfo=timezone.utc)
85
- return int(dt.timestamp())
86
- except (FileNotFoundError, ValueError):
87
- pass
88
- # First sync: N days ago
89
- return int((datetime.now(timezone.utc) - timedelta(days=days_back)).timestamp())
90
-
91
-
92
- def save_sync_state():
93
- """Save current time as the sync timestamp."""
94
- os.makedirs(STATE_DIR, exist_ok=True)
95
- now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
96
- Path(STATE_FILE).write_text(now)
97
-
98
-
99
- def unix_to_readable(ts):
100
- """Convert Unix timestamp to readable date string."""
101
- if ts is None:
102
- return "Unknown"
103
- try:
104
- dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
105
- return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
106
- except (ValueError, OSError):
107
- return "Unknown"
108
-
109
-
110
- def discover_thread_column(db):
111
- """Determine which column to use for thread grouping."""
112
- rows = query(db, "PRAGMA table_info(messages);")
113
- columns = {r["name"] for r in rows}
114
- if "conversation_id" in columns:
115
- return "conversation_id"
116
- if "thread_id" in columns:
117
- return "thread_id"
118
- return None
119
-
120
-
121
- def find_changed_threads(db, thread_col, since_ts):
122
- """Find thread IDs with messages newer than since_ts."""
123
- return query(db, f"""
124
- SELECT DISTINCT m.{thread_col} AS tid
125
- FROM messages m
126
- WHERE m.date_received > {since_ts}
127
- AND m.deleted = 0
128
- AND m.mailbox IN (
129
- SELECT ROWID FROM mailboxes
130
- WHERE url LIKE '%/Inbox%'
131
- OR url LIKE '%/INBOX%'
132
- OR url LIKE '%/Sent%'
133
- )
134
- LIMIT {MAX_THREADS};
135
- """)
136
-
137
-
138
- def fetch_thread_messages(db, thread_col, tid):
139
- """Fetch all messages in a thread with sender info."""
140
- return query(db, f"""
141
- SELECT
142
- m.ROWID AS message_id,
143
- m.{thread_col} AS thread_id,
144
- COALESCE(s.subject, '(No Subject)') AS subject,
145
- COALESCE(m.subject_prefix, '') AS subject_prefix,
146
- COALESCE(a.address, 'Unknown') AS sender,
147
- COALESCE(a.comment, '') AS sender_name,
148
- m.date_received,
149
- COALESCE(su.summary, '') AS summary,
150
- COALESCE(m.list_id_hash, 0) AS list_id_hash,
151
- COALESCE(m.automated_conversation, 0) AS automated_conversation
152
- FROM messages m
153
- LEFT JOIN subjects s ON m.subject = s.ROWID
154
- LEFT JOIN addresses a ON m.sender = a.ROWID
155
- LEFT JOIN summaries su ON m.summary = su.ROWID
156
- WHERE m.{thread_col} = {tid}
157
- AND m.deleted = 0
158
- ORDER BY m.date_received ASC;
159
- """)
160
-
161
-
162
- def fetch_recipients(db, message_ids):
163
- """Batch-fetch To/Cc recipients for a set of message IDs."""
164
- if not message_ids:
165
- return {}
166
- id_list = ",".join(str(mid) for mid in message_ids)
167
- rows = query(db, f"""
168
- SELECT
169
- r.message AS message_id,
170
- r.type,
171
- COALESCE(a.address, '') AS address,
172
- COALESCE(a.comment, '') AS name
173
- FROM recipients r
174
- LEFT JOIN addresses a ON r.address = a.ROWID
175
- WHERE r.message IN ({id_list})
176
- ORDER BY r.message, r.type, r.position;
177
- """)
178
- # Group by message_id → {0: To list, 1: Cc list}
179
- result = {}
180
- for r in rows:
181
- mid = r["message_id"]
182
- rtype = r["type"]
183
- if rtype == 2: # Skip Bcc
184
- continue
185
- result.setdefault(mid, {}).setdefault(rtype, []).append(r)
186
- return result
187
-
188
-
189
- def fetch_attachments(db, message_ids):
190
- """Batch-fetch attachment metadata for a set of message IDs."""
191
- if not message_ids:
192
- return {}
193
- id_list = ",".join(str(mid) for mid in message_ids)
194
- rows = query(db, f"""
195
- SELECT a.message AS message_id, a.attachment_id, a.name
196
- FROM attachments a
197
- WHERE a.message IN ({id_list})
198
- ORDER BY a.message, a.ROWID;
199
- """)
200
- result = {}
201
- for r in rows:
202
- mid = r["message_id"]
203
- result.setdefault(mid, []).append({
204
- "attachment_id": r["attachment_id"],
205
- "name": r["name"],
206
- })
207
- return result
208
-
209
-
210
- def build_file_indexes():
211
- """Build emlx and attachment indexes with a single find traversal.
212
-
213
- Returns (emlx_index, attachment_index):
214
- emlx_index: {message_rowid: path} for .emlx files
215
- attachment_index: {(message_rowid, attachment_id): path} for attachment files
216
- """
217
- mail_dir = os.path.expanduser("~/Library/Mail")
218
- try:
219
- result = subprocess.run(
220
- ["find", mail_dir, "(", "-name", "*.emlx", "-o",
221
- "-path", "*/Attachments/*", ")", "-type", "f"],
222
- capture_output=True, text=True, timeout=60
223
- )
224
- emlx_index = {}
225
- attachment_index = {}
226
- for path in result.stdout.strip().splitlines():
227
- if "/Attachments/" in path:
228
- # Path: .../Attachments/{msg_rowid}/{attachment_id}/{filename}
229
- parts = path.split("/Attachments/", 1)
230
- if len(parts) == 2:
231
- segments = parts[1].split("/")
232
- if len(segments) >= 3 and segments[0].isdigit():
233
- msg_rowid = int(segments[0])
234
- att_id = segments[1]
235
- attachment_index[(msg_rowid, att_id)] = path
236
- elif path.endswith(".emlx"):
237
- basename = os.path.basename(path)
238
- msg_id = basename.split(".")[0]
239
- if msg_id.isdigit():
240
- mid = int(msg_id)
241
- # Prefer .emlx over .partial.emlx (shorter name = full message)
242
- if mid not in emlx_index or len(basename) < len(os.path.basename(emlx_index[mid])):
243
- emlx_index[mid] = path
244
- return emlx_index, attachment_index
245
- except (subprocess.TimeoutExpired, Exception):
246
- return {}, {}
247
-
248
-
249
- def parse_emlx(message_id, emlx_index):
250
- """Parse .emlx file for a message using pre-built index. Returns body text or None."""
251
- path = emlx_index.get(message_id)
252
- if not path:
253
- return None
254
- try:
255
- import email as email_lib
256
- with open(path, "rb") as f:
257
- byte_count = int(f.readline())
258
- raw = f.read(byte_count)
259
- msg = email_lib.message_from_bytes(raw)
260
- return _emlx_mod.extract_body(msg)
261
- except Exception:
262
- return None
263
-
264
-
265
- def format_recipient(r):
266
- """Format a recipient as 'Name <email>' or just 'email'."""
267
- name = (r.get("name") or "").strip()
268
- addr = (r.get("address") or "").strip()
269
- if name and addr:
270
- return f"{name} <{addr}>"
271
- return addr or name
272
-
273
-
274
- def format_sender(msg):
275
- """Format sender as 'Name <email>' or just 'email'."""
276
- name = (msg.get("sender_name") or "").strip()
277
- addr = (msg.get("sender") or "").strip()
278
- if name and addr:
279
- return f"{name} <{addr}>"
280
- return addr or name
281
-
282
-
283
- def copy_thread_attachments(thread_id, messages, attachments_by_msg, attachment_index):
284
- """Copy attachment files into the output attachments directory.
285
-
286
- Returns {message_id: [{name, available, path}, ...]} for markdown listing.
287
- """
288
- results = {}
289
- seen_filenames = set()
290
- for msg in messages:
291
- mid = msg["message_id"]
292
- msg_attachments = attachments_by_msg.get(mid, [])
293
- if not msg_attachments:
294
- continue
295
- msg_results = []
296
- for att in msg_attachments:
297
- att_id = att["attachment_id"]
298
- name = att["name"] or "unnamed"
299
- source = attachment_index.get((mid, att_id))
300
- if not source or not os.path.isfile(source):
301
- msg_results.append({"name": name, "available": False, "path": None})
302
- continue
303
- # Handle filename collisions by prefixing with message_id
304
- dest_name = name
305
- if dest_name in seen_filenames:
306
- dest_name = f"{mid}_{name}"
307
- seen_filenames.add(dest_name)
308
- dest_dir = os.path.join(ATTACHMENTS_DIR, str(thread_id))
309
- os.makedirs(dest_dir, exist_ok=True)
310
- dest_path = os.path.join(dest_dir, dest_name)
311
- try:
312
- shutil.copy2(source, dest_path)
313
- msg_results.append({"name": dest_name, "available": True, "path": dest_path})
314
- except OSError:
315
- msg_results.append({"name": name, "available": False, "path": None})
316
- results[mid] = msg_results
317
- return results
318
-
319
-
320
- def write_thread_markdown(thread_id, messages, recipients_by_msg, emlx_index, attachment_results=None):
321
- """Write a thread as a markdown file."""
322
- if not messages:
323
- return False
324
-
325
- # Use base subject from first message (without prefix)
326
- base_subject = messages[0].get("subject", "(No Subject)")
327
-
328
- # Determine flags
329
- is_mailing_list = any(m.get("list_id_hash", 0) != 0 for m in messages)
330
- is_automated = any(m.get("automated_conversation", 0) != 0 for m in messages)
331
- flags = []
332
- if is_mailing_list:
333
- flags.append("mailing-list")
334
- if is_automated:
335
- flags.append("automated")
336
-
337
- lines = []
338
- lines.append(f"# {base_subject}")
339
- lines.append("")
340
- lines.append(f"**Thread ID:** {thread_id}")
341
- lines.append(f"**Message Count:** {len(messages)}")
342
- if flags:
343
- lines.append(f"**Flags:** {', '.join(flags)}")
344
- lines.append("")
345
-
346
- for msg in messages:
347
- lines.append("---")
348
- lines.append("")
349
- lines.append(f"### From: {format_sender(msg)}")
350
- lines.append(f"**Date:** {unix_to_readable(msg.get('date_received'))}")
351
-
352
- # To/Cc recipients
353
- mid = msg.get("message_id")
354
- msg_recips = recipients_by_msg.get(mid, {})
355
- to_list = msg_recips.get(0, [])
356
- cc_list = msg_recips.get(1, [])
357
- if to_list:
358
- lines.append(f"**To:** {', '.join(format_recipient(r) for r in to_list)}")
359
- if cc_list:
360
- lines.append(f"**Cc:** {', '.join(format_recipient(r) for r in cc_list)}")
361
-
362
- lines.append("")
363
-
364
- # Body: try .emlx first, fall back to summary
365
- body = parse_emlx(mid, emlx_index)
366
- if not body:
367
- body = msg.get("summary", "").strip()
368
- if body:
369
- lines.append(body)
370
- lines.append("")
371
-
372
- # Attachments
373
- if attachment_results:
374
- msg_atts = attachment_results.get(mid, [])
375
- if msg_atts:
376
- lines.append("**Attachments:**")
377
- for att in msg_atts:
378
- if att["available"]:
379
- lines.append(f"- [{att['name']}](attachments/{thread_id}/{att['name']})")
380
- else:
381
- lines.append(f"- {att['name']} *(not available)*")
382
- lines.append("")
383
-
384
- filepath = os.path.join(OUTDIR, f"{thread_id}.md")
385
- Path(filepath).write_text("\n".join(lines))
386
- return True
387
-
388
-
389
- def main():
390
- parser = argparse.ArgumentParser(description="Sync Apple Mail threads.")
391
- parser.add_argument("--days", type=int, default=30,
392
- help="How many days back to sync on first run (default: 30)")
393
- args = parser.parse_args()
394
-
395
- db = find_db()
396
- os.makedirs(OUTDIR, exist_ok=True)
397
-
398
- # Load sync state
399
- since_ts = load_last_sync(days_back=args.days)
400
- since_readable = unix_to_readable(since_ts)
401
-
402
- # Discover thread column
403
- thread_col = discover_thread_column(db)
404
- if not thread_col:
405
- print("Error: Could not find conversation_id or thread_id column.")
406
- sys.exit(1)
407
-
408
- # Find changed threads
409
- changed = find_changed_threads(db, thread_col, since_ts)
410
- thread_ids = [r["tid"] for r in changed]
411
-
412
- if not thread_ids:
413
- print("Apple Mail Sync Complete")
414
- print("Threads processed: 0 (no new messages)")
415
- print(f"Time range: {since_readable} to now")
416
- print(f"Output: {OUTDIR}")
417
- save_sync_state()
418
- return
419
-
420
- # Build .emlx and attachment file indexes (single find traversal)
421
- emlx_index, attachment_index = build_file_indexes()
422
-
423
- # Process each thread
424
- written = 0
425
- for tid in thread_ids:
426
- messages = fetch_thread_messages(db, thread_col, tid)
427
- if not messages:
428
- continue
429
-
430
- msg_ids = [m["message_id"] for m in messages]
431
-
432
- # Batch-fetch recipients and attachments for all messages in thread
433
- recipients = fetch_recipients(db, msg_ids)
434
- attachments_by_msg = fetch_attachments(db, msg_ids)
435
-
436
- # Copy attachment files to output directory
437
- attachment_results = copy_thread_attachments(
438
- tid, messages, attachments_by_msg, attachment_index
439
- )
440
-
441
- if write_thread_markdown(tid, messages, recipients, emlx_index, attachment_results):
442
- written += 1
443
-
444
- # Save sync state (even on partial success)
445
- save_sync_state()
446
-
447
- print("Apple Mail Sync Complete")
448
- print(f"Threads processed: {len(thread_ids)}")
449
- print(f"New/updated files: {written}")
450
- print(f"Time range: {since_readable} to now")
451
- print(f"Output: {OUTDIR}")
452
-
453
-
454
- if __name__ == "__main__":
455
- main()