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 +21 -0
- package/README.md +90 -0
- package/SKILL.md +122 -0
- package/bin/cli.mjs +236 -0
- package/package.json +12 -0
- package/src/analyze.mjs +0 -0
- package/src/prompt.mjs +71 -0
- package/src/render.mjs +125 -0
- package/src/scrub.mjs +72 -0
- package/src/setup.mjs +83 -0
- package/src/sources/apple.mjs +57 -0
- package/src/sources/index.mjs +177 -0
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
|
+
}
|
package/src/analyze.mjs
ADDED
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
+
}
|