@forwardimpact/outpost 3.0.3
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 +15 -0
- package/bin/fit-outpost.js +3 -0
- package/config/scheduler.json +34 -0
- package/package.json +63 -0
- package/src/agent-runner.js +189 -0
- package/src/index.js +5 -0
- package/src/kb-manager.js +228 -0
- package/src/outpost.js +417 -0
- package/src/scheduler.js +195 -0
- package/src/socket-server.js +336 -0
- package/src/state-manager.js +126 -0
- package/templates/.claude/agents/chief-of-staff.md +58 -0
- package/templates/.claude/agents/concierge.md +46 -0
- package/templates/.claude/agents/head-hunter.md +57 -0
- package/templates/.claude/agents/librarian.md +60 -0
- package/templates/.claude/agents/postman.md +47 -0
- package/templates/.claude/agents/recruiter.md +63 -0
- package/templates/.claude/settings.json +88 -0
- package/templates/.claude/skills/candidate-report/SKILL.md +139 -0
- package/templates/.claude/skills/candidate-report/references/report-template.html +203 -0
- package/templates/.claude/skills/candidate-report/references/report.css +438 -0
- package/templates/.claude/skills/candidate-report/references/rubric.md +76 -0
- package/templates/.claude/skills/candidate-report/scripts/render-pdf.mjs +48 -0
- package/templates/.claude/skills/deck-create/SKILL.md +75 -0
- package/templates/.claude/skills/deck-create/references/slide.css +35 -0
- package/templates/.claude/skills/deck-create/scripts/convert-to-pdf.mjs +47 -0
- package/templates/.claude/skills/deck-summarize/SKILL.md +139 -0
- package/templates/.claude/skills/deck-summarize/references/brief-template.md +85 -0
- package/templates/.claude/skills/deck-summarize/scripts/extract-pptx.mjs +210 -0
- package/templates/.claude/skills/doc-collab/SKILL.md +112 -0
- package/templates/.claude/skills/doc-create/SKILL.md +105 -0
- package/templates/.claude/skills/doc-create/scripts/convert-to-pdf.mjs +50 -0
- package/templates/.claude/skills/draft-emails/SKILL.md +141 -0
- package/templates/.claude/skills/draft-emails/references/template.md +26 -0
- package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
- package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +139 -0
- package/templates/.claude/skills/extract-entities/SKILL.md +187 -0
- package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +14 -0
- package/templates/.claude/skills/extract-entities/references/conditions.md +55 -0
- package/templates/.claude/skills/extract-entities/references/content.md +104 -0
- package/templates/.claude/skills/extract-entities/references/links.md +48 -0
- package/templates/.claude/skills/extract-entities/references/recruitment.md +57 -0
- package/templates/.claude/skills/extract-entities/references/resolution.md +71 -0
- package/templates/.claude/skills/extract-entities/references/sources.md +70 -0
- package/templates/.claude/skills/extract-entities/references/templates-conditions.md +36 -0
- package/templates/.claude/skills/extract-entities/references/templates-goals-priorities.md +63 -0
- package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +67 -0
- package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +68 -0
- package/templates/.claude/skills/extract-entities/scripts/state.mjs +131 -0
- package/templates/.claude/skills/hyprnote-follow/SKILL.md +174 -0
- package/templates/.claude/skills/hyprnote-follow/references/coaching.md +118 -0
- package/templates/.claude/skills/hyprnote-follow/references/debrief.md +59 -0
- package/templates/.claude/skills/hyprnote-follow/references/meeting-types.md +14 -0
- package/templates/.claude/skills/hyprnote-follow/scripts/follow.mjs +243 -0
- package/templates/.claude/skills/hyprnote-process/SKILL.md +147 -0
- package/templates/.claude/skills/hyprnote-process/references/extraction.md +84 -0
- package/templates/.claude/skills/hyprnote-process/references/sessions.md +81 -0
- package/templates/.claude/skills/hyprnote-process/scripts/scan.mjs +246 -0
- package/templates/.claude/skills/hyprnote-trim/SKILL.md +177 -0
- package/templates/.claude/skills/manage-tasks/SKILL.md +152 -0
- package/templates/.claude/skills/manage-tasks/references/format.md +73 -0
- package/templates/.claude/skills/meeting-prep/SKILL.md +180 -0
- package/templates/.claude/skills/organize-files/SKILL.md +146 -0
- package/templates/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
- package/templates/.claude/skills/organize-files/scripts/summarize.mjs +91 -0
- package/templates/.claude/skills/req-assess/SKILL.md +137 -0
- package/templates/.claude/skills/req-assess/references/interview-template.md +62 -0
- package/templates/.claude/skills/req-assess/references/panel-template.md +117 -0
- package/templates/.claude/skills/req-assess/references/rubric.md +65 -0
- package/templates/.claude/skills/req-decide/SKILL.md +163 -0
- package/templates/.claude/skills/req-decide/references/rubric.md +63 -0
- package/templates/.claude/skills/req-decide/references/template.md +118 -0
- package/templates/.claude/skills/req-forget/SKILL.md +154 -0
- package/templates/.claude/skills/req-forget/references/classify.md +23 -0
- package/templates/.claude/skills/req-forget/references/locations.md +59 -0
- package/templates/.claude/skills/req-forget/references/report-template.md +67 -0
- package/templates/.claude/skills/req-scan/SKILL.md +145 -0
- package/templates/.claude/skills/req-scan/references/fallbacks.md +76 -0
- package/templates/.claude/skills/req-scan/references/filters.md +83 -0
- package/templates/.claude/skills/req-scan/references/sources.md +76 -0
- package/templates/.claude/skills/req-scan/references/state.md +54 -0
- package/templates/.claude/skills/req-scan/references/template.md +36 -0
- package/templates/.claude/skills/req-scan/scripts/state.mjs +396 -0
- package/templates/.claude/skills/req-screen/SKILL.md +151 -0
- package/templates/.claude/skills/req-screen/references/rubric.md +94 -0
- package/templates/.claude/skills/req-screen/references/template.md +91 -0
- package/templates/.claude/skills/req-track/SKILL.md +189 -0
- package/templates/.claude/skills/req-track/references/fields.md +114 -0
- package/templates/.claude/skills/req-track/references/signals.md +42 -0
- package/templates/.claude/skills/req-track/references/statuses.md +32 -0
- package/templates/.claude/skills/req-track/references/templates.md +127 -0
- package/templates/.claude/skills/req-workday/SKILL.md +162 -0
- package/templates/.claude/skills/req-workday/references/field-mapping.md +40 -0
- package/templates/.claude/skills/req-workday/references/status-mapping.md +38 -0
- package/templates/.claude/skills/req-workday/references/templates.md +126 -0
- package/templates/.claude/skills/req-workday/references/xlsx-format.md +56 -0
- package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +313 -0
- package/templates/.claude/skills/send-chat/SKILL.md +170 -0
- package/templates/.claude/skills/sync-apple-calendar/SKILL.md +145 -0
- package/templates/.claude/skills/sync-apple-calendar/references/SCHEMA.md +80 -0
- package/templates/.claude/skills/sync-apple-calendar/scripts/query.mjs +307 -0
- package/templates/.claude/skills/sync-apple-calendar/scripts/sync.mjs +332 -0
- package/templates/.claude/skills/sync-apple-mail/SKILL.md +151 -0
- package/templates/.claude/skills/sync-apple-mail/references/SCHEMA.md +115 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +389 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/sync-helpers.mjs +372 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/sync.mjs +239 -0
- package/templates/.claude/skills/sync-teams/SKILL.md +177 -0
- package/templates/.claude/skills/sync-teams/scripts/idb-reader.mjs +147 -0
- package/templates/.claude/skills/sync-teams/scripts/leveldb-reader.mjs +288 -0
- package/templates/.claude/skills/sync-teams/scripts/sync.mjs +381 -0
- package/templates/.claude/skills/upstream-skill/SKILL.md +130 -0
- package/templates/.claude/skills/upstream-skill/references/examples.md +80 -0
- package/templates/.claude/skills/weekly-update/SKILL.md +141 -0
- package/templates/.claude/skills/weekly-update/references/template.md +55 -0
- package/templates/CLAUDE.md +134 -0
- package/templates/USER.md +3 -0
- package/templates/apm.yml +5 -0
- package/templates/knowledge/Briefings/.gitkeep +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Sync Apple Calendar events to ~/.cache/fit/outpost/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/outpost/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 fetchEvents(db, startTs, endTs) {
|
|
173
|
+
return query(
|
|
174
|
+
db,
|
|
175
|
+
`
|
|
176
|
+
SELECT
|
|
177
|
+
ci.ROWID AS id,
|
|
178
|
+
ci.summary,
|
|
179
|
+
ci.start_date,
|
|
180
|
+
ci.end_date,
|
|
181
|
+
ci.start_tz,
|
|
182
|
+
ci.end_tz,
|
|
183
|
+
ci.all_day,
|
|
184
|
+
ci.description,
|
|
185
|
+
ci.has_attendees,
|
|
186
|
+
ci.conference_url,
|
|
187
|
+
loc.title AS location,
|
|
188
|
+
cal.title AS calendar_name,
|
|
189
|
+
org.address AS organizer_email,
|
|
190
|
+
org.display_name AS organizer_name
|
|
191
|
+
FROM CalendarItem ci
|
|
192
|
+
LEFT JOIN Location loc ON loc.ROWID = ci.location_id
|
|
193
|
+
LEFT JOIN Calendar cal ON cal.ROWID = ci.calendar_id
|
|
194
|
+
LEFT JOIN Identity org ON org.ROWID = ci.organizer_id
|
|
195
|
+
WHERE ci.start_date <= ${endTs}
|
|
196
|
+
AND COALESCE(ci.end_date, ci.start_date) >= ${startTs}
|
|
197
|
+
AND ci.summary IS NOT NULL
|
|
198
|
+
AND ci.summary != ''
|
|
199
|
+
ORDER BY ci.start_date ASC
|
|
200
|
+
LIMIT 1000;
|
|
201
|
+
`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function fetchAttendeesByEvent(db, eventIds) {
|
|
206
|
+
const attendeesByEvent = {};
|
|
207
|
+
if (eventIds.length === 0) return attendeesByEvent;
|
|
208
|
+
const idList = eventIds.join(",");
|
|
209
|
+
const attendeesRaw = query(
|
|
210
|
+
db,
|
|
211
|
+
`
|
|
212
|
+
SELECT p.owner_id, p.email, p.status, p.role, p.is_self, p.entity_type, i.display_name
|
|
213
|
+
FROM Participant p
|
|
214
|
+
LEFT JOIN Identity i ON i.ROWID = p.identity_id
|
|
215
|
+
WHERE p.owner_id IN (${idList}) AND p.entity_type = 7;
|
|
216
|
+
`,
|
|
217
|
+
);
|
|
218
|
+
for (const a of attendeesRaw) {
|
|
219
|
+
attendeesByEvent[a.owner_id] ??= [];
|
|
220
|
+
attendeesByEvent[a.owner_id].push(a);
|
|
221
|
+
}
|
|
222
|
+
return attendeesByEvent;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function buildAttendeeList(rawAttendees) {
|
|
226
|
+
const attendees = [];
|
|
227
|
+
for (const a of rawAttendees) {
|
|
228
|
+
if (!a.email) continue;
|
|
229
|
+
attendees.push({
|
|
230
|
+
email: a.email,
|
|
231
|
+
name: (a.display_name ?? "").trim() || null,
|
|
232
|
+
status: STATUS_MAP[a.status] ?? "unknown",
|
|
233
|
+
role: ROLE_MAP[a.role] ?? "unknown",
|
|
234
|
+
self: Boolean(a.is_self),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return attendees.length > 0 ? attendees : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildOrganizer(ev) {
|
|
241
|
+
let orgEmail = ev.organizer_email ?? null;
|
|
242
|
+
if (orgEmail?.startsWith("mailto:")) orgEmail = orgEmail.slice(7);
|
|
243
|
+
if (!orgEmail) return null;
|
|
244
|
+
return { email: orgEmail, name: (ev.organizer_name ?? "").trim() || null };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function tzOrNull(tz) {
|
|
248
|
+
return tz !== "_float" ? tz : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function buildEventJson(ev, attendeesByEvent) {
|
|
252
|
+
return {
|
|
253
|
+
id: `apple_cal_${ev.id}`,
|
|
254
|
+
summary: ev.summary,
|
|
255
|
+
start: {
|
|
256
|
+
dateTime: coredataToIso(ev.start_date, ev.start_tz),
|
|
257
|
+
timeZone: tzOrNull(ev.start_tz),
|
|
258
|
+
},
|
|
259
|
+
end: {
|
|
260
|
+
dateTime: coredataToIso(ev.end_date ?? ev.start_date, ev.end_tz),
|
|
261
|
+
timeZone: tzOrNull(ev.end_tz),
|
|
262
|
+
},
|
|
263
|
+
allDay: Boolean(ev.all_day),
|
|
264
|
+
location: ev.location || null,
|
|
265
|
+
description: ev.description || null,
|
|
266
|
+
conferenceUrl: ev.conference_url || null,
|
|
267
|
+
calendar: ev.calendar_name || null,
|
|
268
|
+
organizer: buildOrganizer(ev),
|
|
269
|
+
attendees: buildAttendeeList(attendeesByEvent[ev.id] ?? []),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function writeEventFiles(events, attendeesByEvent) {
|
|
274
|
+
const writtenIds = new Set();
|
|
275
|
+
for (const ev of events) {
|
|
276
|
+
const eventJson = buildEventJson(ev, attendeesByEvent);
|
|
277
|
+
const filename = `${ev.id}.json`;
|
|
278
|
+
writeFileSync(join(OUTDIR, filename), JSON.stringify(eventJson, null, 2));
|
|
279
|
+
writtenIds.add(filename);
|
|
280
|
+
}
|
|
281
|
+
return writtenIds;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function cleanupStaleFiles(writtenIds) {
|
|
285
|
+
let removed = 0;
|
|
286
|
+
for (const fname of readdirSync(OUTDIR)) {
|
|
287
|
+
if (fname.endsWith(".json") && !writtenIds.has(fname)) {
|
|
288
|
+
unlinkSync(join(OUTDIR, fname));
|
|
289
|
+
removed++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return removed;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function main() {
|
|
296
|
+
let daysBack = 30;
|
|
297
|
+
const daysIdx = process.argv.indexOf("--days");
|
|
298
|
+
if (daysIdx !== -1 && process.argv[daysIdx + 1]) {
|
|
299
|
+
daysBack = parseInt(process.argv[daysIdx + 1], 10);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const dbPath = findDb();
|
|
303
|
+
mkdirSync(OUTDIR, { recursive: true });
|
|
304
|
+
|
|
305
|
+
const now = Date.now();
|
|
306
|
+
const start = new Date(now - daysBack * 86400000);
|
|
307
|
+
const end = new Date(now + 14 * 86400000);
|
|
308
|
+
const startTs = (start.getTime() - EPOCH_MS) / 1000;
|
|
309
|
+
const endTs = (end.getTime() - EPOCH_MS) / 1000;
|
|
310
|
+
|
|
311
|
+
const db = openDb(dbPath);
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const events = fetchEvents(db, startTs, endTs);
|
|
315
|
+
const eventIds = events.map((ev) => String(ev.id));
|
|
316
|
+
const attendeesByEvent = fetchAttendeesByEvent(db, eventIds);
|
|
317
|
+
const writtenIds = writeEventFiles(events, attendeesByEvent);
|
|
318
|
+
const removed = cleanupStaleFiles(writtenIds);
|
|
319
|
+
|
|
320
|
+
console.log("Apple Calendar Sync Complete");
|
|
321
|
+
console.log(`Events synced: ${writtenIds.size}`);
|
|
322
|
+
console.log(
|
|
323
|
+
`Time window: ${start.toISOString().slice(0, 10)} to ${end.toISOString().slice(0, 10)}`,
|
|
324
|
+
);
|
|
325
|
+
console.log(`Files cleaned up: ${removed} (outside window)`);
|
|
326
|
+
console.log(`Output: ${OUTDIR}`);
|
|
327
|
+
} finally {
|
|
328
|
+
db.close();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
main();
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sync-apple-mail
|
|
3
|
+
description: Sync email threads from the macOS Mail app's local SQLite database into ~/.cache/fit/outpost/apple_mail/ as markdown files. Use on a schedule or when the user asks to sync their email. Requires macOS with Mail app configured and Full Disk Access granted.
|
|
4
|
+
compatibility: Requires macOS with Apple Mail configured and Full Disk Access granted to the terminal
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Sync Apple Mail
|
|
8
|
+
|
|
9
|
+
Sync email threads from the macOS Mail app's local SQLite database into
|
|
10
|
+
`~/.cache/fit/outpost/apple_mail/` as markdown files. This is an automated data
|
|
11
|
+
pipeline skill — it ingests raw email data that other skills (like
|
|
12
|
+
`extract-entities`) consume downstream.
|
|
13
|
+
|
|
14
|
+
## Trigger
|
|
15
|
+
|
|
16
|
+
Run this skill on a schedule (every 5 minutes) or when the user asks to sync
|
|
17
|
+
their email.
|
|
18
|
+
|
|
19
|
+
## Prerequisites
|
|
20
|
+
|
|
21
|
+
- macOS with the built-in Mail app configured
|
|
22
|
+
- Full Disk Access granted to the terminal (System Settings → Privacy & Security
|
|
23
|
+
→ Full Disk Access)
|
|
24
|
+
|
|
25
|
+
## Inputs
|
|
26
|
+
|
|
27
|
+
- `~/.cache/fit/outpost/state/apple_mail_last_sync` — last sync timestamp
|
|
28
|
+
(single-line text file)
|
|
29
|
+
- `~/.cache/fit/outpost/state/apple_mail_last_rowid` — highest message ROWID
|
|
30
|
+
seen at last sync (single-line text file)
|
|
31
|
+
- `~/Library/Mail/V*/MailData/Envelope Index` — Apple Mail SQLite database
|
|
32
|
+
|
|
33
|
+
## Outputs
|
|
34
|
+
|
|
35
|
+
- `~/.cache/fit/outpost/apple_mail/{thread_id}.md` — one markdown file per email
|
|
36
|
+
thread
|
|
37
|
+
- `~/.cache/fit/outpost/apple_mail/attachments/{thread_id}/` — copied attachment
|
|
38
|
+
files for each thread (PDFs, images, documents, etc.)
|
|
39
|
+
- `~/.cache/fit/outpost/state/apple_mail_last_sync` — updated with new sync
|
|
40
|
+
timestamp
|
|
41
|
+
- `~/.cache/fit/outpost/state/apple_mail_last_rowid` — updated with highest
|
|
42
|
+
ROWID seen
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Implementation
|
|
47
|
+
|
|
48
|
+
Run the sync as a single Node.js script with embedded SQLite. This avoids N+1
|
|
49
|
+
process invocations and handles all data transformation in one pass:
|
|
50
|
+
|
|
51
|
+
node scripts/sync.mjs [--days N]
|
|
52
|
+
|
|
53
|
+
- `--days N` — how many days back to look on first sync (default: 30)
|
|
54
|
+
|
|
55
|
+
The script:
|
|
56
|
+
|
|
57
|
+
1. Finds the Mail database (`~/Library/Mail/V*/MailData/Envelope Index`)
|
|
58
|
+
2. Loads last sync timestamp (or defaults to `--days` days ago for first sync)
|
|
59
|
+
3. Discovers the thread grouping column (`conversation_id` or `thread_id`)
|
|
60
|
+
4. Loads last-seen ROWID (or defaults to 0 for first sync)
|
|
61
|
+
5. Finds threads with new messages since last sync (up to 500), using both
|
|
62
|
+
timestamp and ROWID to catch late-arriving emails (emails downloaded after a
|
|
63
|
+
delay may have `date_received` before the last sync timestamp, but their
|
|
64
|
+
ROWID will be higher than the last-seen ROWID)
|
|
65
|
+
6. For each thread: fetches messages, batch-fetches recipients and attachment
|
|
66
|
+
metadata, parses `.emlx` files for full email bodies (falling back to
|
|
67
|
+
database summaries), copies attachment files to the output directory
|
|
68
|
+
7. Writes one markdown file per thread to `~/.cache/fit/outpost/apple_mail/`
|
|
69
|
+
8. Updates sync state (timestamp and max ROWID)
|
|
70
|
+
9. Reports summary (threads processed, files written)
|
|
71
|
+
|
|
72
|
+
The script imports `scripts/parse-emlx.mjs` to extract plain text bodies from
|
|
73
|
+
`.emlx` / `.partial.emlx` files (handles HTML-only emails by stripping tags).
|
|
74
|
+
|
|
75
|
+
## Database Schema
|
|
76
|
+
|
|
77
|
+
See [references/SCHEMA.md](references/SCHEMA.md) for the complete Apple Mail
|
|
78
|
+
SQLite schema including table structures, column names, and important caveats
|
|
79
|
+
(e.g., `date_received` is Unix timestamps not Core Data, `addresses.comment`
|
|
80
|
+
holds display names, `recipients` columns are `message`/`address` not
|
|
81
|
+
`message_id`/`address_id`).
|
|
82
|
+
|
|
83
|
+
## Output Format
|
|
84
|
+
|
|
85
|
+
Each `{thread_id}.md` file:
|
|
86
|
+
|
|
87
|
+
```markdown
|
|
88
|
+
# {Base Subject}
|
|
89
|
+
|
|
90
|
+
**Thread ID:** {thread_id}
|
|
91
|
+
**Message Count:** {count}
|
|
92
|
+
**Flags:** mailing-list, automated
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### From: {sender_name} <{sender_email}>
|
|
97
|
+
**Date:** {YYYY-MM-DD HH:MM:SS UTC}
|
|
98
|
+
**To:** {name} <{email}>, {name2} <{email2}>
|
|
99
|
+
**Cc:** {name} <{email}>
|
|
100
|
+
|
|
101
|
+
{email_body_or_summary}
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### From: {next_sender_name} <{next_sender_email}>
|
|
106
|
+
**Date:** {next_date}
|
|
107
|
+
**To:** ...
|
|
108
|
+
**Cc:** ...
|
|
109
|
+
|
|
110
|
+
{next_body}
|
|
111
|
+
|
|
112
|
+
**Attachments:**
|
|
113
|
+
- [report.pdf](attachments/{thread_id}/report.pdf)
|
|
114
|
+
- image001.png *(not available)*
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Rules:
|
|
118
|
+
|
|
119
|
+
- Use the **base subject** (from `subject` column, without `subject_prefix`) as
|
|
120
|
+
the `# heading`.
|
|
121
|
+
- **Flags line** — only include when at least one flag is set:
|
|
122
|
+
- `mailing-list` if any message in the thread has `list_id_hash != 0`
|
|
123
|
+
- `automated` if any message has `automated_conversation = 1`
|
|
124
|
+
- Omit the `**Flags:**` line entirely if neither flag applies.
|
|
125
|
+
- **Sender** — format as `{sender_name} <{sender_email}>` when display name is
|
|
126
|
+
present, otherwise just `{sender_email}`.
|
|
127
|
+
- **To/Cc** — include per-message. Format each recipient as `{name} <{email}>`
|
|
128
|
+
when name exists, otherwise just `{email}`. Omit the line if that field has no
|
|
129
|
+
recipients.
|
|
130
|
+
|
|
131
|
+
## Error Handling
|
|
132
|
+
|
|
133
|
+
- Database not found → Mail not configured, report and stop
|
|
134
|
+
- Permission denied → Full Disk Access not granted, report and stop
|
|
135
|
+
- Database locked → wait 2 seconds, retry once
|
|
136
|
+
- `.emlx` / `.partial.emlx` not found → fall back to database summary field
|
|
137
|
+
- `.emlx` parse error → fall back to database summary field
|
|
138
|
+
- HTML-only email → strip tags and use as plain text body (handled by
|
|
139
|
+
parse-emlx.mjs)
|
|
140
|
+
- `find` timeout → skip that message's body, use summary; attachment index empty
|
|
141
|
+
- Attachment file not found on disk → listed as `*(not available)*` in markdown
|
|
142
|
+
- Attachment copy fails (permissions, disk full) → listed as `*(not available)*`
|
|
143
|
+
- Filename collision across messages → prefixed with `{message_id}_`
|
|
144
|
+
- Always update sync state, even on partial success
|
|
145
|
+
|
|
146
|
+
## Constraints
|
|
147
|
+
|
|
148
|
+
- Open database read-only (`readOnly: true`)
|
|
149
|
+
- Only sync Inbox and Sent folders
|
|
150
|
+
- Limit to 500 threads per run
|
|
151
|
+
- Incremental: only threads with new messages since last sync
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Apple Mail Database Schema
|
|
2
|
+
|
|
3
|
+
The Apple Mail SQLite database (`Envelope Index`) stores email metadata. Key
|
|
4
|
+
tables and their actual column names (verified on macOS Sequoia / V10).
|
|
5
|
+
|
|
6
|
+
Typical path: `~/Library/Mail/V10/MailData/Envelope Index`
|
|
7
|
+
|
|
8
|
+
## messages (email metadata)
|
|
9
|
+
|
|
10
|
+
| Column | Type | Notes |
|
|
11
|
+
| ------------------------ | ------- | -------------------------------------------------------- |
|
|
12
|
+
| `ROWID` | INTEGER | Primary key |
|
|
13
|
+
| `sender` | INTEGER | FK → addresses.ROWID |
|
|
14
|
+
| `subject` | INTEGER | FK → subjects.ROWID |
|
|
15
|
+
| `subject_prefix` | TEXT | `Re:`, `Fwd:`, etc. (directly on messages, not subjects) |
|
|
16
|
+
| `summary` | INTEGER | FK → summaries.ROWID |
|
|
17
|
+
| `date_sent` | INTEGER | Unix timestamp (seconds since 1970-01-01 UTC) |
|
|
18
|
+
| `date_received` | INTEGER | Unix timestamp (seconds since 1970-01-01 UTC) |
|
|
19
|
+
| `mailbox` | INTEGER | FK → mailboxes.ROWID |
|
|
20
|
+
| `deleted` | INTEGER | 1 = deleted |
|
|
21
|
+
| `conversation_id` | INTEGER | Thread grouping ID |
|
|
22
|
+
| `list_id_hash` | INTEGER | Non-zero = mailing list message |
|
|
23
|
+
| `automated_conversation` | INTEGER | 1 = automated/machine-generated |
|
|
24
|
+
| `read` | INTEGER | 1 = read |
|
|
25
|
+
| `flagged` | INTEGER | 1 = flagged |
|
|
26
|
+
|
|
27
|
+
**IMPORTANT:** `date_received` stores **Unix timestamps** (seconds since
|
|
28
|
+
1970-01-01 UTC), NOT Core Data timestamps (which use 2001-01-01 epoch). Do NOT
|
|
29
|
+
apply Core Data conversion.
|
|
30
|
+
|
|
31
|
+
## addresses (sender and recipient addresses)
|
|
32
|
+
|
|
33
|
+
| Column | Type | Notes |
|
|
34
|
+
| --------- | ------- | ------------------------------------ |
|
|
35
|
+
| `ROWID` | INTEGER | Primary key |
|
|
36
|
+
| `address` | TEXT | Email address |
|
|
37
|
+
| `comment` | TEXT | Display name (e.g., `"Chen, Sarah"`) |
|
|
38
|
+
|
|
39
|
+
**IMPORTANT:** The display name is in `comment`, not a `name` or `display_name`
|
|
40
|
+
column.
|
|
41
|
+
|
|
42
|
+
## subjects
|
|
43
|
+
|
|
44
|
+
| Column | Type | Notes |
|
|
45
|
+
| --------- | ------- | ----------------- |
|
|
46
|
+
| `ROWID` | INTEGER | Primary key |
|
|
47
|
+
| `subject` | TEXT | Base subject text |
|
|
48
|
+
|
|
49
|
+
Note: `subject_prefix` (Re:, Fwd:, etc.) is stored on the `messages` table
|
|
50
|
+
directly, not here.
|
|
51
|
+
|
|
52
|
+
## recipients (To/Cc/Bcc per message)
|
|
53
|
+
|
|
54
|
+
| Column | Type | Notes |
|
|
55
|
+
| ---------- | ------- | --------------------------- |
|
|
56
|
+
| `ROWID` | INTEGER | Primary key |
|
|
57
|
+
| `message` | INTEGER | FK → messages.ROWID |
|
|
58
|
+
| `address` | INTEGER | FK → addresses.ROWID |
|
|
59
|
+
| `type` | INTEGER | 0 = To, 1 = Cc, 2 = Bcc |
|
|
60
|
+
| `position` | INTEGER | Order within the type group |
|
|
61
|
+
|
|
62
|
+
**IMPORTANT:** Column names are `message` and `address` (not `message_id` or
|
|
63
|
+
`address_id`).
|
|
64
|
+
|
|
65
|
+
## summaries (Apple Intelligence email summaries)
|
|
66
|
+
|
|
67
|
+
| Column | Type | Notes |
|
|
68
|
+
| --------- | ------- | ------------ |
|
|
69
|
+
| `ROWID` | INTEGER | Primary key |
|
|
70
|
+
| `summary` | TEXT | Summary text |
|
|
71
|
+
|
|
72
|
+
## mailboxes
|
|
73
|
+
|
|
74
|
+
| Column | Type | Notes |
|
|
75
|
+
| ------- | ------- | -------------------------------- |
|
|
76
|
+
| `ROWID` | INTEGER | Primary key |
|
|
77
|
+
| `url` | TEXT | Mailbox URL (IMAP or EWS format) |
|
|
78
|
+
|
|
79
|
+
### Mailbox URL patterns
|
|
80
|
+
|
|
81
|
+
Standard IMAP: `imap://user@host/INBOX`, `imap://user@host/Sent Messages` EWS
|
|
82
|
+
(Exchange): `ews://UUID/Inbox`, `ews://UUID/Sent%20Items`
|
|
83
|
+
|
|
84
|
+
Use case-insensitive `LIKE` patterns to match both:
|
|
85
|
+
|
|
86
|
+
- `%/Inbox%` (catches IMAP `/INBOX` and EWS `/Inbox`)
|
|
87
|
+
- `%/INBOX%` (explicit uppercase match)
|
|
88
|
+
- `%/Sent%` (catches `Sent Messages`, `Sent Items`, `Sent%20Items`)
|
|
89
|
+
|
|
90
|
+
## attachments (email attachments)
|
|
91
|
+
|
|
92
|
+
| Column | Type | Notes |
|
|
93
|
+
| --------------- | ------- | --------------------------------------- |
|
|
94
|
+
| `ROWID` | INTEGER | Primary key |
|
|
95
|
+
| `message` | INTEGER | FK → messages.ROWID (ON DELETE CASCADE) |
|
|
96
|
+
| `attachment_id` | TEXT | Used as subdirectory name on disk |
|
|
97
|
+
| `name` | TEXT | Original filename (e.g., `report.pdf`) |
|
|
98
|
+
|
|
99
|
+
**Constraints:** `UNIQUE(message, attachment_id)` — each attachment within a
|
|
100
|
+
message has a unique identifier.
|
|
101
|
+
|
|
102
|
+
**IMPORTANT:** Column is `message` (not `message_id`), matching the convention
|
|
103
|
+
used by the `recipients` table.
|
|
104
|
+
|
|
105
|
+
### Filesystem mapping
|
|
106
|
+
|
|
107
|
+
Attachment files on disk follow this path structure:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
~/Library/Mail/V10/.../Attachments/{message_ROWID}/{attachment_id}/{filename}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- `{message_ROWID}` — the `messages.ROWID` value (same as `attachments.message`)
|
|
114
|
+
- `{attachment_id}` — the `attachments.attachment_id` value
|
|
115
|
+
- `{filename}` — the actual file on disk (may differ from `attachments.name`)
|