@forwardimpact/basecamp 2.0.0 → 2.3.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/config/scheduler.json +5 -0
- package/package.json +1 -1
- package/src/basecamp.js +288 -57
- package/template/.claude/agents/chief-of-staff.md +6 -2
- package/template/.claude/agents/concierge.md +2 -3
- package/template/.claude/agents/librarian.md +4 -6
- package/template/.claude/agents/recruiter.md +269 -0
- package/template/.claude/settings.json +0 -4
- package/template/.claude/skills/analyze-cv/SKILL.md +269 -0
- package/template/.claude/skills/create-presentations/SKILL.md +2 -2
- package/template/.claude/skills/create-presentations/references/slide.css +1 -1
- package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.mjs +47 -0
- package/template/.claude/skills/draft-emails/SKILL.md +85 -123
- package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
- package/template/.claude/skills/draft-emails/scripts/send-email.mjs +118 -0
- package/template/.claude/skills/extract-entities/SKILL.md +2 -2
- package/template/.claude/skills/extract-entities/scripts/state.mjs +130 -0
- package/template/.claude/skills/manage-tasks/SKILL.md +242 -0
- package/template/.claude/skills/organize-files/SKILL.md +3 -3
- package/template/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
- package/template/.claude/skills/organize-files/scripts/summarize.mjs +84 -0
- package/template/.claude/skills/process-hyprnote/SKILL.md +2 -2
- package/template/.claude/skills/right-to-be-forgotten/SKILL.md +333 -0
- package/template/.claude/skills/send-chat/SKILL.md +170 -0
- package/template/.claude/skills/sync-apple-calendar/SKILL.md +5 -5
- package/template/.claude/skills/sync-apple-calendar/scripts/sync.mjs +325 -0
- package/template/.claude/skills/sync-apple-mail/SKILL.md +6 -6
- package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +374 -0
- package/template/.claude/skills/sync-apple-mail/scripts/sync.mjs +629 -0
- package/template/.claude/skills/track-candidates/SKILL.md +376 -0
- package/template/.claude/skills/upstream-skill/SKILL.md +207 -0
- package/template/.claude/skills/weekly-update/SKILL.md +250 -0
- package/template/CLAUDE.md +68 -40
- package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +0 -32
- package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +0 -34
- package/template/.claude/skills/extract-entities/scripts/state.py +0 -100
- package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +0 -42
- package/template/.claude/skills/organize-files/scripts/summarize.sh +0 -21
- package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +0 -242
- package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +0 -104
- package/template/.claude/skills/sync-apple-mail/scripts/sync.py +0 -455
|
@@ -1,57 +1,46 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: draft-emails
|
|
3
|
-
description: Draft email responses using the knowledge base and calendar for
|
|
3
|
+
description: Draft and send email responses using the knowledge base and calendar for context. Use when the user asks to draft, reply to, respond to, or send an email.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Draft Emails
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
Draft and send email responses. Uses the knowledge base and calendar for full
|
|
9
|
+
context on every person and conversation. All drafts require explicit user
|
|
10
|
+
approval before sending.
|
|
11
11
|
|
|
12
12
|
## Trigger
|
|
13
13
|
|
|
14
|
-
Run when the user asks to draft, reply to,
|
|
14
|
+
Run when the user asks to draft, reply to, respond to, or send an email.
|
|
15
15
|
|
|
16
16
|
## Prerequisites
|
|
17
17
|
|
|
18
18
|
- Knowledge base populated (from `extract-entities` skill)
|
|
19
|
-
- Synced email data in `~/.cache/fit/basecamp/apple_mail/`
|
|
20
|
-
`~/.cache/fit/basecamp/gmail/`
|
|
19
|
+
- Synced email data in `~/.cache/fit/basecamp/apple_mail/`
|
|
21
20
|
|
|
22
|
-
##
|
|
21
|
+
## Data Locations
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
## Outputs
|
|
35
|
-
|
|
36
|
-
- `drafts/{email_id}_draft.md` — draft email files
|
|
37
|
-
- `drafts/last_processed` — updated timestamp
|
|
38
|
-
- `drafts/drafted` — updated with newly drafted IDs
|
|
39
|
-
- `drafts/ignored` — updated with newly ignored IDs
|
|
23
|
+
| Data | Location |
|
|
24
|
+
| --------------- | --------------------------------------------- |
|
|
25
|
+
| People | `knowledge/People/*.md` |
|
|
26
|
+
| Organizations | `knowledge/Organizations/*.md` |
|
|
27
|
+
| Email threads | `~/.cache/fit/basecamp/apple_mail/*.md` |
|
|
28
|
+
| Calendar events | `~/.cache/fit/basecamp/apple_calendar/*.json` |
|
|
29
|
+
| Drafted IDs | `drafts/drafted` (one ID per line) |
|
|
30
|
+
| Ignored IDs | `drafts/ignored` (one ID per line) |
|
|
31
|
+
| Draft files | `drafts/{email_id}_draft.md` |
|
|
40
32
|
|
|
41
33
|
---
|
|
42
34
|
|
|
43
|
-
##
|
|
44
|
-
|
|
45
|
-
**BEFORE drafting any email, you MUST look up the person/organization in the
|
|
46
|
-
knowledge base.**
|
|
35
|
+
## Always Look Up Context First
|
|
47
36
|
|
|
48
|
-
|
|
37
|
+
**BEFORE drafting any email, look up the person/organization in the knowledge
|
|
38
|
+
base.**
|
|
49
39
|
|
|
50
|
-
1. **
|
|
51
|
-
2. **
|
|
52
|
-
3. **
|
|
53
|
-
4. **
|
|
54
|
-
5. **THEN DRAFT** — Only now draft the email, using this context
|
|
40
|
+
1. **Search** — `rg -l "Name" knowledge/`
|
|
41
|
+
2. **Read** — `cat "knowledge/People/Name.md"`
|
|
42
|
+
3. **Understand** — Extract role, organization, relationship history, open items
|
|
43
|
+
4. **Draft** — Only now draft the email, using this context
|
|
55
44
|
|
|
56
45
|
## Key Principles
|
|
57
46
|
|
|
@@ -59,55 +48,46 @@ When the user says "draft an email to Monica" or mentions ANY person:
|
|
|
59
48
|
|
|
60
49
|
- If intent is unclear, ASK what the email should be about
|
|
61
50
|
- If a person has multiple contexts, ASK which one
|
|
62
|
-
- **WRONG:** "Here are three variants — pick one"
|
|
63
|
-
- **RIGHT:** "I see Akhilesh is involved in Rowboat and banking. Which topic?"
|
|
64
51
|
|
|
65
|
-
**Be decisive
|
|
52
|
+
**Be decisive:**
|
|
66
53
|
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
|
|
71
|
-
## Processing Flow
|
|
72
|
-
|
|
73
|
-
### Step 1: Scan for New Emails
|
|
54
|
+
- Draft ONE email — no multiple versions
|
|
55
|
+
- Personalize from knowledge base context
|
|
56
|
+
- Match the tone of the incoming email
|
|
74
57
|
|
|
75
|
-
|
|
58
|
+
**User approves before sending:**
|
|
76
59
|
|
|
77
|
-
|
|
60
|
+
- Always present the draft for review before sending
|
|
61
|
+
- Never send without explicit approval
|
|
78
62
|
|
|
79
|
-
|
|
80
|
-
`drafts/drafted` or `drafts/ignored`.
|
|
63
|
+
## Workflow
|
|
81
64
|
|
|
82
|
-
###
|
|
65
|
+
### 1. Scan for New Emails
|
|
83
66
|
|
|
84
|
-
|
|
67
|
+
```bash
|
|
68
|
+
node scripts/scan-emails.mjs
|
|
69
|
+
```
|
|
85
70
|
|
|
86
|
-
-
|
|
87
|
-
- `**Thread ID:** <id>`
|
|
88
|
-
- `**Message Count:** <count>`
|
|
89
|
-
- `### From: Name <email@example.com>`
|
|
90
|
-
- `**Date:** <date>`
|
|
71
|
+
Outputs tab-separated `email_id<TAB>subject` for unprocessed emails.
|
|
91
72
|
|
|
92
|
-
###
|
|
73
|
+
### 2. Classify
|
|
93
74
|
|
|
94
|
-
**
|
|
75
|
+
**Ignore** (append ID to `drafts/ignored`):
|
|
95
76
|
|
|
96
77
|
- Newsletters, marketing, automated notifications
|
|
97
78
|
- Spam or irrelevant cold outreach
|
|
98
79
|
- Outbound emails from user with no reply
|
|
99
80
|
|
|
100
|
-
**
|
|
81
|
+
**Draft response for:**
|
|
101
82
|
|
|
102
83
|
- Meeting requests or scheduling
|
|
103
84
|
- Personal emails from known contacts
|
|
104
|
-
- Business inquiries
|
|
105
|
-
- Follow-ups on existing conversations
|
|
85
|
+
- Business inquiries or follow-ups
|
|
106
86
|
- Emails requesting information or action
|
|
107
87
|
|
|
108
|
-
###
|
|
88
|
+
### 3. Gather Context
|
|
109
89
|
|
|
110
|
-
**Knowledge
|
|
90
|
+
**Knowledge base** (required for every draft):
|
|
111
91
|
|
|
112
92
|
```bash
|
|
113
93
|
rg -l "sender_name" knowledge/
|
|
@@ -115,106 +95,88 @@ cat "knowledge/People/Sender Name.md"
|
|
|
115
95
|
cat "knowledge/Organizations/Company Name.md"
|
|
116
96
|
```
|
|
117
97
|
|
|
118
|
-
**Calendar (for scheduling emails)
|
|
98
|
+
**Calendar** (for scheduling emails):
|
|
119
99
|
|
|
120
100
|
```bash
|
|
121
|
-
ls ~/.cache/fit/basecamp/apple_calendar/
|
|
101
|
+
ls ~/.cache/fit/basecamp/apple_calendar/ 2>/dev/null
|
|
122
102
|
cat "$HOME/.cache/fit/basecamp/apple_calendar/event123.json"
|
|
123
103
|
```
|
|
124
104
|
|
|
125
|
-
###
|
|
105
|
+
### 4. Write Draft
|
|
126
106
|
|
|
127
|
-
|
|
107
|
+
Save to `drafts/{email_id}_draft.md`:
|
|
128
108
|
|
|
129
109
|
```markdown
|
|
130
110
|
# Draft Response
|
|
131
111
|
|
|
132
|
-
**
|
|
133
|
-
**
|
|
134
|
-
**
|
|
135
|
-
**Date Processed:** {date}
|
|
136
|
-
|
|
137
|
-
---
|
|
138
|
-
|
|
139
|
-
## Context Used
|
|
140
|
-
- Calendar: {relevant info or N/A}
|
|
141
|
-
- Knowledge: {relevant notes or N/A}
|
|
112
|
+
**To:** recipient@example.com
|
|
113
|
+
**CC:** other@example.com
|
|
114
|
+
**Subject:** Re: {subject}
|
|
142
115
|
|
|
143
116
|
---
|
|
144
117
|
|
|
145
|
-
## Draft Response
|
|
146
|
-
|
|
147
|
-
Subject: Re: {subject}
|
|
148
|
-
|
|
149
118
|
{personalized draft body}
|
|
150
119
|
|
|
151
120
|
---
|
|
152
121
|
|
|
153
122
|
## Notes
|
|
154
|
-
|
|
123
|
+
- **Original Email ID:** {id}
|
|
124
|
+
- **From:** {sender}
|
|
125
|
+
- **Context:** {knowledge base notes used}
|
|
155
126
|
```
|
|
156
127
|
|
|
157
|
-
|
|
128
|
+
Guidelines:
|
|
158
129
|
|
|
159
|
-
- Draft ONE email —
|
|
160
|
-
-
|
|
161
|
-
-
|
|
162
|
-
|
|
163
|
-
|
|
130
|
+
- Draft ONE email — reference past interactions naturally
|
|
131
|
+
- For scheduling: propose specific times from calendar availability
|
|
132
|
+
- If unsure about intent, ask a clarifying question instead of drafting
|
|
133
|
+
|
|
134
|
+
### 5. Present for Review
|
|
135
|
+
|
|
136
|
+
Show the draft to the user. Wait for explicit approval before sending. The user
|
|
137
|
+
may request edits — apply them and present again.
|
|
164
138
|
|
|
165
|
-
###
|
|
139
|
+
### 6. Send
|
|
166
140
|
|
|
167
|
-
After
|
|
141
|
+
After the user approves, send via Apple Mail:
|
|
168
142
|
|
|
169
143
|
```bash
|
|
170
|
-
|
|
171
|
-
|
|
144
|
+
node scripts/send-email.mjs \
|
|
145
|
+
--to "recipient@example.com" \
|
|
146
|
+
--cc "other@example.com" \
|
|
147
|
+
--subject "Re: Subject" \
|
|
148
|
+
--body "Plain text body"
|
|
172
149
|
```
|
|
173
150
|
|
|
174
|
-
|
|
151
|
+
Options: `--to` (required), `--cc` (optional), `--bcc` (optional), `--subject`
|
|
152
|
+
(required), `--body` (required, plain text only).
|
|
175
153
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
**Emails Scanned:** X
|
|
179
|
-
**Drafts Created:** Y
|
|
180
|
-
**Ignored:** Z
|
|
154
|
+
Do NOT include an email signature — Apple Mail appends the configured signature
|
|
155
|
+
automatically.
|
|
181
156
|
|
|
182
|
-
###
|
|
183
|
-
- {id}: {subject} — {reason}
|
|
157
|
+
### 7. Update State
|
|
184
158
|
|
|
185
|
-
|
|
186
|
-
|
|
159
|
+
```bash
|
|
160
|
+
echo "$EMAIL_ID" >> drafts/drafted # or drafts/ignored
|
|
187
161
|
```
|
|
188
162
|
|
|
189
163
|
## Recruitment & Staffing Emails
|
|
190
164
|
|
|
191
|
-
**
|
|
192
|
-
|
|
193
|
-
When an email involves recruitment, staffing, or hiring:
|
|
165
|
+
**Candidates must NEVER be copied on internal emails about them.**
|
|
194
166
|
|
|
195
|
-
1. **Identify the candidate**
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
internal stakeholders (hiring managers, recruiters, interview panel, etc.).
|
|
199
|
-
The candidate’s email address must NOT appear in To, CC, or BCC
|
|
200
|
-
3. **Only recruiters email candidates directly** — If the email is a direct
|
|
201
|
-
reply TO a candidate (e.g., scheduling an interview, extending an offer),
|
|
202
|
-
flag it clearly so only the recruiter sends it. Add a note:
|
|
167
|
+
1. **Identify the candidate** from the thread and knowledge base
|
|
168
|
+
2. **Strip the candidate from recipients** — draft to internal stakeholders only
|
|
169
|
+
3. **Direct-to-candidate emails** — flag with:
|
|
203
170
|
`⚠️ RECRUITER ONLY — This email goes directly to the candidate.`
|
|
204
171
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
- Interview feedback or debrief
|
|
208
|
-
- Candidate evaluation or comparison
|
|
209
|
-
- Hiring decision discussions
|
|
210
|
-
- Compensation/offer discussions
|
|
211
|
-
- Reference check follow-ups between colleagues
|
|
172
|
+
Internal recruitment emails (candidate excluded): interview feedback, candidate
|
|
173
|
+
evaluation, hiring decisions, compensation discussions, reference checks.
|
|
212
174
|
|
|
213
|
-
**When in doubt:** If an email thread mentions a candidate
|
|
214
|
-
|
|
175
|
+
**When in doubt:** If an email thread mentions a candidate and involves multiple
|
|
176
|
+
internal recipients, treat it as internal and exclude the candidate.
|
|
215
177
|
|
|
216
178
|
## Constraints
|
|
217
179
|
|
|
218
|
-
- Never
|
|
180
|
+
- Never send without explicit user approval
|
|
219
181
|
- Be conservative with ignore — when in doubt, create a draft
|
|
220
|
-
- For ambiguous emails,
|
|
182
|
+
- For ambiguous emails, draft with a note explaining the ambiguity
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Scan for unprocessed emails and output their IDs and subjects.
|
|
4
|
+
*
|
|
5
|
+
* Checks ~/.cache/fit/basecamp/apple_mail/ for email thread markdown files not
|
|
6
|
+
* yet listed in drafts/drafted or drafts/ignored. Outputs one tab-separated
|
|
7
|
+
* line per unprocessed thread: email_id<TAB>subject. Used by the draft-emails
|
|
8
|
+
* skill to identify threads that need a reply.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { basename, join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
|
|
15
|
+
const HELP = `scan-emails — list unprocessed email threads
|
|
16
|
+
|
|
17
|
+
Usage: node scripts/scan-emails.mjs [-h|--help]
|
|
18
|
+
|
|
19
|
+
Scans ~/.cache/fit/basecamp/apple_mail/ for .md thread files not yet
|
|
20
|
+
recorded in drafts/drafted or drafts/ignored. Outputs one line per
|
|
21
|
+
unprocessed thread as: email_id<TAB>subject`;
|
|
22
|
+
|
|
23
|
+
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
24
|
+
console.log(HELP);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const HOME = homedir();
|
|
29
|
+
const MAIL_DIR = join(HOME, ".cache/fit/basecamp/apple_mail");
|
|
30
|
+
|
|
31
|
+
/** Load a file of IDs (one per line) into a Set. */
|
|
32
|
+
function loadIdSet(path) {
|
|
33
|
+
const ids = new Set();
|
|
34
|
+
if (!existsSync(path)) return ids;
|
|
35
|
+
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (trimmed) ids.add(trimmed);
|
|
38
|
+
}
|
|
39
|
+
return ids;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Extract the first H1 heading from a markdown file. */
|
|
43
|
+
function extractSubject(filePath) {
|
|
44
|
+
const text = readFileSync(filePath, "utf-8");
|
|
45
|
+
const match = text.match(/^# (.+)$/m);
|
|
46
|
+
return match ? match[1] : "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function main() {
|
|
50
|
+
if (!existsSync(MAIL_DIR)) return;
|
|
51
|
+
|
|
52
|
+
const drafted = loadIdSet("drafts/drafted");
|
|
53
|
+
const ignored = loadIdSet("drafts/ignored");
|
|
54
|
+
|
|
55
|
+
for (const name of readdirSync(MAIL_DIR).sort()) {
|
|
56
|
+
if (!name.endsWith(".md")) continue;
|
|
57
|
+
|
|
58
|
+
const emailId = basename(name, ".md");
|
|
59
|
+
if (drafted.has(emailId) || ignored.has(emailId)) continue;
|
|
60
|
+
|
|
61
|
+
const subject = extractSubject(join(MAIL_DIR, name));
|
|
62
|
+
console.log(`${emailId}\t${subject}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
main();
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Send an email via Apple Mail using AppleScript.
|
|
4
|
+
*
|
|
5
|
+
* Builds an AppleScript command to create and send an outgoing message through
|
|
6
|
+
* Apple Mail. The script writes a temporary .scpt file, executes it with
|
|
7
|
+
* osascript, and cleans up afterwards. Mail.app must be running.
|
|
8
|
+
*
|
|
9
|
+
* The body should be plain text — no HTML. Do NOT include an email signature;
|
|
10
|
+
* Apple Mail appends the user's configured signature automatically.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { mkdtempSync, writeFileSync, unlinkSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
|
|
18
|
+
const HELP = `send-email — send an email via Apple Mail
|
|
19
|
+
|
|
20
|
+
Usage: node scripts/send-email.mjs --to <addrs> --subject <subj> --body <text> [options]
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--to <addrs> Comma-separated To recipients (required)
|
|
24
|
+
--cc <addrs> Comma-separated CC recipients
|
|
25
|
+
--bcc <addrs> Comma-separated BCC recipients
|
|
26
|
+
--subject <subj> Email subject line (required)
|
|
27
|
+
--body <text> Plain-text email body (required)
|
|
28
|
+
-h, --help Show this help message and exit
|
|
29
|
+
|
|
30
|
+
Mail.app must be running. No email signature needed — Apple Mail appends it.`;
|
|
31
|
+
|
|
32
|
+
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
33
|
+
console.log(HELP);
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Escape a string for AppleScript double-quoted context. */
|
|
38
|
+
function escapeAS(s) {
|
|
39
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build recipient lines for AppleScript. */
|
|
43
|
+
function recipientLines(type, addrs) {
|
|
44
|
+
if (!addrs) return "";
|
|
45
|
+
return addrs
|
|
46
|
+
.split(",")
|
|
47
|
+
.map((a) => a.trim())
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map(
|
|
50
|
+
(addr) =>
|
|
51
|
+
` make new ${type} at end of ${type}s with properties {address:"${escapeAS(addr)}"}`,
|
|
52
|
+
)
|
|
53
|
+
.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function main() {
|
|
57
|
+
const args = process.argv.slice(2);
|
|
58
|
+
let to = "",
|
|
59
|
+
cc = "",
|
|
60
|
+
bcc = "",
|
|
61
|
+
subject = "",
|
|
62
|
+
body = "";
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < args.length; i++) {
|
|
65
|
+
switch (args[i]) {
|
|
66
|
+
case "--to":
|
|
67
|
+
to = args[++i] ?? "";
|
|
68
|
+
break;
|
|
69
|
+
case "--cc":
|
|
70
|
+
cc = args[++i] ?? "";
|
|
71
|
+
break;
|
|
72
|
+
case "--bcc":
|
|
73
|
+
bcc = args[++i] ?? "";
|
|
74
|
+
break;
|
|
75
|
+
case "--subject":
|
|
76
|
+
subject = args[++i] ?? "";
|
|
77
|
+
break;
|
|
78
|
+
case "--body":
|
|
79
|
+
body = args[++i] ?? "";
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!to || !subject || !body) {
|
|
85
|
+
console.error("Error: --to, --subject, and --body are required.");
|
|
86
|
+
console.error("Run with --help for usage info.");
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const lines = [
|
|
91
|
+
'tell application "Mail"',
|
|
92
|
+
` set newMessage to make new outgoing message with properties {subject:"${escapeAS(subject)}", content:"${escapeAS(body)}", visible:false}`,
|
|
93
|
+
" tell newMessage",
|
|
94
|
+
recipientLines("to recipient", to),
|
|
95
|
+
cc ? recipientLines("cc recipient", cc) : "",
|
|
96
|
+
bcc ? recipientLines("bcc recipient", bcc) : "",
|
|
97
|
+
" end tell",
|
|
98
|
+
" send newMessage",
|
|
99
|
+
"end tell",
|
|
100
|
+
]
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.join("\n");
|
|
103
|
+
|
|
104
|
+
const tmp = join(mkdtempSync(join(tmpdir(), "send-email-")), "mail.scpt");
|
|
105
|
+
try {
|
|
106
|
+
writeFileSync(tmp, lines);
|
|
107
|
+
execFileSync("osascript", [tmp], { stdio: "inherit" });
|
|
108
|
+
console.log(`Sent: ${subject}`);
|
|
109
|
+
} finally {
|
|
110
|
+
try {
|
|
111
|
+
unlinkSync(tmp);
|
|
112
|
+
} catch {
|
|
113
|
+
// ignore cleanup errors
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main();
|
|
@@ -68,7 +68,7 @@ Run this skill:
|
|
|
68
68
|
1. Read `USER.md` to get the user's name, email, and domain
|
|
69
69
|
2. Find new/changed files to process:
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
node scripts/state.mjs check
|
|
72
72
|
|
|
73
73
|
This outputs one file path per line for all source files that are new or
|
|
74
74
|
have changed since last processing.
|
|
@@ -434,7 +434,7 @@ Always use absolute links: `[[People/Sarah Chen]]`,
|
|
|
434
434
|
|
|
435
435
|
After processing each file, update the state:
|
|
436
436
|
|
|
437
|
-
|
|
437
|
+
node scripts/state.mjs update "$FILE"
|
|
438
438
|
|
|
439
439
|
## Source Type Rules Summary
|
|
440
440
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Manage graph_processed state for entity extraction.
|
|
4
|
+
*
|
|
5
|
+
* Tracks which source files (synced emails and calendar events) have already
|
|
6
|
+
* been processed by the extract-entities skill. The `check` command lists files
|
|
7
|
+
* that are new or changed since their last recorded hash; `update` marks files
|
|
8
|
+
* as processed by storing their current SHA-256 hash.
|
|
9
|
+
*
|
|
10
|
+
* State is persisted as a TSV file at
|
|
11
|
+
* ~/.cache/fit/basecamp/state/graph_processed (path<TAB>hash).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
15
|
+
console.log(`state — manage graph_processed state for entity extraction
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
node scripts/state.mjs check List new/changed files
|
|
19
|
+
node scripts/state.mjs update <path> [<path>…] Mark files as processed
|
|
20
|
+
node scripts/state.mjs -h|--help Show this help message
|
|
21
|
+
|
|
22
|
+
State file: ~/.cache/fit/basecamp/state/graph_processed (TSV: path<TAB>hash)`);
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import {
|
|
28
|
+
existsSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
statSync,
|
|
33
|
+
writeFileSync,
|
|
34
|
+
} from "node:fs";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
|
|
38
|
+
const HOME = homedir();
|
|
39
|
+
const STATE_FILE = join(HOME, ".cache/fit/basecamp/state/graph_processed");
|
|
40
|
+
const SOURCE_DIRS = [
|
|
41
|
+
join(HOME, ".cache/fit/basecamp/apple_mail"),
|
|
42
|
+
join(HOME, ".cache/fit/basecamp/apple_calendar"),
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** Compute SHA-256 hash of a file. */
|
|
46
|
+
function fileHash(filePath) {
|
|
47
|
+
const data = readFileSync(filePath);
|
|
48
|
+
return createHash("sha256").update(data).digest("hex");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Load the state file into a Map of {path → hash}. */
|
|
52
|
+
function loadState() {
|
|
53
|
+
const state = new Map();
|
|
54
|
+
if (!existsSync(STATE_FILE)) return state;
|
|
55
|
+
const text = readFileSync(STATE_FILE, "utf-8");
|
|
56
|
+
for (const line of text.split("\n")) {
|
|
57
|
+
if (!line) continue;
|
|
58
|
+
const idx = line.indexOf("\t");
|
|
59
|
+
if (idx === -1) continue;
|
|
60
|
+
state.set(line.slice(0, idx), line.slice(idx + 1));
|
|
61
|
+
}
|
|
62
|
+
return state;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Write the full state Map back to the state file. */
|
|
66
|
+
function saveState(state) {
|
|
67
|
+
const dir = join(HOME, ".cache/fit/basecamp/state");
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
const entries = [...state.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
70
|
+
const text = entries.length
|
|
71
|
+
? entries.map(([p, h]) => `${p}\t${h}`).join("\n") + "\n"
|
|
72
|
+
: "";
|
|
73
|
+
writeFileSync(STATE_FILE, text);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Find source files that are new or have changed since last processing. */
|
|
77
|
+
function check() {
|
|
78
|
+
const state = loadState();
|
|
79
|
+
const newFiles = [];
|
|
80
|
+
for (const dir of SOURCE_DIRS) {
|
|
81
|
+
if (!existsSync(dir)) continue;
|
|
82
|
+
for (const name of readdirSync(dir)) {
|
|
83
|
+
const filePath = join(dir, name);
|
|
84
|
+
const stat = statSync(filePath, { throwIfNoEntry: false });
|
|
85
|
+
if (!stat || !stat.isFile()) continue;
|
|
86
|
+
const h = fileHash(filePath);
|
|
87
|
+
if (state.get(filePath) !== h) {
|
|
88
|
+
newFiles.push(filePath);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
newFiles.sort();
|
|
93
|
+
for (const f of newFiles) {
|
|
94
|
+
console.log(f);
|
|
95
|
+
}
|
|
96
|
+
return newFiles.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Mark files as processed by updating their hashes in state. */
|
|
100
|
+
function update(filePaths) {
|
|
101
|
+
const state = loadState();
|
|
102
|
+
for (const fp of filePaths) {
|
|
103
|
+
if (!existsSync(fp)) {
|
|
104
|
+
console.error(`Warning: File not found: ${fp}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
state.set(fp, fileHash(fp));
|
|
108
|
+
}
|
|
109
|
+
saveState(state);
|
|
110
|
+
console.log(`Updated ${filePaths.length} file(s) in graph state`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- CLI ---
|
|
114
|
+
|
|
115
|
+
const args = process.argv.slice(2);
|
|
116
|
+
const cmd = args[0];
|
|
117
|
+
|
|
118
|
+
if (cmd === "check") {
|
|
119
|
+
const count = check();
|
|
120
|
+
console.error(`\n${count} file(s) to process`);
|
|
121
|
+
} else if (cmd === "update" && args.length >= 2) {
|
|
122
|
+
update(args.slice(1));
|
|
123
|
+
} else {
|
|
124
|
+
console.error(
|
|
125
|
+
"Usage:\n" +
|
|
126
|
+
" node scripts/state.mjs check\n" +
|
|
127
|
+
" node scripts/state.mjs update <file-path> [<file-path> …]",
|
|
128
|
+
);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|