standout 0.1.0 → 0.2.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/dist/gather.js ADDED
@@ -0,0 +1,869 @@
1
+ import { execSync } from "child_process";
2
+ import { existsSync, readFileSync, statSync } from "fs";
3
+ import { join } from "path";
4
+ import { homedir } from "os";
5
+ import { STANDOUT_API_URL } from "./api.js";
6
+ import { gatherAiUsage } from "./ai-usage.js";
7
+ import { gatherDevEnvironment } from "./dev-env.js";
8
+ import { scrapeTwitterHandle } from "./twitter-scrape.js";
9
+ import { redactSecrets } from "./redact.js";
10
+ export function emptyGatheredProfile(readiness = {
11
+ usage_stats_status: "unavailable",
12
+ profile_basics_status: "unavailable",
13
+ enrichment_status: "unavailable",
14
+ }) {
15
+ return {
16
+ readiness,
17
+ identity: {
18
+ name: null,
19
+ email: null,
20
+ github_username: null,
21
+ twitter_username: null,
22
+ remotes: [],
23
+ },
24
+ github: {
25
+ profile: null,
26
+ repos: [],
27
+ starred: [],
28
+ following: [],
29
+ events: [],
30
+ prs_authored: [],
31
+ contribution_graph: null,
32
+ repo_contents: {},
33
+ readmes: {},
34
+ commit_messages: {},
35
+ npm_packages: [],
36
+ enrichment: null,
37
+ },
38
+ linkedin: {
39
+ url: null,
40
+ url_resolution_source: null,
41
+ profile: null,
42
+ company_enrichments: {},
43
+ },
44
+ twitter: {
45
+ handle: null,
46
+ url: null,
47
+ profile: null,
48
+ },
49
+ local: {
50
+ repos: [],
51
+ commit_patterns: [],
52
+ collaborators: [],
53
+ },
54
+ ai_coding: {
55
+ total_commits_since_2024: 0,
56
+ ai_assisted_commits: 0,
57
+ lines_added_since_2024: 0,
58
+ lines_deleted_since_2024: 0,
59
+ median_commit_size: 0,
60
+ biggest_commit_size: 0,
61
+ commit_subjects: [],
62
+ claude_md_contents: null,
63
+ claude_dirs: [],
64
+ other_ai_tools: [],
65
+ cursorrules: null,
66
+ },
67
+ ai_usage: {
68
+ claude_code: null,
69
+ codex: null,
70
+ cursor: null,
71
+ },
72
+ dev_environment: {
73
+ agents_md: [],
74
+ claude_settings: [],
75
+ claude_commands: [],
76
+ claude_skills: [],
77
+ cursor_rules: [],
78
+ vscode_extensions: [],
79
+ global_npm_packages: [],
80
+ global_brew_packages: [],
81
+ global_pip_packages: [],
82
+ tools: [],
83
+ },
84
+ };
85
+ }
86
+ function hasAnyUsage(aiUsage) {
87
+ return Boolean(aiUsage.claude_code || aiUsage.codex || aiUsage.cursor);
88
+ }
89
+ function gatherLog(options, message) {
90
+ if (options.verbose === false)
91
+ return;
92
+ process.stderr.write(message);
93
+ }
94
+ export async function gatherUsageStats() {
95
+ try {
96
+ const aiUsage = await gatherAiUsage();
97
+ return {
98
+ ai_usage: aiUsage,
99
+ readiness: {
100
+ usage_stats_status: hasAnyUsage(aiUsage) ? "ready" : "unavailable",
101
+ profile_basics_status: "unavailable",
102
+ enrichment_status: "unavailable",
103
+ },
104
+ };
105
+ }
106
+ catch {
107
+ return {
108
+ ai_usage: {
109
+ claude_code: null,
110
+ codex: null,
111
+ cursor: null,
112
+ },
113
+ readiness: {
114
+ usage_stats_status: "error",
115
+ profile_basics_status: "unavailable",
116
+ enrichment_status: "unavailable",
117
+ },
118
+ };
119
+ }
120
+ }
121
+ export async function gatherProfileBasics() {
122
+ const base = emptyGatheredProfile({
123
+ usage_stats_status: "unavailable",
124
+ profile_basics_status: "scanning",
125
+ enrichment_status: "unavailable",
126
+ });
127
+ try {
128
+ const name = execSafe("git config user.name");
129
+ const email = execSafe("git config user.email");
130
+ const remotes = execSafe("git remote -v").split("\n").filter(Boolean);
131
+ const remoteCandidates = [];
132
+ for (const remote of remotes) {
133
+ const match = remote.match(/github\.com[:/]([a-zA-Z0-9_-]+)\/[a-zA-Z0-9_.-]+/);
134
+ if (match && !remoteCandidates.includes(match[1])) {
135
+ remoteCandidates.push(match[1]);
136
+ }
137
+ }
138
+ let githubUsername = null;
139
+ for (const candidate of remoteCandidates) {
140
+ const profile = (await fetchGitHub(`https://api.github.com/users/${sanitize(candidate)}`));
141
+ if (profile?.type === "User" && profile.login) {
142
+ githubUsername = profile.login;
143
+ break;
144
+ }
145
+ }
146
+ const safeUsername = githubUsername ? sanitize(githubUsername) : null;
147
+ const [ghProfile, ghRepos] = safeUsername
148
+ ? await Promise.all([
149
+ fetchGitHub(`https://api.github.com/users/${safeUsername}`),
150
+ fetchGitHub(`https://api.github.com/users/${safeUsername}/repos?per_page=100&sort=pushed`),
151
+ ])
152
+ : [null, []];
153
+ base.readiness.profile_basics_status = "ready";
154
+ base.identity = {
155
+ name: name || null,
156
+ email: email || null,
157
+ github_username: githubUsername,
158
+ twitter_username: ghProfile && typeof ghProfile === "object"
159
+ ? (ghProfile.twitter_username ??
160
+ null)
161
+ : null,
162
+ remotes,
163
+ };
164
+ base.github.profile = ghProfile
165
+ ? trimProfile(ghProfile)
166
+ : null;
167
+ base.github.repos = (Array.isArray(ghRepos) ? ghRepos : [])
168
+ .slice(0, 30)
169
+ .map(trimRepo);
170
+ return {
171
+ identity: base.identity,
172
+ github: base.github,
173
+ local: base.local,
174
+ readiness: base.readiness,
175
+ };
176
+ }
177
+ catch {
178
+ base.readiness.profile_basics_status = "error";
179
+ return {
180
+ identity: base.identity,
181
+ github: base.github,
182
+ local: base.local,
183
+ readiness: base.readiness,
184
+ };
185
+ }
186
+ }
187
+ export async function gatherFullEnrichment(options = {}) {
188
+ return gather(options);
189
+ }
190
+ function execSafe(cmd, timeout = 10000) {
191
+ try {
192
+ return execSync(cmd, {
193
+ timeout,
194
+ maxBuffer: 1024 * 1024,
195
+ encoding: "utf-8",
196
+ stdio: ["pipe", "pipe", "pipe"],
197
+ }).trim();
198
+ }
199
+ catch {
200
+ return "";
201
+ }
202
+ }
203
+ // First chunk of a repo's README (or CLAUDE.md fallback) — seeds the one-line
204
+ // project blurb. Markdown headers/links are kept; the LLM handles the prose.
205
+ function readRepoReadme(repoPath) {
206
+ for (const name of ["README.md", "README", "readme.md", "CLAUDE.md"]) {
207
+ const p = join(repoPath, name);
208
+ if (!existsSync(p))
209
+ continue;
210
+ try {
211
+ const text = readFileSync(p, "utf-8").trim();
212
+ // Redact first, THEN slice — slicing first can cut a secret mid-pattern so
213
+ // it no longer matches and its head leaks into the excerpt.
214
+ if (text.length > 0)
215
+ return redactSecrets(text).slice(0, 1200);
216
+ }
217
+ catch {
218
+ // unreadable — try the next candidate
219
+ }
220
+ }
221
+ return null;
222
+ }
223
+ // All GitHub traffic goes through the Standout-hosted proxy so users don't need a GITHUB_TOKEN.
224
+ function toProxyUrl(githubUrl) {
225
+ const apiMatch = githubUrl.match(/^https:\/\/api\.github\.com(\/.*)$/);
226
+ if (apiMatch) {
227
+ return `${STANDOUT_API_URL}/api/public/agent-github/api${apiMatch[1]}`;
228
+ }
229
+ const rawMatch = githubUrl.match(/^https:\/\/raw\.githubusercontent\.com(\/.*)$/);
230
+ if (rawMatch) {
231
+ return `${STANDOUT_API_URL}/api/public/agent-github/raw${rawMatch[1]}`;
232
+ }
233
+ return null;
234
+ }
235
+ async function fetchGitHub(url, acceptPreview) {
236
+ const proxied = toProxyUrl(url);
237
+ if (!proxied)
238
+ return null;
239
+ const headers = { Accept: "application/json" };
240
+ if (acceptPreview)
241
+ headers["x-github-accept-preview"] = acceptPreview;
242
+ try {
243
+ const res = await fetch(proxied, { headers });
244
+ if (res.status === 403 || res.status === 429) {
245
+ process.stderr.write("GitHub proxy rate limit hit. Try again in a few minutes.\n");
246
+ return null;
247
+ }
248
+ if (!res.ok)
249
+ return null;
250
+ return await res.json();
251
+ }
252
+ catch {
253
+ return null;
254
+ }
255
+ }
256
+ async function fetchGitHubText(url) {
257
+ const proxied = toProxyUrl(url);
258
+ if (!proxied)
259
+ return null;
260
+ try {
261
+ const res = await fetch(proxied);
262
+ if (!res.ok)
263
+ return null;
264
+ return await res.text();
265
+ }
266
+ catch {
267
+ return null;
268
+ }
269
+ }
270
+ async function fetchJson(url) {
271
+ try {
272
+ const res = await fetch(url);
273
+ if (!res.ok)
274
+ return null;
275
+ return await res.json();
276
+ }
277
+ catch {
278
+ return null;
279
+ }
280
+ }
281
+ async function enrichApi(type, value, context) {
282
+ try {
283
+ const res = await fetch(`${STANDOUT_API_URL}/api/public/agent-enrich`, {
284
+ method: "POST",
285
+ headers: { "Content-Type": "application/json" },
286
+ body: JSON.stringify(context ? { type, value, context } : { type, value }),
287
+ });
288
+ if (!res.ok)
289
+ return null;
290
+ return (await res.json());
291
+ }
292
+ catch {
293
+ return null;
294
+ }
295
+ }
296
+ function sanitize(input) {
297
+ return input.replace(/[^a-zA-Z0-9@._+\-]/g, "");
298
+ }
299
+ function findGitRepos() {
300
+ const dirs = [
301
+ "Document",
302
+ "Documents",
303
+ "Projects",
304
+ "projects",
305
+ "code",
306
+ "Code",
307
+ "dev",
308
+ "Dev",
309
+ "src",
310
+ "work",
311
+ "Work",
312
+ "workspace",
313
+ ];
314
+ const home = homedir();
315
+ // Dedupe by inode (dev:ino), not path string — on case-insensitive filesystems
316
+ // "work" and "Work" are the SAME directory but realpath keeps their distinct
317
+ // casing, so a path/realpath-based dedupe would scan + count them twice.
318
+ const inodeKey = (p) => {
319
+ try {
320
+ const s = statSync(p);
321
+ return `${s.dev}:${s.ino}`;
322
+ }
323
+ catch {
324
+ return null;
325
+ }
326
+ };
327
+ const seenRoots = new Set();
328
+ const searchPaths = [];
329
+ for (const d of dirs) {
330
+ const p = join(home, d);
331
+ if (!existsSync(p))
332
+ continue;
333
+ const key = inodeKey(p);
334
+ if (key && seenRoots.has(key))
335
+ continue;
336
+ if (key)
337
+ seenRoots.add(key);
338
+ searchPaths.push(p);
339
+ }
340
+ if (searchPaths.length === 0)
341
+ return [];
342
+ const result = execSafe(`find ${searchPaths.join(" ")} -maxdepth 3 -name ".git" -type d`, 15000);
343
+ const raw = result ? result.split("\n").filter(Boolean) : [];
344
+ // Dedupe repos by inode, then order by most-recently-active (.git mtime) so the
345
+ // downstream cap keeps the repos you actually work in, not whatever the
346
+ // filesystem happened to list first (e.g. old clones in ~/Documents).
347
+ const seen = new Set();
348
+ const withMtime = [];
349
+ for (const gitDir of raw) {
350
+ let mtime = 0;
351
+ let key = null;
352
+ try {
353
+ const s = statSync(gitDir);
354
+ mtime = s.mtimeMs;
355
+ key = `${s.dev}:${s.ino}`;
356
+ }
357
+ catch {
358
+ // unreadable — fall back to the path string as the dedupe key
359
+ }
360
+ const dedupe = key ?? gitDir;
361
+ if (seen.has(dedupe))
362
+ continue;
363
+ seen.add(dedupe);
364
+ withMtime.push({ path: gitDir, mtime });
365
+ }
366
+ return withMtime.sort((a, b) => b.mtime - a.mtime).map((r) => r.path);
367
+ }
368
+ // Trim helpers — keep only fields the LLM needs
369
+ function trimRepo(r) {
370
+ return {
371
+ name: r.name,
372
+ language: r.language,
373
+ description: r.description,
374
+ stars: r.stargazers_count ?? r.stars,
375
+ pushed_at: r.pushed_at,
376
+ fork: r.fork,
377
+ default_branch: r.default_branch,
378
+ topics: r.topics,
379
+ homepage: r.homepage,
380
+ created_at: r.created_at,
381
+ };
382
+ }
383
+ function trimStarred(r) {
384
+ return {
385
+ full_name: r.full_name,
386
+ description: r.description,
387
+ language: r.language,
388
+ stars: r.stargazers_count,
389
+ };
390
+ }
391
+ function trimFollowing(u) {
392
+ return { login: u.login, bio: u.bio };
393
+ }
394
+ function trimEvent(e) {
395
+ const repo = e.repo;
396
+ const payload = e.payload;
397
+ const pr = payload?.pull_request;
398
+ return {
399
+ type: e.type,
400
+ repo: repo?.name,
401
+ created_at: e.created_at,
402
+ ...(pr ? { pr_title: pr.title, pr_merged: pr.merged } : {}),
403
+ };
404
+ }
405
+ function trimPr(pr) {
406
+ const repoUrl = pr.repository_url;
407
+ const pullRequest = pr.pull_request;
408
+ return {
409
+ title: pr.title,
410
+ url: pr.html_url,
411
+ repo: repoUrl?.split("/").slice(-2).join("/"),
412
+ state: pr.state,
413
+ created_at: pr.created_at,
414
+ merged_at: pullRequest?.merged_at ?? null,
415
+ };
416
+ }
417
+ function trimProfile(p) {
418
+ return {
419
+ name: p.name,
420
+ bio: p.bio,
421
+ location: p.location,
422
+ company: p.company,
423
+ blog: p.blog,
424
+ twitter: p.twitter_username ?? p.twitter,
425
+ public_repos: p.public_repos,
426
+ followers: p.followers,
427
+ following: p.following,
428
+ created_at: p.created_at,
429
+ hireable: p.hireable,
430
+ };
431
+ }
432
+ function trimContent(f) {
433
+ return { name: f.name, type: f.type };
434
+ }
435
+ function trimNpmPackage(p) {
436
+ const pkg = p.package;
437
+ return {
438
+ name: pkg?.name,
439
+ version: pkg?.version,
440
+ description: pkg?.description,
441
+ };
442
+ }
443
+ export async function gather(options = {}) {
444
+ const profileBasics = options.profileBasics?.readiness.profile_basics_status === "ready"
445
+ ? options.profileBasics
446
+ : undefined;
447
+ // Phase 1: Git identity
448
+ const name = profileBasics
449
+ ? (profileBasics.identity.name ?? "")
450
+ : execSafe("git config user.name");
451
+ const email = profileBasics
452
+ ? (profileBasics.identity.email ?? "")
453
+ : execSafe("git config user.email");
454
+ const remotes = profileBasics
455
+ ? profileBasics.identity.remotes
456
+ : execSafe("git remote -v").split("\n").filter(Boolean);
457
+ // Extract GitHub username candidates from remotes (may be an org, not the user)
458
+ let githubUsername = profileBasics?.identity.github_username ?? null;
459
+ if (!profileBasics) {
460
+ const remoteCandidates = [];
461
+ for (const remote of remotes) {
462
+ const match = remote.match(/github\.com[:/]([a-zA-Z0-9_-]+)\/[a-zA-Z0-9_.-]+/);
463
+ if (match && !remoteCandidates.includes(match[1])) {
464
+ remoteCandidates.push(match[1]);
465
+ }
466
+ }
467
+ // Resolve to a USER (not an Organization) profile
468
+ for (const candidate of remoteCandidates) {
469
+ const profile = (await fetchGitHub(`https://api.github.com/users/${sanitize(candidate)}`));
470
+ if (profile?.type === "User" && profile.login) {
471
+ githubUsername = profile.login;
472
+ break;
473
+ }
474
+ }
475
+ }
476
+ // Fallback: search GitHub by email (public profile email match)
477
+ if (!githubUsername && email) {
478
+ const searchResult = (await fetchGitHub(`https://api.github.com/search/users?q=${encodeURIComponent(email)}+in:email`));
479
+ if (searchResult?.items?.[0]) {
480
+ githubUsername = searchResult.items[0].login;
481
+ }
482
+ }
483
+ // Fallback: search commits by author-email (catches users with private email)
484
+ if (!githubUsername && email) {
485
+ const commitSearch = (await fetchGitHub(`https://api.github.com/search/commits?q=author-email:${encodeURIComponent(email)}`));
486
+ const login = commitSearch?.items?.[0]?.author?.login;
487
+ if (login)
488
+ githubUsername = login;
489
+ }
490
+ const safeUsername = githubUsername ? sanitize(githubUsername) : null;
491
+ const safeEmail = email ? sanitize(email) : null;
492
+ // Phase 2: GitHub API (parallelized)
493
+ gatherLog(options, "Gathering GitHub data...\n");
494
+ const canUseCachedGithubBasics = Boolean(profileBasics?.identity.github_username) &&
495
+ profileBasics?.identity.github_username === githubUsername;
496
+ const [ghProfile, ghRepos, ghStarred, ghFollowing, ghEvents, ghPrs, ghContrib, ghNpm,] = safeUsername
497
+ ? await Promise.all([
498
+ canUseCachedGithubBasics
499
+ ? Promise.resolve(profileBasics?.github.profile ?? null)
500
+ : fetchGitHub(`https://api.github.com/users/${safeUsername}`),
501
+ canUseCachedGithubBasics
502
+ ? Promise.resolve(profileBasics?.github.repos ?? [])
503
+ : fetchGitHub(`https://api.github.com/users/${safeUsername}/repos?per_page=100&sort=pushed`),
504
+ fetchGitHub(`https://api.github.com/users/${safeUsername}/starred?per_page=100`),
505
+ fetchGitHub(`https://api.github.com/users/${safeUsername}/following?per_page=100`),
506
+ fetchGitHub(`https://api.github.com/users/${safeUsername}/events?per_page=30`),
507
+ fetchGitHub(`https://api.github.com/search/issues?q=author:${safeUsername}+type:pr&per_page=20`),
508
+ fetchJson(`https://github-contributions-api.jogruber.de/v4/${safeUsername}?y=last`),
509
+ fetchJson(`https://registry.npmjs.org/-/v1/search?text=maintainer:${safeUsername}&size=20`),
510
+ ])
511
+ : [null, [], [], [], [], { items: [] }, null, { objects: [] }];
512
+ const repos = Array.isArray(ghRepos) ? ghRepos : [];
513
+ const nonForkRepos = repos.filter((r) => !r.fork && r.language);
514
+ // Phase 2b: Repo contents, READMEs, commit messages (top repos)
515
+ const topRepos = nonForkRepos.slice(0, 5);
516
+ const repoContents = {};
517
+ const readmes = {};
518
+ const commitMessages = {};
519
+ if (safeUsername) {
520
+ const contentPromises = topRepos.map(async (repo) => {
521
+ const repoName = repo.name;
522
+ const contents = (await fetchGitHub(`https://api.github.com/repos/${safeUsername}/${repoName}/contents`));
523
+ if (Array.isArray(contents)) {
524
+ repoContents[repoName] = contents.map(trimContent);
525
+ }
526
+ });
527
+ const readmeRepos = nonForkRepos.slice(0, 3);
528
+ const readmePromises = readmeRepos.map(async (repo) => {
529
+ const repoName = repo.name;
530
+ const defaultBranch = repo.default_branch || "main";
531
+ const text = await fetchGitHubText(`https://raw.githubusercontent.com/${safeUsername}/${repoName}/${defaultBranch}/README.md`);
532
+ if (text)
533
+ readmes[repoName] = text.slice(0, 3000);
534
+ });
535
+ const commitRepos = nonForkRepos.slice(0, 3);
536
+ const commitPromises = commitRepos.map(async (repo) => {
537
+ const repoName = repo.name;
538
+ const commits = (await fetchGitHub(`https://api.github.com/repos/${safeUsername}/${repoName}/commits?per_page=10`));
539
+ if (Array.isArray(commits)) {
540
+ commitMessages[repoName] = commits
541
+ .map((c) => c.commit?.message || "")
542
+ .filter(Boolean)
543
+ .map((m) => m.split("\n")[0].slice(0, 120));
544
+ }
545
+ });
546
+ await Promise.all([
547
+ ...contentPromises,
548
+ ...readmePromises,
549
+ ...commitPromises,
550
+ ]);
551
+ }
552
+ // Phase 2c: AI tool usage + dev environment (run in background during LinkedIn)
553
+ gatherLog(options, "Scanning AI tool usage and dev environment...\n");
554
+ const githubEnrichPromise = safeUsername
555
+ ? enrichApi("github", safeUsername).catch(() => null)
556
+ : Promise.resolve(null);
557
+ const aiUsagePromise = options.aiUsage
558
+ ? Promise.resolve(options.aiUsage)
559
+ : gatherAiUsage().catch(() => ({
560
+ claude_code: null,
561
+ codex: null,
562
+ cursor: null,
563
+ }));
564
+ const devEnvPromise = gatherDevEnvironment().catch(() => ({
565
+ agents_md: [],
566
+ claude_settings: [],
567
+ claude_commands: [],
568
+ claude_skills: [],
569
+ cursor_rules: [],
570
+ vscode_extensions: [],
571
+ global_npm_packages: [],
572
+ global_brew_packages: [],
573
+ global_pip_packages: [],
574
+ tools: [],
575
+ }));
576
+ // Phase 3: LinkedIn enrichment
577
+ gatherLog(options, "Checking LinkedIn...\n");
578
+ let linkedinUrl = null;
579
+ let linkedinResolutionSource = null;
580
+ let linkedinProfile = null;
581
+ const companyEnrichments = {};
582
+ if (safeEmail) {
583
+ const emailResult = await enrichApi("email_to_linkedin", email);
584
+ if (emailResult && typeof emailResult === "object") {
585
+ linkedinUrl =
586
+ emailResult
587
+ .linkedin_profile_url || null;
588
+ if (linkedinUrl)
589
+ linkedinResolutionSource = "email";
590
+ }
591
+ }
592
+ // Fallback: name + signals via AI web search when email lookup comes up empty
593
+ if (!linkedinUrl && ghProfile && name) {
594
+ gatherLog(options, "Resolving LinkedIn via name + signals...\n");
595
+ const profile = ghProfile;
596
+ const signals = [];
597
+ if (profile.company)
598
+ signals.push(`Company: ${profile.company}`);
599
+ if (profile.location)
600
+ signals.push(`Location: ${profile.location}`);
601
+ if (profile.bio)
602
+ signals.push(`Bio: ${profile.bio}`);
603
+ if (profile.blog)
604
+ signals.push(`Website/blog: ${profile.blog}`);
605
+ if (safeUsername)
606
+ signals.push(`GitHub handle: ${safeUsername}`);
607
+ const topLangs = nonForkRepos
608
+ .slice(0, 5)
609
+ .map((r) => r.language)
610
+ .filter(Boolean);
611
+ if (topLangs.length)
612
+ signals.push(`Top GitHub languages: ${[...new Set(topLangs)].join(", ")}`);
613
+ const topRepoNames = nonForkRepos
614
+ .slice(0, 3)
615
+ .map((r) => r.name);
616
+ if (topRepoNames.length)
617
+ signals.push(`Notable GitHub repos: ${topRepoNames.join(", ")}`);
618
+ const resolveResult = await enrichApi("resolve_linkedin", name, {
619
+ email_domain: email ? email.split("@")[1] : undefined,
620
+ signals,
621
+ });
622
+ const resolved = resolveResult;
623
+ if (resolved?.linkedin_url) {
624
+ linkedinUrl = resolved.linkedin_url;
625
+ linkedinResolutionSource = "ai_search";
626
+ }
627
+ }
628
+ if (linkedinUrl) {
629
+ linkedinProfile = await enrichApi("linkedin_profile", linkedinUrl);
630
+ // Company enrichment
631
+ if (linkedinProfile &&
632
+ Array.isArray(linkedinProfile.experiences)) {
633
+ const experiences = linkedinProfile.experiences;
634
+ const companyUrls = [
635
+ ...new Set(experiences.map((e) => e.company_linkedin_profile_url).filter(Boolean)),
636
+ ].slice(0, 5);
637
+ await Promise.all(companyUrls.map(async (url) => {
638
+ const data = await enrichApi("company", url);
639
+ if (data)
640
+ companyEnrichments[url] = data;
641
+ }));
642
+ }
643
+ }
644
+ // Phase 3b: Twitter/X handle + optional enrichment
645
+ let twitterHandle = profileBasics?.identity.twitter_username ?? null;
646
+ if (ghProfile && typeof ghProfile === "object") {
647
+ const raw = ghProfile
648
+ .twitter_username ??
649
+ ghProfile.twitter;
650
+ if (!twitterHandle && typeof raw === "string" && raw.trim()) {
651
+ twitterHandle = raw.trim();
652
+ }
653
+ }
654
+ if (!twitterHandle && linkedinProfile) {
655
+ const socialHandles = linkedinProfile.social_handles;
656
+ if (socialHandles && typeof socialHandles.twitter === "string") {
657
+ twitterHandle = socialHandles.twitter;
658
+ }
659
+ }
660
+ // Fallback: scrape the user's logged-in Chrome session for an X handle.
661
+ if (!twitterHandle) {
662
+ gatherLog(options, "Checking for an X/Twitter session in Chrome...\n");
663
+ try {
664
+ const scraped = await scrapeTwitterHandle();
665
+ if (scraped)
666
+ twitterHandle = scraped;
667
+ }
668
+ catch {
669
+ // silent
670
+ }
671
+ }
672
+ const safeTwitter = twitterHandle ? sanitize(twitterHandle) : null;
673
+ let twitterProfile = null;
674
+ if (safeTwitter) {
675
+ twitterProfile = await enrichApi("twitter", safeTwitter);
676
+ }
677
+ const twitterUrl = safeTwitter ? `https://x.com/${safeTwitter}` : null;
678
+ // Phase 4: Local machine analysis
679
+ gatherLog(options, "Scanning local repos...\n");
680
+ const gitRepoPaths = findGitRepos();
681
+ const localRepos = [];
682
+ // Aggregated line stats across all local repos (since 2024).
683
+ let linesAdded = 0;
684
+ let linesDeleted = 0;
685
+ const commitSizes = [];
686
+ for (const gitDir of gitRepoPaths.slice(0, 30)) {
687
+ const repoPath = gitDir.replace(/\/\.git$/, "");
688
+ if (!safeEmail)
689
+ continue;
690
+ const count = parseInt(execSafe(`git -C "${repoPath}" log --author="${safeEmail}" --oneline | wc -l`), 10);
691
+ const lastCommit = execSafe(`git -C "${repoPath}" log --author="${safeEmail}" -1 --format="%ai %s"`);
692
+ if (count > 0) {
693
+ localRepos.push({
694
+ path: repoPath.replace(homedir(), "~"),
695
+ name: repoPath.split("/").filter(Boolean).pop() ?? repoPath,
696
+ commit_count: count || 0,
697
+ last_commit: lastCommit,
698
+ readme_excerpt: readRepoReadme(repoPath),
699
+ });
700
+ // Per-commit added/deleted lines (since 2024). The %H line marks a commit;
701
+ // numstat lines are "added\tdeleted\tfile" ("-" for binary).
702
+ const numstat = execSafe(`git -C "${repoPath}" log --author="${safeEmail}" --since="2024-01-01" --pretty=tformat:"%H" --numstat`);
703
+ let cur = 0;
704
+ let inCommit = false;
705
+ for (const line of numstat.split("\n")) {
706
+ if (/^[0-9a-f]{40}$/.test(line)) {
707
+ if (inCommit)
708
+ commitSizes.push(cur);
709
+ cur = 0;
710
+ inCommit = true;
711
+ continue;
712
+ }
713
+ const t = line.split("\t");
714
+ if (t.length >= 2) {
715
+ const a = t[0] === "-" ? 0 : parseInt(t[0], 10) || 0;
716
+ const d = t[1] === "-" ? 0 : parseInt(t[1], 10) || 0;
717
+ linesAdded += a;
718
+ linesDeleted += d;
719
+ cur += a + d;
720
+ }
721
+ }
722
+ if (inCommit)
723
+ commitSizes.push(cur);
724
+ }
725
+ }
726
+ commitSizes.sort((a, b) => a - b);
727
+ const medianCommitSize = commitSizes.length
728
+ ? commitSizes[Math.floor(commitSizes.length / 2)]
729
+ : 0;
730
+ const biggestCommitSize = commitSizes.length
731
+ ? commitSizes[commitSizes.length - 1]
732
+ : 0;
733
+ // Commit patterns from largest local repo
734
+ const commitPatterns = [];
735
+ const collaborators = [];
736
+ if (localRepos.length > 0 && safeEmail) {
737
+ const sorted = [...localRepos].sort((a, b) => b.commit_count - a.commit_count);
738
+ const repoPath = sorted[0].path.replace("~", homedir());
739
+ const patterns = execSafe(`git -C "${repoPath}" log --author="${safeEmail}" --format="%ai" --since="2024-01-01"`);
740
+ if (patterns) {
741
+ commitPatterns.push(...patterns.split("\n").filter(Boolean).slice(0, 200));
742
+ }
743
+ const collabs = execSafe(`git -C "${repoPath}" log --format="%aN <%aE>" --since="2024-01-01" | sort | uniq -c | sort -rn | head -10`);
744
+ if (collabs) {
745
+ collaborators.push(...collabs.split("\n").filter(Boolean));
746
+ }
747
+ }
748
+ // Phase 5: AI coding tool analysis
749
+ gatherLog(options, "Analyzing AI coding tools...\n");
750
+ let totalCommits = 0;
751
+ let aiAssistedCommits = 0;
752
+ // Sum commits since 2024 across ALL local repos (matching the git email), not
753
+ // just the current directory — one repo badly under-counts a multi-repo dev.
754
+ if (safeEmail) {
755
+ for (const gitDir of gitRepoPaths.slice(0, 30)) {
756
+ const repoPath = gitDir.replace(/\/\.git$/, "");
757
+ const total = parseInt(execSafe(`git -C "${repoPath}" log --author="${safeEmail}" --format="%H" --since="2024-01-01" | wc -l`), 10) || 0;
758
+ if (total === 0)
759
+ continue;
760
+ totalCommits += total;
761
+ // Co-Authored-By at the commit level (not trailer lines: %b is multi-line
762
+ // and squash-merges stack several trailers per commit).
763
+ aiAssistedCommits +=
764
+ parseInt(execSafe(`git -C "${repoPath}" log --author="${safeEmail}" --since="2024-01-01" --grep="Co-Authored-By" --format="%H" | wc -l`), 10) || 0;
765
+ }
766
+ }
767
+ // Recent commit subjects (last 90 days) — feed the "what you shipped" summary.
768
+ let commitSubjects = [];
769
+ if (safeEmail) {
770
+ const subjectsRaw = execSafe(`git log --author="${safeEmail}" --since="90 days ago" --no-merges --format="%s"`);
771
+ commitSubjects = subjectsRaw
772
+ ? subjectsRaw
773
+ .split("\n")
774
+ .map((s) => s.trim())
775
+ .filter(Boolean)
776
+ .slice(0, 200)
777
+ .map((s) => redactSecrets(s).slice(0, 160))
778
+ : [];
779
+ }
780
+ const claudeMd = existsSync("CLAUDE.md")
781
+ ? readFileSync("CLAUDE.md", "utf-8").slice(0, 3000)
782
+ : null;
783
+ const claudeDirsRaw = execSafe(`find ${homedir()} -maxdepth 3 -path "*/.claude" -type d | head -5`);
784
+ const claudeDirs = claudeDirsRaw
785
+ ? claudeDirsRaw
786
+ .split("\n")
787
+ .filter(Boolean)
788
+ .map((d) => d.replace(homedir(), "~"))
789
+ : [];
790
+ const otherAiTools = [];
791
+ const aiToolPaths = [".cursor", ".continue", ".github/copilot", ".codeium"];
792
+ for (const tool of aiToolPaths) {
793
+ if (existsSync(join(homedir(), tool))) {
794
+ otherAiTools.push(tool);
795
+ }
796
+ }
797
+ let cursorrules = null;
798
+ if (existsSync(".cursorrules")) {
799
+ cursorrules = readFileSync(".cursorrules", "utf-8").slice(0, 2000);
800
+ }
801
+ // Phase 6: Await AI usage + dev environment + GitHub enrichment
802
+ const [aiUsage, devEnvironment, githubEnrichment] = await Promise.all([
803
+ aiUsagePromise,
804
+ devEnvPromise,
805
+ githubEnrichPromise,
806
+ ]);
807
+ gatherLog(options, "Done gathering.\n");
808
+ return {
809
+ readiness: {
810
+ usage_stats_status: hasAnyUsage(aiUsage) ? "ready" : "unavailable",
811
+ profile_basics_status: "ready",
812
+ enrichment_status: "ready",
813
+ },
814
+ identity: {
815
+ name: name || null,
816
+ email: email || null,
817
+ github_username: githubUsername,
818
+ twitter_username: twitterHandle,
819
+ remotes,
820
+ },
821
+ github: {
822
+ profile: ghProfile
823
+ ? trimProfile(ghProfile)
824
+ : null,
825
+ repos: repos.slice(0, 50).map(trimRepo),
826
+ starred: (Array.isArray(ghStarred) ? ghStarred : []).map(trimStarred),
827
+ following: (Array.isArray(ghFollowing) ? ghFollowing : []).map(trimFollowing),
828
+ events: (Array.isArray(ghEvents) ? ghEvents : []).map(trimEvent),
829
+ prs_authored: (ghPrs?.items || []).map(trimPr),
830
+ contribution_graph: ghContrib || null,
831
+ repo_contents: repoContents,
832
+ readmes,
833
+ commit_messages: commitMessages,
834
+ npm_packages: (ghNpm?.objects || []).map(trimNpmPackage),
835
+ enrichment: githubEnrichment,
836
+ },
837
+ linkedin: {
838
+ url: linkedinUrl,
839
+ url_resolution_source: linkedinResolutionSource,
840
+ profile: linkedinProfile,
841
+ company_enrichments: companyEnrichments,
842
+ },
843
+ twitter: {
844
+ handle: twitterHandle,
845
+ url: twitterUrl,
846
+ profile: twitterProfile,
847
+ },
848
+ local: {
849
+ repos: localRepos,
850
+ commit_patterns: commitPatterns,
851
+ collaborators,
852
+ },
853
+ ai_coding: {
854
+ total_commits_since_2024: totalCommits,
855
+ ai_assisted_commits: aiAssistedCommits,
856
+ lines_added_since_2024: linesAdded,
857
+ lines_deleted_since_2024: linesDeleted,
858
+ median_commit_size: medianCommitSize,
859
+ biggest_commit_size: biggestCommitSize,
860
+ commit_subjects: commitSubjects,
861
+ claude_md_contents: claudeMd,
862
+ claude_dirs: claudeDirs,
863
+ other_ai_tools: otherAiTools,
864
+ cursorrules,
865
+ },
866
+ ai_usage: aiUsage,
867
+ dev_environment: devEnvironment,
868
+ };
869
+ }