@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.
- package/LICENSE +201 -0
- package/README.md +229 -0
- package/build.js +124 -0
- package/config/scheduler.json +28 -0
- package/package.json +37 -0
- package/scheduler.js +552 -0
- package/scripts/build-pkg.sh +117 -0
- package/scripts/compile.sh +26 -0
- package/scripts/install.sh +108 -0
- package/scripts/pkg-resources/conclusion.html +62 -0
- package/scripts/pkg-resources/welcome.html +64 -0
- package/scripts/postinstall +46 -0
- package/scripts/uninstall.sh +56 -0
- package/template/.claude/settings.json +40 -0
- package/template/.claude/skills/create-presentations/SKILL.md +75 -0
- package/template/.claude/skills/create-presentations/references/slide.css +35 -0
- package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +32 -0
- package/template/.claude/skills/doc-collab/SKILL.md +112 -0
- package/template/.claude/skills/draft-emails/SKILL.md +191 -0
- package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +33 -0
- package/template/.claude/skills/extract-entities/SKILL.md +466 -0
- package/template/.claude/skills/extract-entities/references/TEMPLATES.md +131 -0
- package/template/.claude/skills/extract-entities/scripts/state.py +100 -0
- package/template/.claude/skills/meeting-prep/SKILL.md +135 -0
- package/template/.claude/skills/organize-files/SKILL.md +146 -0
- package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +42 -0
- package/template/.claude/skills/organize-files/scripts/summarize.sh +21 -0
- package/template/.claude/skills/sync-apple-calendar/SKILL.md +101 -0
- package/template/.claude/skills/sync-apple-calendar/references/SCHEMA.md +80 -0
- package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +233 -0
- package/template/.claude/skills/sync-apple-mail/SKILL.md +131 -0
- package/template/.claude/skills/sync-apple-mail/references/SCHEMA.md +88 -0
- package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +104 -0
- package/template/.claude/skills/sync-apple-mail/scripts/sync.py +348 -0
- package/template/CLAUDE.md +152 -0
- 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.
|