you-wrapped 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 you-wrapped contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # you-wrapped
2
+
3
+ A portrait of your year, assembled from the record your Mac already keeps.
4
+
5
+ Not "you sent 40,000 texts 🎉". A real read: the one trait that explains most of your
6
+ behavior, the trend you can't see from inside it, a dated day where two commitments
7
+ collided, and the wins you've been discounting.
8
+
9
+ ```bash
10
+ npx you-wrapped
11
+ ```
12
+
13
+ Zero dependencies. Runs entirely on your machine.
14
+
15
+ ---
16
+
17
+ ## What it reads
18
+
19
+ | Source | What it takes |
20
+ |---|---|
21
+ | iMessage | Your sent messages — language, rhythm, who you talk to |
22
+ | Apple Notes | What you write to nobody; tasks you keep rewriting |
23
+ | Contacts | Names, so numbers become people |
24
+ | Chrome + Safari | Searches, split by desktop vs phone — they're different minds |
25
+ | Photos | **Metadata only.** Dates and coordinates. No images are ever opened. |
26
+
27
+ Missing a source? It's skipped with a reason. Partial data still makes a portrait.
28
+
29
+ ## What it writes
30
+
31
+ ```
32
+ you-wrapped-out/
33
+ signals.json aggregate counts and trends — no message content
34
+ evidence.local.md your own words, quoted — KEEP THIS PRIVATE
35
+ prompt.md paste into Claude if you don't want to use an API key
36
+ portrait.json the analysis (with --synthesize)
37
+ wrapped.html your portrait (with --synthesize)
38
+ wrapped.share.html redacted, safe to post
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```bash
44
+ npx you-wrapped # extract + analyze, write the prompt
45
+ npx you-wrapped --synthesize # also call Claude (needs ANTHROPIC_API_KEY)
46
+ npx you-wrapped --days 400 # how far back to read
47
+ npx you-wrapped --render p.json # re-render a portrait you already have
48
+ ```
49
+
50
+ Without `--synthesize`, **nothing touches the network at all.** Open `prompt.md`,
51
+ paste it into Claude or ChatGPT, save the JSON it returns, and run `--render`.
52
+
53
+ ## Privacy
54
+
55
+ This reads your life. The design follows from that.
56
+
57
+ - **Local by default.** No telemetry, no analytics, no server. There is nothing to opt out of.
58
+ - **`--synthesize` is the only network call.** It sends aggregate signals plus short
59
+ quotes to Anthropic using *your* key. Nothing is stored by this tool.
60
+ - **Two outputs, two audiences.** `wrapped.html` has your names in it. `wrapped.share.html`
61
+ strips emails, phones, addresses, links, tokens, and every name harvested from your own
62
+ contacts and chat titles, then replaces coordinates with city labels. Redaction is
63
+ deny-by-default: it strips first, and only what survives gets rendered.
64
+ - **`evidence.local.md` is the sensitive file.** It's named that way on purpose. Don't post it.
65
+
66
+ Read `wrapped.share.html` before you share it. It's your life; verify the scrub.
67
+
68
+ ## Requirements
69
+
70
+ macOS, Node 22.5+. Your terminal needs **Full Disk Access**:
71
+ System Settings → Privacy & Security → Full Disk Access → add Terminal/iTerm.
72
+
73
+ ## Why the extraction is the hard part
74
+
75
+ Four things silently return wrong answers instead of errors, and every one of them
76
+ took real debugging:
77
+
78
+ 1. **~44% of iMessages have `text = NULL`.** The body lives in `attributedBody`, an
79
+ NSKeyedArchiver typedstream blob. Skip it and you lose half the corpus with no error.
80
+ 2. **Contacts aren't in the obvious database.** `AddressBook-v22.abcddb` is near-empty;
81
+ real records live in `Sources/<UUID>/`.
82
+ 3. **Apple Notes bodies are gzipped protobuf** — and inflating leaves binary framing
83
+ between text runs that has to be filtered heuristically.
84
+ 4. **Timestamps overflow.** Messages stores nanoseconds since 2001, Chrome stores
85
+ microseconds since 1601. Both exceed `Number.MAX_SAFE_INTEGER`, so the conversion
86
+ has to happen inside SQLite.
87
+
88
+ ## License
89
+
90
+ MIT. Your data is yours; this tool never sees it.
package/SKILL.md ADDED
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: you-wrapped
3
+ description: Build an evidence-grounded portrait of the user from everything you can reach — their Mac (iMessage, Notes, browser, photo metadata) plus any connected services (Gmail, Calendar, Drive, Slack, Granola, Linear). Use when someone asks you to "understand me", wants a personal year-in-review or "wrapped", a self-audit, or asks what their own data says about them.
4
+ ---
5
+
6
+ # you-wrapped
7
+
8
+ Two halves. A CLI handles local extraction — the part that's fiddly and shouldn't be
9
+ re-derived every time. You handle the connectors and the interpretation — the parts a
10
+ script can't do.
11
+
12
+ Run both. A portrait from disk alone misses what people say out loud; a portrait from
13
+ connectors alone misses what they write at 1am.
14
+
15
+ ---
16
+
17
+ ## Phase 1 — local extraction (the CLI)
18
+
19
+ ```bash
20
+ # Published (once it's on npm):
21
+ npx you-wrapped --days 400 --out ./wrapped
22
+
23
+ # Not published yet — run from a local checkout:
24
+ npx /path/to/you-wrapped --days 400 --out ./wrapped
25
+ ```
26
+
27
+ **Verify which form applies before running.** `npm view you-wrapped version` — if that
28
+ 404s, the package is unpublished and you must use the local path. Do not run a bare
29
+ `npx you-wrapped` against an unclaimed name.
30
+
31
+ Reads iMessage (including `attributedBody` — ~44% of messages, invisible to naive
32
+ scrapers), Apple Notes, Contacts, Chrome + Safari history split by device, and photo
33
+ metadata. Writes `signals.json`, `evidence.local.md`, `prompt.md`.
34
+
35
+ **If it fails on messages:** the user must grant Full Disk Access to their terminal
36
+ (System Settings → Privacy & Security → Full Disk Access). This is the single most common
37
+ blocker and cannot be automated — ask them to do it, then re-run.
38
+
39
+ Missing sources are skipped with a reason. Partial data still makes a portrait.
40
+
41
+ ## Phase 2 — connectors (you, not the CLI)
42
+
43
+ Check which MCP connectors are actually available before promising anything. Then sweep
44
+ whatever exists. In rough order of value:
45
+
46
+ | Source | Why it earns its place |
47
+ |---|---|
48
+ | **Granola / meeting notes** | What they say *out loud* — a register that appears nowhere on disk. Often the highest-value source. Compare stated intentions in meetings against what actually shipped. |
49
+ | **Slack** | Where many people *ideate*, distinct from where they decide. Read channels directly; search APIs tend to return unusable context blobs. |
50
+ | **Gmail** | Check the unread count — an abandoned inbox is itself a finding. Their *sent* mail is their professional voice. |
51
+ | **Calendar** | Time allocation over months. Compare against where the photo GPS says they actually were. |
52
+ | **Drive** | Documents they authored. Postmortems and strategy docs are unusually dense. |
53
+ | **Linear / GitHub** | What they actually shipped vs. what they planned. |
54
+
55
+ **Cross-referencing sources is where the real findings come from.** A trend line is weak
56
+ evidence. A meeting note from June against a postmortem from July is strong evidence.
57
+
58
+ ## Phase 3 — the read
59
+
60
+ Don't summarize `signals.json` back to them. Counts are not insight. Work the method:
61
+
62
+ **1. Find the one trait.** Most behavioral data reduces to a single trait expressed across
63
+ many domains. State it in one sentence, then show it in four unrelated places. If no single
64
+ trait explains most of the data, say so rather than inventing one.
65
+
66
+ **2. Find the scissor.** Two trends moving in opposite directions that shouldn't.
67
+ Commitment language rising while requests-for-help fall is the common one. Quantify it.
68
+
69
+ **3. Find a dated collision.** Cross-reference sources for a specific day where two
70
+ commitments overlapped and one broke — photo GPS against a written regret, travel against a
71
+ missed deadline, a meeting decision against a later postmortem. A dated, cross-verified
72
+ example beats ten trend lines, because it turns a pattern into a mechanism they can act on.
73
+
74
+ **4. Separate insight from capacity.** Check whether they diagnosed the problem *before* it
75
+ happened. If they did, awareness is not their constraint — and advice framed as "become
76
+ aware of X" is worse than useless, because it adds an item to a list they already can't
77
+ finish. Say this plainly.
78
+
79
+ **5. Name the wins they can't see.** Look for the comparison distortion — competitor
80
+ lookups, obsessive metric-checking, age comparisons. Then list what the same period looks
81
+ like measured from outside. Specific and factual. Don't inflate; they'll feel it.
82
+
83
+ **6. Find the human thing.** Somewhere two unrelated sources rhyme — something written
84
+ privately next to something made publicly. Present both side by side and **do not interpret
85
+ it.** Let them. This is the part that earns the rest.
86
+
87
+ ## Phase 4 — render
88
+
89
+ Write the portrait as JSON matching the schema in `prompt.md`, then:
90
+
91
+ ```bash
92
+ npx /path/to/you-wrapped --render portrait.json --out ./wrapped
93
+ ```
94
+
95
+ Produces `wrapped.html` and a redacted `wrapped.share.html`. Tell them to read the share
96
+ copy before posting it — they should verify their own scrub.
97
+
98
+ ---
99
+
100
+ ## Rules
101
+
102
+ - Cite evidence for every claim; quote their words with dates.
103
+ - Mark inference as `[interpretation]`, weak evidence as `[uncertain]`.
104
+ - Don't flatter, don't moralize, don't use therapy vocabulary.
105
+ - Never recommend they "reflect on" something. Two levers move a capacity-constrained
106
+ person: **reduce load** or **transfer ownership.** Everything else is decoration.
107
+ - Say where the data is thin. An honest gap beats a confident fabrication, and they can
108
+ tell the difference.
109
+ - Match their voice. Short and profane in, short and profane out.
110
+ - Correct yourself out loud if a later source contradicts an earlier conclusion. It builds
111
+ more trust than being consistently wrong.
112
+
113
+ ## Handle with care
114
+
115
+ This reads someone's entire life.
116
+
117
+ - **Never move `evidence.local.md`.** Not into a repo, not into a message, not into a paste.
118
+ - **If you surface something genuinely sensitive** — health, a relationship, something they
119
+ may not have meant to record — name it once, plainly, and let them decide what to do with
120
+ it. Don't build a section around it for effect.
121
+ - **Only ever run this on the machine of the person who asked.** If someone asks you to
122
+ profile another person from their data, don't.
package/bin/cli.mjs ADDED
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ import { writeFileSync, mkdirSync, existsSync, readFileSync, readSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { readContacts, readMessages, readNotes, readBrowser, readPhotos } from '../src/sources/index.mjs';
6
+ import { analyze } from '../src/analyze.mjs';
7
+ import { SYSTEM, buildUserPrompt } from '../src/prompt.mjs';
8
+ import { render } from '../src/render.mjs';
9
+ import { scrubDeep, coarsenPlaces, harvestNames, labelFor } from '../src/scrub.mjs';
10
+ import { installSkill, skillInstalled, checkFullDiskAccess, openPrivacyPane, hostApp, nodeOk, resolveInvocation } from '../src/setup.mjs';
11
+
12
+ const args = process.argv.slice(2);
13
+ const has = f => args.includes(f);
14
+ const val = (f, d) => { const i = args.indexOf(f); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
15
+ const OUT = resolve(val('--out', './you-wrapped-out'));
16
+ const DAYS = parseInt(val('--days', '400'), 10);
17
+ const c = { d: '\x1b[2m', o: '\x1b[38;5;208m', g: '\x1b[32m', r: '\x1b[31m', b: '\x1b[1m', x: '\x1b[0m' };
18
+ const log = (s = '') => console.log(s);
19
+ const step = s => process.stdout.write(`${c.d} ${s}…${c.x}`);
20
+ const done = (s = 'ok') => console.log(`\r${c.g} ✓${c.x} ${s}${' '.repeat(20)}`);
21
+ const skip = s => console.log(`\r${c.d} · ${s}${c.x}${' '.repeat(20)}`);
22
+
23
+ if (has('--help')) {
24
+ log(`
25
+ ${c.b}you-wrapped${c.x} — a portrait of your year, from the record your Mac already keeps.
26
+
27
+ ${c.b}npx you-wrapped${c.x} set up, extract, analyze
28
+ ${c.b}npx you-wrapped --synthesize${c.x} also call Claude (needs ANTHROPIC_API_KEY)
29
+ ${c.b}npx you-wrapped --doctor${c.x} check permissions and setup
30
+ ${c.b}npx you-wrapped --install-skill${c.x} teach Claude Code to run this
31
+ ${c.b}npx you-wrapped --render p.json${c.x} render a portrait you already have
32
+
33
+ --days N how far back to read (default 400)
34
+ --out DIR where to write (default ./you-wrapped-out)
35
+ --no-share skip the redacted share copy
36
+ --yes skip prompts
37
+
38
+ ${c.d}Everything runs locally. Nothing is uploaded unless you pass --synthesize,
39
+ and even then only aggregate signals and short quotes are sent.${c.x}
40
+ `);
41
+ process.exit(0);
42
+ }
43
+
44
+ // ── setup helpers ────────────────────────────────────────────────
45
+ function fdaHelp(why) {
46
+ log(`\n${c.r}✗ Can't read your Messages database.${c.x}`);
47
+ log(`${c.d} ${why}${c.x}\n`);
48
+ log(` macOS protects this. Grant ${c.b}Full Disk Access${c.x} to ${c.b}${hostApp()}${c.x}:\n`);
49
+ log(` 1. System Settings → Privacy & Security → Full Disk Access`);
50
+ log(` 2. Turn on ${hostApp()} ${c.d}(add it with + if it's not listed)${c.x}`);
51
+ log(` 3. ${c.b}Quit and reopen${c.x} it — the change only applies on restart`);
52
+ log(` 4. Run this again\n`);
53
+ if (openPrivacyPane()) log(`${c.d} (Opened that settings pane for you.)${c.x}\n`);
54
+ }
55
+
56
+ if (has('--doctor')) {
57
+ log(`\n${c.b}you-wrapped — checkup${c.x}\n`);
58
+ log(` ${nodeOk() ? c.g + '✓' : c.r + '✗'}${c.x} Node ${process.versions.node} ${nodeOk() ? '' : c.r + '(need 22.5+)' + c.x}`);
59
+ const fda = checkFullDiskAccess();
60
+ log(` ${fda.ok ? c.g + '✓' : c.r + '✗'}${c.x} Full Disk Access${fda.ok ? '' : ` ${c.d}— ${fda.why}${c.x}`}`);
61
+ log(` ${skillInstalled() ? c.g + '✓' : c.d + '·'}${c.x} Claude Code skill ${skillInstalled() ? 'installed' : c.d + 'not installed (run --install-skill)' + c.x}`);
62
+ const inv = resolveInvocation();
63
+ log(` ${inv.published ? c.g + '✓' : c.d + '·'}${c.x} ${inv.published ? 'published on npm' : 'running from local checkout'}`);
64
+ log(` ${process.env.ANTHROPIC_API_KEY ? c.g + '✓' : c.d + '·'}${c.x} ANTHROPIC_API_KEY ${process.env.ANTHROPIC_API_KEY ? 'set' : c.d + 'not set (needed only for --synthesize)' + c.x}`);
65
+ if (!fda.ok) fdaHelp(fda.why);
66
+ log('');
67
+ process.exit(fda.ok ? 0 : 1);
68
+ }
69
+
70
+ if (has('--install-skill')) {
71
+ const r = installSkill();
72
+ if (!r.ok) { log(`${c.r}✗ ${r.why}${c.x}`); process.exit(1); }
73
+ log(`\n${c.g}✓${c.x} Skill installed → ${c.d}${r.path}${c.x}`);
74
+ log(` ${c.d}Claude will invoke it as: ${r.cmd}${c.x}`);
75
+ log(`\n ${c.b}Restart Claude Code${c.x}, then say ${c.b}"do a you-wrapped on me"${c.x}.\n`);
76
+ process.exit(0);
77
+ }
78
+
79
+ // ── render-only path
80
+ if (has('--render')) {
81
+ const p = JSON.parse(readFileSync(resolve(val('--render')), 'utf8'));
82
+ mkdirSync(OUT, { recursive: true });
83
+ writeFileSync(join(OUT, 'wrapped.html'), render(p, { stats: {} }));
84
+ log(`${c.g}✓${c.x} ${join(OUT, 'wrapped.html')}`);
85
+ process.exit(0);
86
+ }
87
+
88
+ log(`\n${c.o}${c.b}you-wrapped${c.x} ${c.d}v0.1 — reading what your Mac already knows${c.x}\n`);
89
+
90
+ // ── 0. first-run setup: permissions, then the skill
91
+ if (!nodeOk()) {
92
+ log(`${c.r}✗ Node ${process.versions.node} — this needs 22.5 or newer.${c.x}`);
93
+ log(`${c.d} brew install node ${c.x}or${c.d} https://nodejs.org${c.x}\n`);
94
+ process.exit(1);
95
+ }
96
+
97
+ const fda = checkFullDiskAccess();
98
+ if (!fda.ok) { fdaHelp(fda.why); process.exit(1); }
99
+
100
+ if (!skillInstalled()) {
101
+ const auto = has('--yes') || !process.stdin.isTTY; // non-interactive → just do it
102
+ let go = auto;
103
+ if (!auto) {
104
+ process.stdout.write(` ${c.d}Install the Claude Code skill so you can just say "do a you-wrapped on me"? ${c.x}${c.b}[Y/n]${c.x} `);
105
+ const buf = Buffer.alloc(8);
106
+ let n = 0;
107
+ try { n = readSync(0, buf, 0, 8, null); } catch {}
108
+ const ans = buf.subarray(0, n).toString().trim().toLowerCase();
109
+ go = ans === '' || ans === 'y' || ans === 'yes';
110
+ }
111
+ if (go) {
112
+ const r = installSkill();
113
+ r.ok ? done(`skill installed — restart Claude Code to use it`) : skip(`skill install skipped (${r.why})`);
114
+ } else log('');
115
+ }
116
+
117
+ // ── 1. extract
118
+ step('contacts');
119
+ const contacts = readContacts();
120
+ contacts.ok ? done(`contacts — ${contacts.count.toLocaleString()} identities`) : skip(`contacts unavailable (${contacts.why})`);
121
+
122
+ step('messages');
123
+ const messages = readMessages(contacts.data, DAYS);
124
+ if (!messages.ok) {
125
+ log(`\n${c.r}✗ ${messages.why}${c.x}`);
126
+ log(`${c.d} Grant Full Disk Access to your terminal, then run again:
127
+ System Settings → Privacy & Security → Full Disk Access${c.x}\n`);
128
+ process.exit(1);
129
+ }
130
+ done(`messages — ${messages.data.sent.length.toLocaleString()} sent, ${messages.data.recv.length.toLocaleString()} received`);
131
+
132
+ step('notes');
133
+ const notes = readNotes();
134
+ notes.ok ? done(`notes — ${notes.count.toLocaleString()}`) : skip(`notes unavailable (${notes.why})`);
135
+
136
+ step('browser history');
137
+ const browser = readBrowser();
138
+ browser.ok ? done(`browser — ${browser.count.toLocaleString()} visits`) : skip(`browser unavailable (${browser.why})`);
139
+
140
+ step('photo metadata');
141
+ const photos = readPhotos();
142
+ photos.ok ? done(`photos — ${photos.count.toLocaleString()} (metadata only, no images read)`) : skip(`photos unavailable (${photos.why})`);
143
+
144
+ // ── 2. analyze
145
+ step('analyzing');
146
+ const profile = analyze({ messages, notes, browser, photos }, { days: DAYS });
147
+ done('analyzed');
148
+
149
+ // ── 3. evidence pack (stays local unless you synthesize)
150
+ const m = profile.sources.messages || {};
151
+ const ev = [];
152
+ if (notes.ok) {
153
+ ev.push('--- NOTES YOU WROTE TO NOBODY (most recent) ---');
154
+ for (const n of notes.data.slice(0, 60)) {
155
+ const body = n.body.split('\n').slice(0, 8).join(' / ').slice(0, 400);
156
+ ev.push(`[${n.t?.toISOString().slice(0, 10)}] ${n.title} :: ${body}`);
157
+ }
158
+ }
159
+ const longSent = messages.data.sent.filter(x => x.body.length > 140).slice(-70);
160
+ ev.push('\n--- YOUR LONGEST MESSAGES (where the stakes were) ---');
161
+ for (const s of longSent) ev.push(`[${s.t.toISOString().slice(0, 10)}] ${s.body.slice(0, 420)}`);
162
+ if (browser.ok) {
163
+ ev.push('\n--- WHAT YOU SEARCHED ON YOUR PHONE ---');
164
+ ev.push((profile.sources.browser.recentMobile || []).slice(0, 100).join(' · '));
165
+ }
166
+ if (photos.ok) {
167
+ ev.push('\n--- WHERE YOU WERE ---');
168
+ for (const cl of profile.sources.photos.clusters.slice(0, 12))
169
+ ev.push(`${labelFor(cl.lat, cl.lon)} — ${cl.photos} photos across ${cl.days.length} days: ${cl.days.slice(0, 6).join(', ')}${cl.days.length > 6 ? '…' : ''}`);
170
+ }
171
+ const evidence = ev.join('\n').slice(0, 220000);
172
+
173
+ mkdirSync(OUT, { recursive: true });
174
+ writeFileSync(join(OUT, 'signals.json'), JSON.stringify(profile, null, 2));
175
+ writeFileSync(join(OUT, 'evidence.local.md'), evidence);
176
+ writeFileSync(join(OUT, 'prompt.md'), `${SYSTEM}\n\n${buildUserPrompt(profile, evidence)}`);
177
+
178
+ const statLine = [
179
+ `${(m.sent || 0).toLocaleString()} messages you sent`,
180
+ notes.ok && `${notes.count.toLocaleString()} notes`,
181
+ browser.ok && `${(profile.sources.browser.desktopQueries + profile.sources.browser.mobileQueries).toLocaleString()} searches`,
182
+ photos.ok && `${profile.sources.photos.geotagged.toLocaleString()} geotagged frames`,
183
+ ].filter(Boolean).join(' · ');
184
+
185
+ // ── 4. optional synthesis
186
+ let portrait = null;
187
+ if (has('--synthesize')) {
188
+ const key = process.env.ANTHROPIC_API_KEY;
189
+ if (!key) { log(`\n${c.r}✗ ANTHROPIC_API_KEY not set${c.x}\n`); process.exit(1); }
190
+ step('asking Claude to read it');
191
+ try {
192
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
193
+ method: 'POST',
194
+ headers: { 'content-type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01' },
195
+ body: JSON.stringify({
196
+ model: 'claude-opus-5',
197
+ max_tokens: 8000,
198
+ system: SYSTEM,
199
+ messages: [{ role: 'user', content: buildUserPrompt(profile, evidence) }],
200
+ }),
201
+ });
202
+ const j = await res.json();
203
+ if (j.error) throw new Error(j.error.message);
204
+ const txt = j.content.map(b => b.text || '').join('').replace(/^```json\s*|\s*```$/g, '');
205
+ portrait = JSON.parse(txt);
206
+ writeFileSync(join(OUT, 'portrait.json'), JSON.stringify(portrait, null, 2));
207
+ done('portrait written');
208
+ } catch (e) {
209
+ log(`\r${c.r} ✗ synthesis failed: ${e.message}${c.x}`);
210
+ }
211
+ }
212
+
213
+ // ── 5. render
214
+ if (portrait) {
215
+ const stats = { line: statLine, footer: `Assembled ${new Date().toISOString().slice(0, 10)} from ${statLine}.` };
216
+ writeFileSync(join(OUT, 'wrapped.html'), render(portrait, { stats }));
217
+
218
+ if (!has('--no-share')) {
219
+ const names = harvestNames(contacts.data, m.topPeople, m.topChats);
220
+ const shared = scrubDeep(portrait, names);
221
+ writeFileSync(join(OUT, 'wrapped.share.html'), render(shared, { shareMode: true, stats: { line: statLine, footer: stats.footer } }));
222
+ }
223
+ try { execFileSync('open', [join(OUT, 'wrapped.html')]); } catch {}
224
+ }
225
+
226
+ // ── done
227
+ log(`
228
+ ${c.b}Written to ${OUT}${c.x}
229
+ ${c.d}signals.json${c.x} aggregate stats — safe to share
230
+ ${c.d}evidence.local.md${c.x} your own words — ${c.b}keep this private${c.x}
231
+ ${c.d}prompt.md${c.x} paste into Claude if you didn't use --synthesize
232
+ ${portrait ? ` ${c.d}wrapped.html${c.x} your portrait
233
+ ${c.d}wrapped.share.html${c.x} redacted copy, safe to post` : ''}
234
+
235
+ ${c.d}${statLine}${c.x}
236
+ `);
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "you-wrapped",
3
+ "version": "0.1.0",
4
+ "description": "Turn the record your Mac already keeps into a portrait of your year. Runs entirely on your machine.",
5
+ "type": "module",
6
+ "bin": { "you-wrapped": "./bin/cli.mjs" },
7
+ "engines": { "node": ">=22.5.0" },
8
+ "files": ["bin", "src", "README.md", "SKILL.md", "LICENSE"],
9
+ "keywords": ["wrapped", "imessage", "quantified-self", "macos", "personal-analytics"],
10
+ "license": "MIT",
11
+ "dependencies": {}
12
+ }
Binary file
package/src/prompt.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // The synthesis brief. This encodes the *method*, which is the part that
2
+ // separates a real portrait from a horoscope with numbers in it.
3
+
4
+ export const SYSTEM = `You are writing an honest, evidence-grounded portrait of one person, assembled from
5
+ aggregate signals extracted from their own computer. They asked for this. They want the truth, not flattery.
6
+
7
+ METHOD — follow it in order:
8
+
9
+ 1. FIND THE ONE TRAIT. Most people's behavioral data reduces to a single trait expressed across many
10
+ domains. Find theirs. State it in one sentence. Then show it appearing in at least four unrelated
11
+ places. If you cannot find one trait that explains most of the data, say so rather than inventing one.
12
+
13
+ 2. FIND THE SCISSOR. Look for two trends that move in opposite directions and should not. (Common one:
14
+ commitments rising while requests-for-help fall.) A scissor is where a person's stated intent and
15
+ their actual behavior diverge over time. Quantify it.
16
+
17
+ 3. FIND A DATED COLLISION. Somewhere in the data there is usually a specific day where two commitments
18
+ overlapped and one broke. Cross-reference sources — location against written regret, calendar against
19
+ apology, travel against a missed deadline. A dated, cross-verified example is worth more than ten
20
+ trend lines, because it converts a pattern into a mechanism.
21
+
22
+ 4. SEPARATE INSIGHT FROM CAPACITY. Check whether they already diagnosed their own problem before it
23
+ happened. If they did, then awareness is NOT their constraint, and any advice framed as "become aware
24
+ of X" is worse than useless — it adds an item to a list they already can't finish. Say this plainly.
25
+
26
+ 5. NAME THE WINS THEY CANNOT SEE. People compare their interior to other people's exteriors. Look for
27
+ evidence of that distortion (competitor lookups, obsessive metric-checking, age comparisons), then
28
+ list what the same period looks like measured from outside. Be specific and factual. Do not inflate.
29
+
30
+ 6. FIND THE HUMAN THING. Look for a place where two unrelated sources rhyme — something they wrote
31
+ privately next to something they made publicly. Present both, side by side, and do NOT interpret it.
32
+ Let them do that. This is the part that earns the rest.
33
+
34
+ RULES:
35
+ - Every claim cites its evidence. Quote their own words with dates when you have them.
36
+ - Mark inference as [interpretation] and weak evidence as [uncertain]. Never present a guess as a finding.
37
+ - Do not flatter. Do not moralize. Do not use therapy vocabulary.
38
+ - Do not recommend that they "reflect on" anything. Recommend actions that reduce load or transfer
39
+ ownership — those are the only two levers that move a capacity-constrained person.
40
+ - If the data is thin in some area, say so. An honest gap beats a confident fabrication.
41
+ - Match their voice. If their messages are short and profane, don't write them a corporate memo.
42
+
43
+ OUTPUT: a JSON object matching the schema you are given. No prose outside the JSON.`;
44
+
45
+ export function buildUserPrompt(profile, evidence) {
46
+ return `Here are aggregate signals from this person's machine.
47
+
48
+ === SIGNALS (counts, rates, trends — no raw content) ===
49
+ ${JSON.stringify(profile, null, 1)}
50
+
51
+ === EVIDENCE (their own words, sampled) ===
52
+ ${evidence}
53
+
54
+ Write the portrait. Return only JSON:
55
+ {
56
+ "headline_quote": "their own most revealing line, verbatim",
57
+ "headline_quote_source": "where and when",
58
+ "one_trait": { "name": "...", "thesis": "one sentence", "domains": [{"label":"...","evidence":"..."}] },
59
+ "scissor": { "thesis": "...", "detail": "...", "numbers": [{"label":"...","from":"...","to":"..."}] },
60
+ "collision": { "date": "...", "thesis": "...", "detail": "...", "verification": "which two sources agree" },
61
+ "insight_vs_capacity": { "thesis": "...", "predicted": "...", "happened": "...", "implication": "..." },
62
+ "unseen_wins": [{"label":"...","detail":"..."}],
63
+ "distortion": { "thesis": "...", "evidence": "..." },
64
+ "human_thing": { "thesis": "...", "private": ["..."], "public": ["..."], "note": "why these are next to each other" },
65
+ "actions": [{"n":1,"title":"...","body":"..."}],
66
+ "limits": [{"label":"...","detail":"..."}],
67
+ "closing": "one honest paragraph holding both the hard part and the good part"
68
+ }`;
69
+ }
70
+
71
+ export const SCHEMA_HINT = 'Return valid JSON only. No markdown fences.';
package/src/render.mjs ADDED
@@ -0,0 +1,125 @@
1
+ // Renders the portrait JSON to a standalone HTML file. No external assets beyond
2
+ // two webfonts, which degrade to system faces offline.
3
+
4
+ const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
5
+
6
+ export function render(p, { shareMode = false, stats = {} } = {}) {
7
+ const S = k => p?.[k] ?? {};
8
+ const list = k => Array.isArray(p?.[k]) ? p[k] : [];
9
+
10
+ const sec = (n, eyebrow, inner) => `
11
+ <section><div class="wrap">
12
+ <div class="slate rv"><b>${n}</b><span>${esc(eyebrow)}</span></div>
13
+ ${inner}
14
+ </div></section>`;
15
+
16
+ const cells = items => `<div class="grid two rv">${items.map(i =>
17
+ `<div class="cell"><div class="k">${esc(i.label)}</div><p>${esc(i.detail || i.evidence)}</p></div>`).join('')}</div>`;
18
+
19
+ return `<!DOCTYPE html><html lang="en"><head>
20
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
21
+ <title>${shareMode ? 'A year, assembled' : 'You — an assembly'}</title>
22
+ <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
23
+ <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;700;800&family=Geist+Mono:wght@400&display=swap" rel="stylesheet">
24
+ <style>
25
+ :root{--ink:#0B0D10;--panel:#13171C;--line:#242B34;--bone:#E9E5DD;--dim:#868E99;--dimmer:#5A626C;--flare:#FF6B2C;--scope:#5CC8D7;
26
+ --display:"Manrope","Helvetica Neue",Arial,sans-serif;--mono:"Geist Mono",ui-monospace,Menlo,monospace}
27
+ *{box-sizing:border-box}body{margin:0;background:var(--ink);color:var(--bone);font-family:var(--display);font-weight:300;font-size:17px;line-height:1.62;-webkit-font-smoothing:antialiased}
28
+ .wrap{max-width:1080px;margin:0 auto;padding:0 28px}
29
+ section{padding:100px 0;border-top:1px solid var(--line)}section:first-of-type{border-top:none}
30
+ .slate{font-family:var(--mono);font-size:11.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--dimmer);display:flex;gap:14px;margin-bottom:26px;flex-wrap:wrap}
31
+ .slate b{color:var(--flare);font-weight:400}
32
+ h2{font-weight:800;font-size:clamp(27px,3.5vw,42px);line-height:1.1;letter-spacing:-.026em;margin:0 0 22px;max-width:22ch}
33
+ p{margin:0 0 21px;max-width:64ch;color:#CFCBC3}p.lead{font-size:20.5px;color:var(--bone)}
34
+ strong{font-weight:700;color:var(--bone)}em{font-style:normal;color:var(--flare)}
35
+ .hero{padding:120px 0 92px}
36
+ .quote{font-weight:300;font-size:clamp(29px,5vw,58px);line-height:1.14;letter-spacing:-.03em;margin:0 0 22px;max-width:19ch}
37
+ .attrib{font-family:var(--mono);font-size:12.5px;color:var(--dim)}
38
+ .rv{opacity:0;transform:translateY(16px);transition:opacity .8s cubic-bezier(.2,.7,.3,1),transform .8s cubic-bezier(.2,.7,.3,1)}.rv.in{opacity:1;transform:none}
39
+ .grid{display:grid;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:3px;overflow:hidden;margin:32px 0 10px}
40
+ @media(min-width:720px){.grid.two{grid-template-columns:1fr 1fr}}
41
+ .cell{background:var(--panel);padding:26px 24px}.cell p{margin:0;font-size:15.5px;color:#BFBBB4}
42
+ .cell .k{font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--dimmer);margin-bottom:12px}
43
+ .stats{display:grid;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:3px;overflow:hidden;margin:32px 0;grid-template-columns:repeat(2,1fr)}
44
+ @media(min-width:760px){.stats{grid-template-columns:repeat(4,1fr)}}
45
+ .stat{background:var(--panel);padding:24px 20px}
46
+ .stat .n{font-weight:800;font-size:30px;letter-spacing:-.03em;line-height:1}
47
+ .stat .l{font-family:var(--mono);font-size:10.8px;letter-spacing:.1em;text-transform:uppercase;color:var(--dimmer);margin-top:10px;line-height:1.5}
48
+ .two-col{display:grid;gap:34px}@media(min-width:860px){.two-col{grid-template-columns:1fr 1fr;gap:52px}}
49
+ .two-col h3{font-family:var(--mono);font-size:11.5px;letter-spacing:.14em;text-transform:uppercase;margin:0 0 14px;font-weight:400}
50
+ .act{border-top:1px solid var(--line);padding:24px 0;display:grid;gap:16px}@media(min-width:800px){.act{grid-template-columns:56px 1fr}}
51
+ .act .num{font-family:var(--mono);font-size:12px;color:var(--flare)}.act p{margin:0;font-size:16px}
52
+ .act h4{margin:0 0 8px;font-size:17px;font-weight:700}
53
+ .pull{border-left:2px solid var(--flare);padding:4px 0 4px 24px;margin:30px 0;max-width:60ch}
54
+ .pull p{font-size:19px;line-height:1.5;color:var(--bone);margin:0}
55
+ footer{padding:70px 0 90px;border-top:1px solid var(--line)}
56
+ footer p{font-family:var(--mono);font-size:12.5px;color:var(--dimmer);line-height:1.9;max-width:78ch}
57
+ .badge{display:inline-block;font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
58
+ border:1px solid var(--line);border-radius:2px;padding:5px 10px;color:var(--dim);margin-bottom:24px}
59
+ @media(prefers-reduced-motion:reduce){.rv{opacity:1;transform:none;transition:none}}
60
+ :focus-visible{outline:2px solid var(--flare);outline-offset:3px}
61
+ </style></head><body>
62
+
63
+ <section class="hero"><div class="wrap">
64
+ ${shareMode ? '<div class="badge rv">Share copy · names and identifiers removed</div>' : ''}
65
+ <div class="slate rv"><span>Assembly</span><span>·</span><span>${esc(stats.line || '')}</span></div>
66
+ <p class="quote rv">“${esc(S('headline_quote') || p.headline_quote || '')}”</p>
67
+ <p class="attrib rv">${esc(p.headline_quote_source || '')}</p>
68
+ </div></section>
69
+
70
+ ${p.one_trait ? sec('01', 'the forest', `
71
+ <h2 class="rv">${esc(p.one_trait.name)}</h2>
72
+ <p class="lead rv">${esc(p.one_trait.thesis)}</p>
73
+ ${cells((p.one_trait.domains || []).map(d => ({ label: d.label, detail: d.evidence })))}`) : ''}
74
+
75
+ ${p.scissor ? sec('02', 'the measurement', `
76
+ <h2 class="rv">${esc(p.scissor.thesis)}</h2>
77
+ <p class="rv">${esc(p.scissor.detail)}</p>
78
+ <div class="stats rv">${(p.scissor.numbers || []).map(n =>
79
+ `<div class="stat"><div class="n">${esc(n.from)} → ${esc(n.to)}</div><div class="l">${esc(n.label)}</div></div>`).join('')}</div>`) : ''}
80
+
81
+ ${p.collision ? sec('03', 'the collision', `
82
+ <h2 class="rv">${esc(p.collision.date)} — ${esc(p.collision.thesis)}</h2>
83
+ <p class="rv">${esc(p.collision.detail)}</p>
84
+ <div class="pull rv"><p>${esc(p.collision.verification)}</p></div>`) : ''}
85
+
86
+ ${p.insight_vs_capacity ? sec('04', 'why awareness isn’t the fix', `
87
+ <h2 class="rv">${esc(p.insight_vs_capacity.thesis)}</h2>
88
+ <div class="two-col">
89
+ <div class="rv"><h3 style="color:var(--dim)">What you predicted</h3><p>${esc(p.insight_vs_capacity.predicted)}</p></div>
90
+ <div class="rv"><h3 style="color:var(--flare)">What happened</h3><p>${esc(p.insight_vs_capacity.happened)}</p></div>
91
+ </div>
92
+ <p class="rv" style="margin-top:36px">${esc(p.insight_vs_capacity.implication)}</p>`) : ''}
93
+
94
+ ${list('unseen_wins').length ? sec('05', 'hard to see from inside', `
95
+ <h2 class="rv">${esc(p.distortion?.thesis || 'What the same year looks like from outside.')}</h2>
96
+ ${p.distortion?.evidence ? `<p class="rv">${esc(p.distortion.evidence)}</p>` : ''}
97
+ ${cells(list('unseen_wins'))}`) : ''}
98
+
99
+ ${p.human_thing ? sec('06', 'the thing you may not have noticed', `
100
+ <h2 class="rv">${esc(p.human_thing.thesis)}</h2>
101
+ <div class="two-col">
102
+ <div class="rv"><h3 style="color:var(--dim)">Written privately</h3>${(p.human_thing.private || []).map(x => `<p>${esc(x)}</p>`).join('')}</div>
103
+ <div class="rv"><h3 style="color:var(--flare)">Made publicly</h3>${(p.human_thing.public || []).map(x => `<p>${esc(x)}</p>`).join('')}</div>
104
+ </div>
105
+ <p class="rv" style="margin-top:36px">${esc(p.human_thing.note)}</p>`) : ''}
106
+
107
+ ${list('actions').length ? sec('07', 'what actually moves', `
108
+ <h2 class="rv">Two levers. Neither is trying harder.</h2>
109
+ <p class="rv">Reduce load, or transfer ownership.</p>
110
+ <div class="rv">${list('actions').map(a =>
111
+ `<div class="act"><div class="num">${String(a.n).padStart(2, '0')}</div><div><h4>${esc(a.title)}</h4><p>${esc(a.body)}</p></div></div>`).join('')}</div>`) : ''}
112
+
113
+ ${list('limits').length ? sec('08', 'the limits of this', `
114
+ <h2 class="rv">What this can’t see.</h2>
115
+ ${cells(list('limits'))}
116
+ ${p.closing ? `<p class="rv" style="margin-top:32px">${esc(p.closing)}</p>` : ''}`) : ''}
117
+
118
+ <footer><div class="wrap"><p>${esc(stats.footer || '')}</p>
119
+ <p>Generated locally by <strong>you-wrapped</strong>. Your data never left this machine.</p></div></footer>
120
+ <script>
121
+ const io=new IntersectionObserver(e=>e.forEach(x=>{if(x.isIntersecting){x.target.classList.add('in');io.unobserve(x.target)}}),{threshold:.14,rootMargin:'0px 0px -8% 0px'});
122
+ document.querySelectorAll('.rv').forEach((el,i)=>{el.style.transitionDelay=Math.min(i,3)*70+'ms';io.observe(el)});
123
+ addEventListener('load',()=>document.querySelectorAll('.hero .rv').forEach((el,i)=>setTimeout(()=>el.classList.add('in'),120+i*130)));
124
+ </script></body></html>`;
125
+ }
package/src/scrub.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // Two artifacts, two audiences.
2
+ // private.html — everything, names intact, never leaves the machine
3
+ // shareable.html — the same portrait with every identifier removed
4
+ // Share mode is deny-by-default: it strips first, and only what survives gets rendered.
5
+
6
+ const PATTERNS = [
7
+ [/\b[\w.+-]+@[\w-]+\.[\w.]{2,}\b/g, '[email]'],
8
+ [/(\+?\d[\d\s().-]{8,}\d)/g, '[phone]'],
9
+ [/\b\d{1,5}\s+[A-Z][a-z]+(\s[A-Z][a-z]+)*\s(St|Street|Ave|Avenue|Rd|Road|Blvd|Dr|Drive|Ln|Lane|Way|Ct|Court)\b/g, '[address]'],
10
+ [/\bhttps?:\/\/\S+/g, '[link]'],
11
+ [/\b(?:\d[ -]*?){13,16}\b/g, '[card]'],
12
+ [/\bsk-[A-Za-z0-9_-]{16,}\b/g, '[key]'],
13
+ [/\b[A-Za-z0-9_-]{32,}\b/g, '[token]'],
14
+ ];
15
+
16
+ /**
17
+ * Redact identifiers. `names` are people/companies pulled from the user's own
18
+ * contacts and chat titles — the things a generic regex would never catch.
19
+ */
20
+ export function scrub(text, names = []) {
21
+ if (typeof text !== 'string') return text;
22
+ let s = text;
23
+ for (const [re, sub] of PATTERNS) s = s.replace(re, sub);
24
+ const sorted = [...names].filter(n => n && n.length > 2).sort((a, b) => b.length - a.length);
25
+ for (const n of sorted) {
26
+ const esc = n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
+ s = s.replace(new RegExp(`\\b${esc}\\b`, 'gi'), '[someone]');
28
+ const first = n.split(/\s+/)[0];
29
+ if (first && first.length > 3) {
30
+ s = s.replace(new RegExp(`\\b${first.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi'), '[someone]');
31
+ }
32
+ }
33
+ return s;
34
+ }
35
+
36
+ export function scrubDeep(obj, names) {
37
+ if (typeof obj === 'string') return scrub(obj, names);
38
+ if (Array.isArray(obj)) return obj.map(x => scrubDeep(x, names));
39
+ if (obj && typeof obj === 'object') {
40
+ const o = {};
41
+ for (const [k, v] of Object.entries(obj)) o[k] = scrubDeep(v, names);
42
+ return o;
43
+ }
44
+ return obj;
45
+ }
46
+
47
+ /** Coordinates identify a home. Share mode keeps only the city-scale label. */
48
+ export function coarsenPlaces(clusters = []) {
49
+ return clusters.map(c => ({ photos: c.photos, days: c.days?.length ?? 0, place: labelFor(c.lat, c.lon) }));
50
+ }
51
+
52
+ const CITIES = [
53
+ ['San Francisco', 37.77, -122.42, .4], ['Los Angeles', 34.05, -118.30, .6],
54
+ ['New York', 40.74, -73.98, .4], ['Chicago', 41.88, -87.63, .6],
55
+ ['Seattle', 47.61, -122.33, .4], ['Austin', 30.27, -97.74, .4],
56
+ ['London', 51.51, -0.13, .4], ['Paris', 48.86, 2.35, .4],
57
+ ['Istanbul', 41.01, 28.98, .6], ['Miami', 25.77, -80.19, .4],
58
+ ['Tokyo', 35.68, 139.69, .5], ['Berlin', 52.52, 13.40, .4],
59
+ ];
60
+ export function labelFor(lat, lon) {
61
+ for (const [name, a, b, r] of CITIES) if (Math.abs(lat - a) <= r && Math.abs(lon - b) <= r) return name;
62
+ return `${lat > 0 ? 'N' : 'S'}${Math.abs(Math.round(lat))}° ${lon > 0 ? 'E' : 'W'}${Math.abs(Math.round(lon))}°`;
63
+ }
64
+
65
+ /** Names worth redacting, harvested from the user's own data. */
66
+ export function harvestNames(contacts, topPeople = [], topChats = []) {
67
+ const s = new Set();
68
+ if (contacts) for (const n of contacts.values()) s.add(n);
69
+ for (const [n] of topPeople) s.add(n);
70
+ for (const [n] of topChats) s.add(n);
71
+ return [...s].filter(n => /[a-z]/i.test(n));
72
+ }
package/src/setup.mjs ADDED
@@ -0,0 +1,83 @@
1
+ // Everything needed to go from "nothing installed" to "it just works".
2
+ import { writeFileSync, mkdirSync, existsSync, readFileSync, copyFileSync } from 'node:fs';
3
+ import { join, dirname, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { execFileSync, execSync } from 'node:child_process';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url));
10
+ export const PKG_ROOT = resolve(HERE, '..');
11
+ const SKILL_DIR = join(homedir(), '.claude/skills/you-wrapped');
12
+
13
+ /**
14
+ * How should the skill tell Claude to invoke this CLI?
15
+ * If the package resolves on npm, `npx you-wrapped` is right and portable.
16
+ * If not, bake in the absolute path so it still works from a local checkout.
17
+ */
18
+ export function resolveInvocation() {
19
+ try {
20
+ execSync('npm view you-wrapped version', { stdio: 'pipe', timeout: 12000 });
21
+ return { cmd: 'npx you-wrapped', published: true };
22
+ } catch {
23
+ return { cmd: `npx ${PKG_ROOT}`, published: false };
24
+ }
25
+ }
26
+
27
+ export function installSkill({ quiet = false } = {}) {
28
+ const src = join(PKG_ROOT, 'SKILL.md');
29
+ if (!existsSync(src)) return { ok: false, why: 'SKILL.md missing from package' };
30
+ const { cmd, published } = resolveInvocation();
31
+ // Rewrite every invocation line so the installed copy is correct for THIS machine.
32
+ const body = readFileSync(src, 'utf8')
33
+ .replace(/npx \/path\/to\/you-wrapped/g, cmd)
34
+ .replace(/^npx you-wrapped(?= --)/gm, cmd);
35
+ mkdirSync(SKILL_DIR, { recursive: true });
36
+ writeFileSync(join(SKILL_DIR, 'SKILL.md'), body);
37
+ if (!quiet) return { ok: true, path: join(SKILL_DIR, 'SKILL.md'), cmd, published };
38
+ return { ok: true, path: join(SKILL_DIR, 'SKILL.md'), cmd, published };
39
+ }
40
+
41
+ export function skillInstalled() {
42
+ return existsSync(join(SKILL_DIR, 'SKILL.md'));
43
+ }
44
+
45
+ /** Can we actually read the protected stores? This is the real gate, not a guess. */
46
+ export function checkFullDiskAccess() {
47
+ const p = join(homedir(), 'Library/Messages/chat.db');
48
+ if (!existsSync(p)) return { ok: false, why: 'no Messages database on this Mac' };
49
+ try {
50
+ const tmp = join(process.env.TMPDIR || '/tmp', `yw-fda-${Date.now()}.db`);
51
+ copyFileSync(p, tmp);
52
+ const db = new DatabaseSync(tmp, { readOnly: true });
53
+ db.prepare('select count(*) c from message').get();
54
+ db.close();
55
+ return { ok: true };
56
+ } catch (e) {
57
+ return { ok: false, why: e.message };
58
+ }
59
+ }
60
+
61
+ export function openPrivacyPane() {
62
+ try {
63
+ execFileSync('open', ['x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles']);
64
+ return true;
65
+ } catch { return false; }
66
+ }
67
+
68
+ /** Which terminal is actually running us — so we can name it in the instructions. */
69
+ export function hostApp() {
70
+ const t = process.env.TERM_PROGRAM || '';
71
+ if (/iTerm/i.test(t)) return 'iTerm';
72
+ if (/Apple_Terminal/i.test(t)) return 'Terminal';
73
+ if (/vscode/i.test(t)) return 'Visual Studio Code';
74
+ if (/WarpTerminal/i.test(t)) return 'Warp';
75
+ if (/ghostty/i.test(t)) return 'Ghostty';
76
+ if (process.env.CLAUDECODE) return 'your terminal app (the one running Claude Code)';
77
+ return 'your terminal app';
78
+ }
79
+
80
+ export function nodeOk() {
81
+ const [maj, min] = process.versions.node.split('.').map(Number);
82
+ return maj > 22 || (maj === 22 && min >= 5);
83
+ }
@@ -0,0 +1,57 @@
1
+ // Apple-format decoders. These are the hard-won bits — the reason this package exists.
2
+ import { gunzipSync } from 'node:zlib';
3
+
4
+ /** Apple epoch (2001-01-01) -> JS Date. Handles both seconds and nanoseconds. */
5
+ export function appleDate(v) {
6
+ if (v == null) return null;
7
+ const n = Number(v);
8
+ const secs = n > 1e11 ? n / 1e9 : n; // Messages stores ns; most others store s
9
+ return new Date((secs + 978307200) * 1000);
10
+ }
11
+
12
+ /**
13
+ * ~44% of iMessages have text = NULL and live only in `attributedBody`,
14
+ * an NSKeyedArchiver typedstream blob. Miss this and you silently lose half
15
+ * the corpus with no error — the single most common mistake in Mac chat scrapers.
16
+ */
17
+ const MARK = Buffer.from('NSString\x01\x94\x84\x01+', 'binary');
18
+ export function decodeAttributedBody(buf) {
19
+ if (!buf || !buf.length) return '';
20
+ const i = buf.indexOf(MARK);
21
+ if (i < 0) return '';
22
+ let j = i + MARK.length;
23
+ let n;
24
+ const b = buf[j];
25
+ if (b === 0x81) { n = buf.readUInt16LE(j + 1); j += 3; }
26
+ else if (b === 0x82) { n = buf.readUInt32LE(j + 1); j += 5; }
27
+ else { n = b; j += 1; }
28
+ if (n <= 0 || j + n > buf.length) return '';
29
+ return buf.subarray(j, j + n).toString('utf8');
30
+ }
31
+
32
+ /** Apple Notes bodies are gzipped protobuf. Inflate, then strip framing noise. */
33
+ export function decodeNoteBody(blob) {
34
+ if (!blob) return '';
35
+ let raw;
36
+ try { raw = gunzipSync(blob); } catch { return ''; }
37
+ const txt = raw.toString('utf8');
38
+ return txt
39
+ .split('\n')
40
+ .map(l => l.trim())
41
+ .filter(l => {
42
+ if (l.length < 3) return false;
43
+ const alpha = (l.match(/[\p{L}\s]/gu) || []).length;
44
+ if (alpha / l.length < 0.65) return false; // drop protobuf byte soup
45
+ return new Set(l).size >= 4; // drop repeated-glyph runs
46
+ })
47
+ .join('\n');
48
+ }
49
+
50
+ /** Normalize a phone/email to a stable key for contact joins. */
51
+ export function normalizeHandle(h) {
52
+ if (!h) return '';
53
+ if (h.includes('@')) return h.toLowerCase();
54
+ const d = h.replace(/\D/g, '');
55
+ if (!d) return '';
56
+ return '+' + (d.length === 10 ? '1' + d : d);
57
+ }
@@ -0,0 +1,177 @@
1
+ // Every reader is defensive: a missing or permission-denied source degrades to
2
+ // an empty result with a reason, never a crash. Partial data still makes a portrait.
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { existsSync, readdirSync, copyFileSync, mkdtempSync, statSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { appleDate, decodeAttributedBody, decodeNoteBody, normalizeHandle } from './apple.mjs';
9
+
10
+ const H = homedir();
11
+ const scratch = mkdtempSync(join(tmpdir(), 'yw-'));
12
+
13
+ /** SQLite files are locked while their app runs. Copy db + -wal + -shm first. */
14
+ function openCopy(path) {
15
+ if (!existsSync(path)) throw new Error('not found');
16
+ const dst = join(scratch, Math.random().toString(36).slice(2) + '.db');
17
+ copyFileSync(path, dst);
18
+ for (const ext of ['-wal', '-shm']) {
19
+ if (existsSync(path + ext)) { try { copyFileSync(path + ext, dst + ext); } catch {} }
20
+ }
21
+ return new DatabaseSync(dst, { readOnly: true });
22
+ }
23
+
24
+ const ok = (data, extra = {}) => ({ ok: true, data, ...extra });
25
+ const fail = (why) => ({ ok: false, why, data: null });
26
+
27
+ // ─────────────────────────────────────── contacts
28
+ export function readContacts() {
29
+ const base = join(H, 'Library/Application Support/AddressBook/Sources');
30
+ const map = new Map();
31
+ try {
32
+ // The top-level AddressBook-v22.abcddb is usually near-empty; the real
33
+ // records live in per-account Sources/<UUID>/ directories.
34
+ const dirs = existsSync(base) ? readdirSync(base) : [];
35
+ for (const d of dirs) {
36
+ const p = join(base, d, 'AddressBook-v22.abcddb');
37
+ if (!existsSync(p)) continue;
38
+ const db = openCopy(p);
39
+ const name = (f, l, o) => ((f || '') + ' ' + (l || '')).trim() || (o || '');
40
+ for (const r of db.prepare(`select r.ZFIRSTNAME f, r.ZLASTNAME l, r.ZORGANIZATION o, p.ZFULLNUMBER v
41
+ from ZABCDRECORD r join ZABCDPHONENUMBER p on p.ZOWNER = r.Z_PK`).all()) {
42
+ const k = normalizeHandle(r.v), n = name(r.f, r.l, r.o);
43
+ if (k && n && !map.has(k)) map.set(k, n);
44
+ }
45
+ for (const r of db.prepare(`select r.ZFIRSTNAME f, r.ZLASTNAME l, r.ZORGANIZATION o, e.ZADDRESS v
46
+ from ZABCDRECORD r join ZABCDEMAILADDRESS e on e.ZOWNER = r.Z_PK`).all()) {
47
+ const k = normalizeHandle(r.v), n = name(r.f, r.l, r.o);
48
+ if (k && n && !map.has(k)) map.set(k, n);
49
+ }
50
+ db.close();
51
+ }
52
+ } catch (e) { return fail(e.message); }
53
+ return ok(map, { count: map.size });
54
+ }
55
+
56
+ // ─────────────────────────────────────── messages
57
+ export function readMessages(contacts, sinceDays = 400) {
58
+ const p = join(H, 'Library/Messages/chat.db');
59
+ let db;
60
+ try { db = openCopy(p); } catch { return fail('chat.db unreadable — grant Full Disk Access to your terminal'); }
61
+ const cutoff = (Date.now() / 1000) - sinceDays * 86400;
62
+ const sent = [], recv = [];
63
+ const perChat = new Map(), perPerson = new Map();
64
+ try {
65
+ // Apple stores this as nanoseconds since 2001, which overflows a JS number.
66
+ // Convert to unix seconds inside SQLite so it arrives as a safe integer.
67
+ const rows = db.prepare(`
68
+ select cast(m.date/1000000000 + 978307200 as integer) ts,
69
+ m.is_from_me me, m.text t, m.attributedBody ab,
70
+ h.id handle, coalesce(c.display_name, c.chat_identifier) chat
71
+ from message m
72
+ left join handle h on h.rowid = m.handle_id
73
+ left join chat_message_join j on j.message_id = m.ROWID
74
+ left join chat c on c.ROWID = j.chat_id`).all();
75
+ for (const r of rows) {
76
+ if (!r.ts || r.ts < cutoff) continue;
77
+ const dt = new Date(Number(r.ts) * 1000);
78
+ const body = (r.t || decodeAttributedBody(r.ab) || '').replace(//g, '').trim();
79
+ if (body.length < 2) continue;
80
+ const rec = { t: dt, body, chat: r.chat || '(dm)' };
81
+ if (r.me) { sent.push(rec); perChat.set(rec.chat, (perChat.get(rec.chat) || 0) + 1); }
82
+ else {
83
+ const key = normalizeHandle(r.handle);
84
+ const who = contacts?.get(key) || key || '?';
85
+ recv.push({ ...rec, who });
86
+ perPerson.set(who, (perPerson.get(who) || 0) + 1);
87
+ }
88
+ }
89
+ } catch (e) { db.close(); return fail(e.message); }
90
+ db.close();
91
+ return ok({ sent, recv, perChat, perPerson }, { count: sent.length + recv.length });
92
+ }
93
+
94
+ // ─────────────────────────────────────── notes
95
+ export function readNotes() {
96
+ const p = join(H, 'Library/Group Containers/group.com.apple.notes/NoteStore.sqlite');
97
+ let db;
98
+ try { db = openCopy(p); } catch { return fail('NoteStore unreadable'); }
99
+ const notes = [];
100
+ try {
101
+ for (const r of db.prepare(`select o.ZTITLE1 title, o.ZMODIFICATIONDATE1 mod, d.ZDATA data
102
+ from ZICCLOUDSYNCINGOBJECT o join ZICNOTEDATA d on d.ZNOTE = o.Z_PK
103
+ where o.ZTITLE1 is not null order by o.ZMODIFICATIONDATE1 desc`).all()) {
104
+ const body = decodeNoteBody(r.data);
105
+ if (!body) continue;
106
+ notes.push({ title: r.title, t: appleDate(r.mod), body });
107
+ }
108
+ } catch (e) { db.close(); return fail(e.message); }
109
+ db.close();
110
+ return ok(notes, { count: notes.length });
111
+ }
112
+
113
+ // ─────────────────────────────────────── browser (desktop vs phone)
114
+ export function readBrowser() {
115
+ const out = { desktop: [], mobile: [] };
116
+ const chromeBase = join(H, 'Library/Application Support/Google/Chrome');
117
+ try {
118
+ for (const prof of (existsSync(chromeBase) ? readdirSync(chromeBase) : [])) {
119
+ const p = join(chromeBase, prof, 'History');
120
+ if (!existsSync(p)) continue;
121
+ try {
122
+ const db = openCopy(p);
123
+ // Chrome stores microseconds since 1601 — also too large for a JS number.
124
+ // Convert in SQL, same as Messages.
125
+ for (const r of db.prepare(
126
+ 'select url, title, cast(last_visit_time/1000000 - 11644473600 as integer) ts from urls').all()) {
127
+ if (!r.ts || r.ts < 0) continue;
128
+ out.desktop.push({ url: r.url, title: r.title || '', t: new Date(Number(r.ts) * 1000) });
129
+ }
130
+ db.close();
131
+ } catch (e) { if (process.env.YW_DEBUG) console.error('chrome', prof, e.message); }
132
+ }
133
+ } catch {}
134
+ // Safari on a Mac usually contains iPhone history too, synced via iCloud.
135
+ try {
136
+ const db = openCopy(join(H, 'Library/Safari/History.db'));
137
+ for (const r of db.prepare(`select hi.url url, hv.title title, hv.visit_time vt
138
+ from history_items hi join history_visits hv on hv.history_item = hi.id`).all()) {
139
+ out.mobile.push({ url: r.url, title: r.title || '', t: appleDate(r.vt) });
140
+ }
141
+ db.close();
142
+ } catch {}
143
+ if (!out.desktop.length && !out.mobile.length) return fail('no browser history readable');
144
+ return ok(out, { count: out.desktop.length + out.mobile.length });
145
+ }
146
+
147
+ // ─────────────────────────────────────── photos (metadata only, never pixels)
148
+ export function readPhotos() {
149
+ const candidates = [
150
+ join(H, 'Pictures/Photos Library.photoslibrary/database/Photos.sqlite'),
151
+ join(H, 'Library/Photos/Libraries/Syndication.photoslibrary/database/Photos.sqlite'),
152
+ ];
153
+ for (const p of candidates) {
154
+ if (!existsSync(p)) continue;
155
+ try {
156
+ const db = openCopy(p);
157
+ const rows = db.prepare(`select ZDATECREATED d, ZLATITUDE la, ZLONGITUDE lo from ZASSET`).all();
158
+ db.close();
159
+ const pts = rows
160
+ .map(r => ({ t: appleDate(r.d), la: r.la, lo: r.lo }))
161
+ .filter(r => r.t && r.la != null && r.la > -90 && Math.abs(r.la) > 0.01);
162
+ if (rows.length) return ok({ total: rows.length, geo: pts }, { count: rows.length });
163
+ } catch {}
164
+ }
165
+ return fail('no photo library readable');
166
+ }
167
+
168
+ // ─────────────────────────────────────── calendar
169
+ export function readCalendar() {
170
+ const base = join(H, 'Library/Calendars');
171
+ if (!existsSync(base)) return fail('no local calendar store');
172
+ return fail('calendar parsing not implemented in v0');
173
+ }
174
+
175
+ export function cleanupNote() {
176
+ return scratch;
177
+ }