@forwardimpact/basecamp 0.1.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 (36) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +229 -0
  3. package/build.js +124 -0
  4. package/config/scheduler.json +28 -0
  5. package/package.json +37 -0
  6. package/scheduler.js +552 -0
  7. package/scripts/build-pkg.sh +117 -0
  8. package/scripts/compile.sh +26 -0
  9. package/scripts/install.sh +108 -0
  10. package/scripts/pkg-resources/conclusion.html +62 -0
  11. package/scripts/pkg-resources/welcome.html +64 -0
  12. package/scripts/postinstall +46 -0
  13. package/scripts/uninstall.sh +56 -0
  14. package/template/.claude/settings.json +40 -0
  15. package/template/.claude/skills/create-presentations/SKILL.md +75 -0
  16. package/template/.claude/skills/create-presentations/references/slide.css +35 -0
  17. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +32 -0
  18. package/template/.claude/skills/doc-collab/SKILL.md +112 -0
  19. package/template/.claude/skills/draft-emails/SKILL.md +191 -0
  20. package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +33 -0
  21. package/template/.claude/skills/extract-entities/SKILL.md +466 -0
  22. package/template/.claude/skills/extract-entities/references/TEMPLATES.md +131 -0
  23. package/template/.claude/skills/extract-entities/scripts/state.py +100 -0
  24. package/template/.claude/skills/meeting-prep/SKILL.md +135 -0
  25. package/template/.claude/skills/organize-files/SKILL.md +146 -0
  26. package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +42 -0
  27. package/template/.claude/skills/organize-files/scripts/summarize.sh +21 -0
  28. package/template/.claude/skills/sync-apple-calendar/SKILL.md +101 -0
  29. package/template/.claude/skills/sync-apple-calendar/references/SCHEMA.md +80 -0
  30. package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +233 -0
  31. package/template/.claude/skills/sync-apple-mail/SKILL.md +131 -0
  32. package/template/.claude/skills/sync-apple-mail/references/SCHEMA.md +88 -0
  33. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +104 -0
  34. package/template/.claude/skills/sync-apple-mail/scripts/sync.py +348 -0
  35. package/template/CLAUDE.md +152 -0
  36. package/template/USER.md +5 -0
@@ -0,0 +1,348 @@
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
8
+
9
+ Requires: macOS with Mail app configured and Full Disk Access granted.
10
+ """
11
+
12
+ import importlib.util
13
+ import json
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ from datetime import datetime, timezone, timedelta
19
+ from pathlib import Path
20
+
21
+ OUTDIR = os.path.expanduser("~/.cache/fit/basecamp/apple_mail")
22
+ STATE_DIR = os.path.expanduser("~/.cache/fit/basecamp/state")
23
+ STATE_FILE = os.path.join(STATE_DIR, "apple_mail_last_sync")
24
+
25
+ # Import parse-emlx module directly (avoids subprocess per message)
26
+ _emlx_spec = importlib.util.spec_from_file_location(
27
+ "parse_emlx", os.path.join(os.path.dirname(__file__), "parse-emlx.py")
28
+ )
29
+ _emlx_mod = importlib.util.module_from_spec(_emlx_spec)
30
+ _emlx_spec.loader.exec_module(_emlx_mod)
31
+
32
+ MAX_THREADS = 500
33
+
34
+
35
+ def find_db():
36
+ """Find the Apple Mail Envelope Index database."""
37
+ import glob
38
+ mail_dir = os.path.expanduser("~/Library/Mail")
39
+ # V10, V9, etc. — always in {version}/MailData/Envelope Index
40
+ paths = sorted(glob.glob(os.path.join(mail_dir, "V*/MailData/Envelope Index")),
41
+ reverse=True)
42
+ if not paths:
43
+ print("Error: Apple Mail database not found. Is Mail configured?")
44
+ sys.exit(1)
45
+ db = paths[0]
46
+ if not os.access(db, os.R_OK):
47
+ print("Error: Cannot read Mail database. Grant Full Disk Access to terminal.")
48
+ sys.exit(1)
49
+ return db
50
+
51
+
52
+ def query(db, sql, retry=True):
53
+ """Execute a read-only SQLite query and return JSON results."""
54
+ result = subprocess.run(
55
+ ["sqlite3", "-readonly", "-json", db, sql],
56
+ capture_output=True, text=True
57
+ )
58
+ if result.returncode != 0:
59
+ if retry and "database is locked" in result.stderr:
60
+ time.sleep(2)
61
+ return query(db, sql, retry=False)
62
+ print(f"SQLite error: {result.stderr.strip()}", file=sys.stderr)
63
+ return []
64
+ return json.loads(result.stdout) if result.stdout.strip() else []
65
+
66
+
67
+ def load_last_sync():
68
+ """Load the last sync timestamp. Returns Unix timestamp."""
69
+ try:
70
+ iso = Path(STATE_FILE).read_text().strip()
71
+ if iso:
72
+ dt = datetime.fromisoformat(iso)
73
+ if dt.tzinfo is None:
74
+ dt = dt.replace(tzinfo=timezone.utc)
75
+ return int(dt.timestamp())
76
+ except (FileNotFoundError, ValueError):
77
+ pass
78
+ # First sync: 30 days ago
79
+ return int((datetime.now(timezone.utc) - timedelta(days=30)).timestamp())
80
+
81
+
82
+ def save_sync_state():
83
+ """Save current time as the sync timestamp."""
84
+ os.makedirs(STATE_DIR, exist_ok=True)
85
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
86
+ Path(STATE_FILE).write_text(now)
87
+
88
+
89
+ def unix_to_readable(ts):
90
+ """Convert Unix timestamp to readable date string."""
91
+ if ts is None:
92
+ return "Unknown"
93
+ try:
94
+ dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
95
+ return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
96
+ except (ValueError, OSError):
97
+ return "Unknown"
98
+
99
+
100
+ def discover_thread_column(db):
101
+ """Determine which column to use for thread grouping."""
102
+ rows = query(db, "PRAGMA table_info(messages);")
103
+ columns = {r["name"] for r in rows}
104
+ if "conversation_id" in columns:
105
+ return "conversation_id"
106
+ if "thread_id" in columns:
107
+ return "thread_id"
108
+ return None
109
+
110
+
111
+ def find_changed_threads(db, thread_col, since_ts):
112
+ """Find thread IDs with messages newer than since_ts."""
113
+ return query(db, f"""
114
+ SELECT DISTINCT m.{thread_col} AS tid
115
+ FROM messages m
116
+ WHERE m.date_received > {since_ts}
117
+ AND m.deleted = 0
118
+ AND m.mailbox IN (
119
+ SELECT ROWID FROM mailboxes
120
+ WHERE url LIKE '%/Inbox%'
121
+ OR url LIKE '%/INBOX%'
122
+ OR url LIKE '%/Sent%'
123
+ )
124
+ LIMIT {MAX_THREADS};
125
+ """)
126
+
127
+
128
+ def fetch_thread_messages(db, thread_col, tid):
129
+ """Fetch all messages in a thread with sender info."""
130
+ return query(db, f"""
131
+ SELECT
132
+ m.ROWID AS message_id,
133
+ m.{thread_col} AS thread_id,
134
+ COALESCE(s.subject, '(No Subject)') AS subject,
135
+ COALESCE(m.subject_prefix, '') AS subject_prefix,
136
+ COALESCE(a.address, 'Unknown') AS sender,
137
+ COALESCE(a.comment, '') AS sender_name,
138
+ m.date_received,
139
+ COALESCE(su.summary, '') AS summary,
140
+ COALESCE(m.list_id_hash, 0) AS list_id_hash,
141
+ COALESCE(m.automated_conversation, 0) AS automated_conversation
142
+ FROM messages m
143
+ LEFT JOIN subjects s ON m.subject = s.ROWID
144
+ LEFT JOIN addresses a ON m.sender = a.ROWID
145
+ LEFT JOIN summaries su ON m.summary = su.ROWID
146
+ WHERE m.{thread_col} = {tid}
147
+ AND m.deleted = 0
148
+ ORDER BY m.date_received ASC;
149
+ """)
150
+
151
+
152
+ def fetch_recipients(db, message_ids):
153
+ """Batch-fetch To/Cc recipients for a set of message IDs."""
154
+ if not message_ids:
155
+ return {}
156
+ id_list = ",".join(str(mid) for mid in message_ids)
157
+ rows = query(db, f"""
158
+ SELECT
159
+ r.message AS message_id,
160
+ r.type,
161
+ COALESCE(a.address, '') AS address,
162
+ COALESCE(a.comment, '') AS name
163
+ FROM recipients r
164
+ LEFT JOIN addresses a ON r.address = a.ROWID
165
+ WHERE r.message IN ({id_list})
166
+ ORDER BY r.message, r.type, r.position;
167
+ """)
168
+ # Group by message_id → {0: To list, 1: Cc list}
169
+ result = {}
170
+ for r in rows:
171
+ mid = r["message_id"]
172
+ rtype = r["type"]
173
+ if rtype == 2: # Skip Bcc
174
+ continue
175
+ result.setdefault(mid, {}).setdefault(rtype, []).append(r)
176
+ return result
177
+
178
+
179
+ def build_emlx_index():
180
+ """Build a dict mapping message ROWID → .emlx file path with a single find."""
181
+ mail_dir = os.path.expanduser("~/Library/Mail")
182
+ try:
183
+ result = subprocess.run(
184
+ ["find", mail_dir, "-name", "*.emlx"],
185
+ capture_output=True, text=True, timeout=30
186
+ )
187
+ index = {}
188
+ for path in result.stdout.strip().splitlines():
189
+ # Filename is like 12345.emlx or 12345.partial.emlx
190
+ basename = os.path.basename(path)
191
+ # Extract the numeric ID
192
+ msg_id = basename.split(".")[0]
193
+ if msg_id.isdigit():
194
+ mid = int(msg_id)
195
+ # Prefer .emlx over .partial.emlx (shorter name = full message)
196
+ if mid not in index or len(basename) < len(os.path.basename(index[mid])):
197
+ index[mid] = path
198
+ return index
199
+ except (subprocess.TimeoutExpired, Exception):
200
+ return {}
201
+
202
+
203
+ def parse_emlx(message_id, emlx_index):
204
+ """Parse .emlx file for a message using pre-built index. Returns body text or None."""
205
+ path = emlx_index.get(message_id)
206
+ if not path:
207
+ return None
208
+ try:
209
+ import email as email_lib
210
+ with open(path, "rb") as f:
211
+ byte_count = int(f.readline())
212
+ raw = f.read(byte_count)
213
+ msg = email_lib.message_from_bytes(raw)
214
+ return _emlx_mod.extract_body(msg)
215
+ except Exception:
216
+ return None
217
+
218
+
219
+ def format_recipient(r):
220
+ """Format a recipient as 'Name <email>' or just 'email'."""
221
+ name = (r.get("name") or "").strip()
222
+ addr = (r.get("address") or "").strip()
223
+ if name and addr:
224
+ return f"{name} <{addr}>"
225
+ return addr or name
226
+
227
+
228
+ def format_sender(msg):
229
+ """Format sender as 'Name <email>' or just 'email'."""
230
+ name = (msg.get("sender_name") or "").strip()
231
+ addr = (msg.get("sender") or "").strip()
232
+ if name and addr:
233
+ return f"{name} <{addr}>"
234
+ return addr or name
235
+
236
+
237
+ def write_thread_markdown(thread_id, messages, recipients_by_msg, emlx_index):
238
+ """Write a thread as a markdown file."""
239
+ if not messages:
240
+ return False
241
+
242
+ # Use base subject from first message (without prefix)
243
+ base_subject = messages[0].get("subject", "(No Subject)")
244
+
245
+ # Determine flags
246
+ is_mailing_list = any(m.get("list_id_hash", 0) != 0 for m in messages)
247
+ is_automated = any(m.get("automated_conversation", 0) != 0 for m in messages)
248
+ flags = []
249
+ if is_mailing_list:
250
+ flags.append("mailing-list")
251
+ if is_automated:
252
+ flags.append("automated")
253
+
254
+ lines = []
255
+ lines.append(f"# {base_subject}")
256
+ lines.append("")
257
+ lines.append(f"**Thread ID:** {thread_id}")
258
+ lines.append(f"**Message Count:** {len(messages)}")
259
+ if flags:
260
+ lines.append(f"**Flags:** {', '.join(flags)}")
261
+ lines.append("")
262
+
263
+ for msg in messages:
264
+ lines.append("---")
265
+ lines.append("")
266
+ lines.append(f"### From: {format_sender(msg)}")
267
+ lines.append(f"**Date:** {unix_to_readable(msg.get('date_received'))}")
268
+
269
+ # To/Cc recipients
270
+ mid = msg.get("message_id")
271
+ msg_recips = recipients_by_msg.get(mid, {})
272
+ to_list = msg_recips.get(0, [])
273
+ cc_list = msg_recips.get(1, [])
274
+ if to_list:
275
+ lines.append(f"**To:** {', '.join(format_recipient(r) for r in to_list)}")
276
+ if cc_list:
277
+ lines.append(f"**Cc:** {', '.join(format_recipient(r) for r in cc_list)}")
278
+
279
+ lines.append("")
280
+
281
+ # Body: try .emlx first, fall back to summary
282
+ body = parse_emlx(mid, emlx_index)
283
+ if not body:
284
+ body = msg.get("summary", "").strip()
285
+ if body:
286
+ lines.append(body)
287
+ lines.append("")
288
+
289
+ filepath = os.path.join(OUTDIR, f"{thread_id}.md")
290
+ Path(filepath).write_text("\n".join(lines))
291
+ return True
292
+
293
+
294
+ def main():
295
+ db = find_db()
296
+ os.makedirs(OUTDIR, exist_ok=True)
297
+
298
+ # Load sync state
299
+ since_ts = load_last_sync()
300
+ since_readable = unix_to_readable(since_ts)
301
+
302
+ # Discover thread column
303
+ thread_col = discover_thread_column(db)
304
+ if not thread_col:
305
+ print("Error: Could not find conversation_id or thread_id column.")
306
+ sys.exit(1)
307
+
308
+ # Find changed threads
309
+ changed = find_changed_threads(db, thread_col, since_ts)
310
+ thread_ids = [r["tid"] for r in changed]
311
+
312
+ if not thread_ids:
313
+ print("Apple Mail Sync Complete")
314
+ print("Threads processed: 0 (no new messages)")
315
+ print(f"Time range: {since_readable} to now")
316
+ print(f"Output: {OUTDIR}")
317
+ save_sync_state()
318
+ return
319
+
320
+ # Build .emlx file index once (single find traversal)
321
+ emlx_index = build_emlx_index()
322
+
323
+ # Process each thread
324
+ written = 0
325
+ for tid in thread_ids:
326
+ messages = fetch_thread_messages(db, thread_col, tid)
327
+ if not messages:
328
+ continue
329
+
330
+ # Batch-fetch recipients for all messages in thread
331
+ msg_ids = [m["message_id"] for m in messages]
332
+ recipients = fetch_recipients(db, msg_ids)
333
+
334
+ if write_thread_markdown(tid, messages, recipients, emlx_index):
335
+ written += 1
336
+
337
+ # Save sync state (even on partial success)
338
+ save_sync_state()
339
+
340
+ print("Apple Mail Sync Complete")
341
+ print(f"Threads processed: {len(thread_ids)}")
342
+ print(f"New/updated files: {written}")
343
+ print(f"Time range: {since_readable} to now")
344
+ print(f"Output: {OUTDIR}")
345
+
346
+
347
+ if __name__ == "__main__":
348
+ main()
@@ -0,0 +1,152 @@
1
+ # Basecamp Knowledge Base
2
+
3
+ You are the user's personal knowledge assistant. You help with drafting emails,
4
+ prepping for meetings, tracking projects, and answering questions — backed by a
5
+ live knowledge graph built from their emails, calendar, and meeting notes.
6
+ Everything lives locally on this machine.
7
+
8
+ ## Personality
9
+
10
+ - **Supportive thoroughness:** Explain complex topics clearly and completely.
11
+ - **Lighthearted:** Friendly tone with subtle humor and warmth.
12
+ - **Decisive:** Don't hedge. If the next step is obvious, do it.
13
+ - Do NOT say: "would you like me to", "want me to do that", "should I", "shall
14
+ I".
15
+ - Ask at most one clarifying question at the start, never at the end.
16
+
17
+ ## Dependencies
18
+
19
+ - **ripgrep** (`rg`) — used for fast knowledge graph searches. Install:
20
+ `brew install ripgrep`
21
+
22
+ ## Workspace Layout
23
+
24
+ This directory is a knowledge base. Everything is relative to this root:
25
+
26
+ ```
27
+ ./
28
+ ├── knowledge/ # The knowledge graph (Obsidian-compatible)
29
+ │ ├── People/ # Notes on individuals
30
+ │ ├── Organizations/ # Notes on companies and teams
31
+ │ ├── Projects/ # Notes on initiatives and workstreams
32
+ │ └── Topics/ # Notes on recurring themes
33
+ ├── .claude/skills/ # Claude Code skill files (auto-discovered)
34
+ ├── drafts/ # Email drafts created by the draft-emails skill
35
+ ├── USER.md # Your identity (name, email, domain) — gitignored
36
+ ├── CLAUDE.md # This file
37
+ └── .mcp.json # MCP server configurations (optional)
38
+ ```
39
+
40
+ ## Cache Directory (`~/.cache/fit/basecamp/`)
41
+
42
+ Synced data and runtime state live outside the knowledge base in
43
+ `~/.cache/fit/basecamp/`:
44
+
45
+ ```
46
+ ~/.cache/fit/basecamp/
47
+ ├── apple_mail/ # Synced Apple Mail threads (.md files)
48
+ ├── apple_calendar/ # Synced Apple Calendar events (.json files)
49
+ ├── google_calendar/ # Synced Google Calendar events (.json files)
50
+ └── state/ # Runtime state (plain text files)
51
+ ├── apple_mail_last_sync # ISO timestamp of last mail sync
52
+ └── graph_processed # TSV of processed files (path<TAB>hash)
53
+ ```
54
+
55
+ This separation keeps the knowledge base clean — only the parsed knowledge
56
+ graph, notes, documents, and drafts live in the KB directory. Raw synced data
57
+ and processing state are cached externally. State files use simple Unix-friendly
58
+ formats (single-value text files, TSV) rather than JSON, making them easy to
59
+ read and write from shell scripts.
60
+
61
+ ## How to Access the Knowledge Graph
62
+
63
+ The knowledge graph is plain markdown with Obsidian-style `[[backlinks]]`.
64
+
65
+ **Finding notes:**
66
+
67
+ ```bash
68
+ # List all people
69
+ ls knowledge/People/
70
+
71
+ # Search for a person by name
72
+ rg "Sarah Chen" knowledge/
73
+
74
+ # Find notes mentioning a company
75
+ rg "Acme Corp" knowledge/
76
+ ```
77
+
78
+ **Reading notes:**
79
+
80
+ ```bash
81
+ cat "knowledge/People/Sarah Chen.md"
82
+ cat "knowledge/Organizations/Acme Corp.md"
83
+ ```
84
+
85
+ **CRITICAL:** When the user mentions ANY person, organization, project, or topic
86
+ by name, you MUST look them up in the knowledge base FIRST before responding. Do
87
+ not provide generic responses. Look up the context, then respond with that
88
+ knowledge.
89
+
90
+ **When to access:**
91
+
92
+ - Always when the user mentions a named entity (person, org, project, topic)
93
+ - When tasks involve specific people, projects, or past context
94
+ - When referencing meetings, emails, or calendar data
95
+ - NOT for general knowledge questions, brainstorming, or tasks unrelated to
96
+ user's work context
97
+
98
+ ## Emails & Calendar Data
99
+
100
+ Synced emails and calendar events are stored in `~/.cache/fit/basecamp/`,
101
+ outside the knowledge base:
102
+
103
+ - **Emails:** `~/.cache/fit/basecamp/apple_mail/` — each thread is a `.md` file
104
+ - **Calendar:** `~/.cache/fit/basecamp/apple_calendar/` — each event is a
105
+ `.json` file
106
+
107
+ When the user asks about calendar, upcoming meetings, or recent emails, read
108
+ directly from these folders.
109
+
110
+ ## Skills
111
+
112
+ Skills are auto-discovered by Claude Code from `.claude/skills/`. Each skill is
113
+ a `SKILL.md` file inside a named directory. You do NOT need to read them
114
+ manually — Claude Code loads them automatically based on context.
115
+
116
+ Available skills:
117
+
118
+ | Skill | Directory | Purpose |
119
+ | ---------------------- | -------------------------------------- | ---------------------------------------------- |
120
+ | Sync Apple Mail | `.claude/skills/sync-apple-mail/` | Sync Apple Mail threads via SQLite |
121
+ | Sync Apple Calendar | `.claude/skills/sync-apple-calendar/` | Sync Apple Calendar events via SQLite |
122
+ | Extract Entities | `.claude/skills/extract-entities/` | Process synced data into knowledge graph notes |
123
+ | Draft Emails | `.claude/skills/draft-emails/` | Draft email responses using knowledge context |
124
+ | Meeting Prep | `.claude/skills/meeting-prep/` | Prepare briefings for upcoming meetings |
125
+ | Create Presentations | `.claude/skills/create-presentations/` | Create slide decks as PDF |
126
+ | Document Collaboration | `.claude/skills/doc-collab/` | Document creation and collaboration |
127
+ | Organize Files | `.claude/skills/organize-files/` | File organization and cleanup |
128
+
129
+ ## User Identity
130
+
131
+ @import USER.md
132
+
133
+ Use this for:
134
+
135
+ - Excluding self from entity extraction
136
+ - Identifying internal vs. external contacts
137
+ - Personalizing responses
138
+
139
+ ## Communication Style
140
+
141
+ - Be concise and direct. No verbose explanations unless asked.
142
+ - Break complex work into clear sequential steps.
143
+ - Always confirm before destructive actions.
144
+ - When referencing files, give the full path.
145
+ - Use the knowledge graph context to personalize every response.
146
+
147
+ ## Working Outside This Directory
148
+
149
+ You have full access to the user's filesystem. The user is on macOS. For tasks
150
+ outside this knowledge base (organizing Desktop, finding files in Downloads,
151
+ etc.), just use shell commands directly. Never say you can't access something —
152
+ just do it.
@@ -0,0 +1,5 @@
1
+ # User Identity
2
+
3
+ - **Name:**
4
+ - **Email:**
5
+ - **Domain:**