get_all_stats.js 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207
  1. #!/usr/bin/env node
  2. /* eslint-disable custom-rules/no-console-new-code, custom-rules/no-process-exit, custom-rules/no-top-level-side-effects */
  3. /**
  4. * Unified statistics extraction for Thinkback
  5. *
  6. * Consolidates git commits, session data, and time patterns into a single
  7. * narrative-friendly JSON output. Designed to work on any user's machine.
  8. *
  9. * Usage:
  10. * node get_all_stats.js # Full output as JSON
  11. * node get_all_stats.js --git-only # Just git stats
  12. * node get_all_stats.js --recent-only # Just session stats (recent ~30 days)
  13. * node get_all_stats.js --markdown # Also write detailed markdown report
  14. * node get_all_stats.js --markdown-path=/path/to/output.md
  15. * node get_all_stats.js --help
  16. */
  17. import { execFile, execFileSync } from 'child_process';
  18. import { promisify } from 'util';
  19. import fs from 'fs';
  20. import path from 'path';
  21. import os from 'os';
  22. const execFileAsync = promisify(execFile);
  23. // =============================================================================
  24. // ARGUMENT PARSING
  25. // =============================================================================
  26. function parseArgs(argv) {
  27. const args = {
  28. mode: 'all',
  29. help: false,
  30. markdown: false,
  31. markdownPath: null,
  32. };
  33. for (let i = 2; i < argv.length; i++) {
  34. const arg = argv[i];
  35. if (arg.startsWith('--markdown-path=')) {
  36. args.markdown = true;
  37. args.markdownPath = arg.slice('--markdown-path='.length);
  38. } else {
  39. switch (arg) {
  40. case '--git-only':
  41. args.mode = 'git';
  42. break;
  43. case '--recent-only':
  44. args.mode = 'recent';
  45. break;
  46. case '--markdown':
  47. args.markdown = true;
  48. break;
  49. case '--help':
  50. case '-h':
  51. args.help = true;
  52. break;
  53. }
  54. }
  55. }
  56. return args;
  57. }
  58. // =============================================================================
  59. // GIT USER IDENTITY
  60. // =============================================================================
  61. function getGitUser() {
  62. try {
  63. const name = execFileSync('git', ['config', 'user.name'], { encoding: 'utf-8' }).trim();
  64. const email = execFileSync('git', ['config', 'user.email'], { encoding: 'utf-8' }).trim();
  65. return { name, email };
  66. } catch {
  67. return { name: null, email: null };
  68. }
  69. }
  70. // =============================================================================
  71. // REPO DISCOVERY (from ~/.claude/projects/ folder names)
  72. // =============================================================================
  73. function pathExists(p) {
  74. try {
  75. fs.accessSync(p);
  76. return true;
  77. } catch {
  78. return false;
  79. }
  80. }
  81. function folderNameToPath(folderName) {
  82. if (!folderName.startsWith('-')) {
  83. return folderName.replace(/-/g, '/');
  84. }
  85. const parts = folderName.slice(1).split('-');
  86. let currentPath = '';
  87. let i = 0;
  88. while (i < parts.length) {
  89. let found = false;
  90. for (let len = parts.length - i; len >= 1; len--) {
  91. const segment = parts.slice(i, i + len).join('-');
  92. const testPath = currentPath + '/' + segment;
  93. if (pathExists(testPath)) {
  94. currentPath = testPath;
  95. i += len;
  96. found = true;
  97. break;
  98. }
  99. }
  100. if (!found) {
  101. currentPath += '/' + parts[i];
  102. i++;
  103. }
  104. }
  105. return currentPath;
  106. }
  107. function findGitRoot(dirPath) {
  108. // Resolve to absolute path to avoid infinite loops with relative paths
  109. let current = path.resolve(dirPath);
  110. const root = path.parse(current).root;
  111. while (current !== root) {
  112. const gitPath = path.join(current, '.git');
  113. try {
  114. if (fs.existsSync(gitPath)) {
  115. return current;
  116. }
  117. } catch {
  118. // Permission denied
  119. }
  120. const parent = path.dirname(current);
  121. // Safety check: stop if we're not making progress
  122. if (parent === current) {
  123. break;
  124. }
  125. current = parent;
  126. }
  127. return null;
  128. }
  129. function discoverReposFromProjects() {
  130. const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
  131. if (!fs.existsSync(claudeProjectsDir)) {
  132. return [];
  133. }
  134. const folderNames = fs.readdirSync(claudeProjectsDir);
  135. const gitRepos = new Set();
  136. for (const folderName of folderNames) {
  137. const decodedPath = folderNameToPath(folderName);
  138. if (!pathExists(decodedPath)) continue;
  139. const gitRoot = findGitRoot(decodedPath);
  140. if (gitRoot) {
  141. gitRepos.add(gitRoot);
  142. }
  143. }
  144. return [...gitRepos].sort();
  145. }
  146. // Fallback: scan ~/code for git repos
  147. function findGitReposInPath(searchPath) {
  148. const repos = [];
  149. function walk(dir, depth = 0) {
  150. if (depth > 5) return;
  151. try {
  152. const entries = fs.readdirSync(dir, { withFileTypes: true });
  153. for (const entry of entries) {
  154. if (!entry.isDirectory()) continue;
  155. if (entry.name.startsWith('.') && entry.name !== '.git') continue;
  156. const fullPath = path.join(dir, entry.name);
  157. if (entry.name === '.git') {
  158. repos.push(dir);
  159. return;
  160. }
  161. if (['node_modules', 'vendor', 'dist', 'build', '.next'].includes(entry.name)) {
  162. continue;
  163. }
  164. walk(fullPath, depth + 1);
  165. }
  166. } catch {
  167. // Permission denied
  168. }
  169. }
  170. walk(searchPath);
  171. return repos;
  172. }
  173. // =============================================================================
  174. // GIT COMMIT EXTRACTION
  175. // =============================================================================
  176. async function getClaudeCommitsFromRepo(repoPath, author) {
  177. const gitArgs = [
  178. '-C', repoPath,
  179. 'log', '--all',
  180. '--grep=Co-Authored-By: Claude',
  181. '--format=%H|%ai|%aI|%s',
  182. ];
  183. if (author) {
  184. gitArgs.push(`--author=${author}`);
  185. }
  186. try {
  187. const { stdout } = await execFileAsync('git', gitArgs, { maxBuffer: 10 * 1024 * 1024 });
  188. const lines = stdout.trim().split('\n').filter(line => line);
  189. return lines.map(line => {
  190. const [hash, dateStr, isoDate, ...subjectParts] = line.split('|');
  191. const subject = subjectParts.join('|');
  192. const date = new Date(isoDate);
  193. return {
  194. hash,
  195. date,
  196. dateStr,
  197. subject,
  198. repo: path.basename(repoPath),
  199. repoPath,
  200. };
  201. });
  202. } catch {
  203. return [];
  204. }
  205. }
  206. function aggregateGitStats(commits) {
  207. if (commits.length === 0) {
  208. return {
  209. totalCommits: 0,
  210. firstCommit: null,
  211. lastCommit: null,
  212. repos: {},
  213. repoCount: 0,
  214. byMonth: {},
  215. byDayOfWeek: { Sun: 0, Mon: 0, Tue: 0, Wed: 0, Thu: 0, Fri: 0, Sat: 0 },
  216. byHour: {},
  217. peakHour: null,
  218. peakDay: null,
  219. topRepos: [],
  220. };
  221. }
  222. // Dedupe commits by hash (worktrees share commits)
  223. const seenHashes = new Set();
  224. const uniqueCommits = [];
  225. for (const commit of commits) {
  226. if (!seenHashes.has(commit.hash)) {
  227. seenHashes.add(commit.hash);
  228. uniqueCommits.push(commit);
  229. }
  230. }
  231. uniqueCommits.sort((a, b) => a.date - b.date);
  232. const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
  233. const repos = {};
  234. const byMonth = {};
  235. const byDayOfWeek = { Sun: 0, Mon: 0, Tue: 0, Wed: 0, Thu: 0, Fri: 0, Sat: 0 };
  236. const byHour = {};
  237. for (const commit of uniqueCommits) {
  238. repos[commit.repo] = (repos[commit.repo] || 0) + 1;
  239. const month = commit.date.toISOString().slice(0, 7);
  240. byMonth[month] = (byMonth[month] || 0) + 1;
  241. const day = dayNames[commit.date.getDay()];
  242. byDayOfWeek[day]++;
  243. const hour = commit.date.getHours();
  244. byHour[hour] = (byHour[hour] || 0) + 1;
  245. }
  246. // Sort repos by count
  247. const sortedRepos = Object.fromEntries(
  248. Object.entries(repos).sort((a, b) => b[1] - a[1])
  249. );
  250. // Find peak hour
  251. let peakHour = null;
  252. let maxHourCount = 0;
  253. for (const [hour, count] of Object.entries(byHour)) {
  254. if (count > maxHourCount) {
  255. maxHourCount = count;
  256. peakHour = { hour: parseInt(hour), count, label: formatHour(parseInt(hour)) };
  257. }
  258. }
  259. // Find peak day
  260. let peakDay = null;
  261. let maxDayCount = 0;
  262. for (const [day, count] of Object.entries(byDayOfWeek)) {
  263. if (count > maxDayCount) {
  264. maxDayCount = count;
  265. peakDay = { day, count };
  266. }
  267. }
  268. // Top 5 repos
  269. const topRepos = Object.entries(sortedRepos)
  270. .slice(0, 5)
  271. .map(([name, count]) => ({ name, count }));
  272. return {
  273. totalCommits: uniqueCommits.length,
  274. firstCommit: {
  275. date: uniqueCommits[0].dateStr,
  276. repo: uniqueCommits[0].repo,
  277. subject: uniqueCommits[0].subject,
  278. },
  279. lastCommit: {
  280. date: uniqueCommits[uniqueCommits.length - 1].dateStr,
  281. repo: uniqueCommits[uniqueCommits.length - 1].repo,
  282. subject: uniqueCommits[uniqueCommits.length - 1].subject,
  283. },
  284. repos: sortedRepos,
  285. repoCount: Object.keys(repos).length,
  286. byMonth,
  287. byDayOfWeek,
  288. byHour,
  289. peakHour,
  290. peakDay,
  291. topRepos,
  292. };
  293. }
  294. // =============================================================================
  295. // SESSION FILE PROCESSING (No tool uses - per user request)
  296. // =============================================================================
  297. function findSessionFiles() {
  298. const claudeDir = path.join(os.homedir(), '.claude', 'projects');
  299. const sessionFiles = [];
  300. if (!fs.existsSync(claudeDir)) {
  301. return sessionFiles;
  302. }
  303. const projects = fs.readdirSync(claudeDir);
  304. for (const project of projects) {
  305. const projectDir = path.join(claudeDir, project);
  306. const stat = fs.statSync(projectDir);
  307. if (!stat.isDirectory()) continue;
  308. const files = fs.readdirSync(projectDir);
  309. for (const file of files) {
  310. if (file.endsWith('.jsonl')) {
  311. sessionFiles.push({
  312. project,
  313. path: path.join(projectDir, file),
  314. filename: file,
  315. });
  316. }
  317. }
  318. }
  319. return sessionFiles;
  320. }
  321. function parseSessionFile(filePath) {
  322. const stats = {
  323. userMessages: 0,
  324. assistantMessages: 0,
  325. timestamps: [],
  326. firstTimestamp: null,
  327. lastTimestamp: null,
  328. };
  329. let content;
  330. try {
  331. content = fs.readFileSync(filePath, 'utf-8');
  332. } catch {
  333. return stats;
  334. }
  335. const lines = content.split('\n').filter(line => line.trim());
  336. for (const line of lines) {
  337. let msg;
  338. try {
  339. msg = JSON.parse(line);
  340. } catch {
  341. continue;
  342. }
  343. if (msg.timestamp) {
  344. const msgDate = new Date(msg.timestamp);
  345. stats.timestamps.push(msgDate);
  346. if (!stats.firstTimestamp || msgDate < stats.firstTimestamp) {
  347. stats.firstTimestamp = msgDate;
  348. }
  349. if (!stats.lastTimestamp || msgDate > stats.lastTimestamp) {
  350. stats.lastTimestamp = msgDate;
  351. }
  352. }
  353. if (msg.type === 'user') {
  354. stats.userMessages++;
  355. } else if (msg.type === 'assistant') {
  356. stats.assistantMessages++;
  357. }
  358. }
  359. return stats;
  360. }
  361. // =============================================================================
  362. // DETAILED SESSION MESSAGE EXTRACTION (for markdown report)
  363. // =============================================================================
  364. function extractRecentMessages(filePath, limit = 10) {
  365. const messages = [];
  366. let content;
  367. try {
  368. content = fs.readFileSync(filePath, 'utf-8');
  369. } catch {
  370. return messages;
  371. }
  372. const lines = content.split('\n').filter(line => line.trim());
  373. for (const line of lines) {
  374. let msg;
  375. try {
  376. msg = JSON.parse(line);
  377. } catch {
  378. continue;
  379. }
  380. if (msg.type === 'user' && msg.message?.content) {
  381. // Extract text content from user message
  382. let text = '';
  383. if (typeof msg.message.content === 'string') {
  384. text = msg.message.content;
  385. } else if (Array.isArray(msg.message.content)) {
  386. text = msg.message.content
  387. .filter(c => c.type === 'text')
  388. .map(c => c.text)
  389. .join(' ');
  390. }
  391. if (text.trim()) {
  392. messages.push({
  393. type: 'user',
  394. timestamp: msg.timestamp ? new Date(msg.timestamp) : null,
  395. text: text.slice(0, 500), // Truncate long messages
  396. });
  397. }
  398. }
  399. }
  400. // Return only the most recent messages
  401. return messages.slice(-limit);
  402. }
  403. function getRecentMessagesPerProject() {
  404. const claudeDir = path.join(os.homedir(), '.claude', 'projects');
  405. const projectMessages = {};
  406. if (!fs.existsSync(claudeDir)) {
  407. return projectMessages;
  408. }
  409. const projects = fs.readdirSync(claudeDir);
  410. for (const project of projects) {
  411. const projectDir = path.join(claudeDir, project);
  412. try {
  413. const stat = fs.statSync(projectDir);
  414. if (!stat.isDirectory()) continue;
  415. } catch {
  416. continue;
  417. }
  418. const files = fs.readdirSync(projectDir);
  419. const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
  420. // Get the most recent session file
  421. let mostRecentFile = null;
  422. let mostRecentTime = 0;
  423. for (const file of jsonlFiles) {
  424. const filePath = path.join(projectDir, file);
  425. try {
  426. const fileStat = fs.statSync(filePath);
  427. if (fileStat.mtimeMs > mostRecentTime) {
  428. mostRecentTime = fileStat.mtimeMs;
  429. mostRecentFile = filePath;
  430. }
  431. } catch {
  432. continue;
  433. }
  434. }
  435. if (mostRecentFile) {
  436. const decodedProject = decodeProjectName(project);
  437. const messages = extractRecentMessages(mostRecentFile, 5);
  438. if (messages.length > 0) {
  439. projectMessages[decodedProject] = {
  440. encodedName: project,
  441. messages,
  442. lastActive: new Date(mostRecentTime),
  443. };
  444. }
  445. }
  446. }
  447. return projectMessages;
  448. }
  449. // =============================================================================
  450. // TIME PATTERN HELPERS
  451. // =============================================================================
  452. function formatHour(hour) {
  453. if (hour === 0) return '12am';
  454. if (hour === 12) return '12pm';
  455. if (hour < 12) return `${hour}am`;
  456. return `${hour - 12}pm`;
  457. }
  458. function formatDate(date) {
  459. return date.toISOString().split('T')[0];
  460. }
  461. function getDayName(dayIndex) {
  462. const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
  463. return days[dayIndex];
  464. }
  465. function getISOWeek(date) {
  466. const d = new Date(date);
  467. d.setHours(0, 0, 0, 0);
  468. d.setDate(d.getDate() + 4 - (d.getDay() || 7));
  469. const yearStart = new Date(d.getFullYear(), 0, 1);
  470. const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
  471. return `${d.getFullYear()}-W${String(weekNo).padStart(2, '0')}`;
  472. }
  473. function getWeekDateRange(isoWeek) {
  474. const [year, week] = isoWeek.split('-W').map(Number);
  475. const jan1 = new Date(year, 0, 1);
  476. const days = (week - 1) * 7;
  477. const weekStart = new Date(jan1.getTime() + days * 86400000);
  478. const dayOfWeek = weekStart.getDay();
  479. const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
  480. weekStart.setDate(weekStart.getDate() + diff);
  481. const weekEnd = new Date(weekStart.getTime() + 6 * 86400000);
  482. const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  483. return `${months[weekStart.getMonth()]} ${weekStart.getDate()}-${weekEnd.getDate()}, ${year}`;
  484. }
  485. // =============================================================================
  486. // SESSION STATS AGGREGATION
  487. // =============================================================================
  488. function aggregateSessionStats(sessionFiles) {
  489. const sessions = [];
  490. const projectStats = {};
  491. const hourCounts = Array(24).fill(0);
  492. const weekdayCounts = Array(7).fill(0);
  493. const daySessions = {};
  494. const weekSessions = {};
  495. let totalUserMessages = 0;
  496. let totalAssistantMessages = 0;
  497. let firstTimestamp = null;
  498. let lastTimestamp = null;
  499. for (const sessionFile of sessionFiles) {
  500. const stats = parseSessionFile(sessionFile.path);
  501. if (stats.timestamps.length === 0) continue;
  502. const session = {
  503. project: sessionFile.project,
  504. filename: sessionFile.filename,
  505. messages: stats.userMessages + stats.assistantMessages,
  506. userMessages: stats.userMessages,
  507. assistantMessages: stats.assistantMessages,
  508. firstTimestamp: stats.firstTimestamp,
  509. lastTimestamp: stats.lastTimestamp,
  510. duration: stats.lastTimestamp - stats.firstTimestamp,
  511. };
  512. sessions.push(session);
  513. totalUserMessages += stats.userMessages;
  514. totalAssistantMessages += stats.assistantMessages;
  515. // Track per-project stats
  516. if (!projectStats[sessionFile.project]) {
  517. projectStats[sessionFile.project] = { sessions: 0, messages: 0 };
  518. }
  519. projectStats[sessionFile.project].sessions++;
  520. projectStats[sessionFile.project].messages += session.messages;
  521. // Track timestamps
  522. if (!firstTimestamp || stats.firstTimestamp < firstTimestamp) {
  523. firstTimestamp = stats.firstTimestamp;
  524. }
  525. if (!lastTimestamp || stats.lastTimestamp > lastTimestamp) {
  526. lastTimestamp = stats.lastTimestamp;
  527. }
  528. // Track hourly/weekday patterns
  529. const hour = stats.firstTimestamp.getHours();
  530. const weekday = stats.firstTimestamp.getDay();
  531. hourCounts[hour]++;
  532. weekdayCounts[weekday]++;
  533. // Track daily sessions
  534. const dateStr = formatDate(stats.firstTimestamp);
  535. daySessions[dateStr] = (daySessions[dateStr] || 0) + 1;
  536. // Track weekly sessions
  537. const weekStr = getISOWeek(stats.firstTimestamp);
  538. if (!weekSessions[weekStr]) {
  539. weekSessions[weekStr] = { sessions: 0, messages: 0 };
  540. }
  541. weekSessions[weekStr].sessions++;
  542. weekSessions[weekStr].messages += session.messages;
  543. }
  544. // Sort projects by session count and decode names
  545. const topProjects = Object.entries(projectStats)
  546. .sort((a, b) => b[1].sessions - a[1].sessions)
  547. .slice(0, 5)
  548. .map(([name]) => {
  549. // Decode the folder name to get the actual path, then extract the last part
  550. const decoded = folderNameToPath(name);
  551. return path.basename(decoded);
  552. });
  553. return {
  554. sessions,
  555. projectStats,
  556. hourCounts,
  557. weekdayCounts,
  558. daySessions,
  559. weekSessions,
  560. totalUserMessages,
  561. totalAssistantMessages,
  562. firstTimestamp,
  563. lastTimestamp,
  564. topProjects,
  565. };
  566. }
  567. // =============================================================================
  568. // PATTERN CALCULATIONS
  569. // =============================================================================
  570. function calculatePatterns(data) {
  571. const totalSessions = data.sessions.length;
  572. // Hourly patterns
  573. let peakHour = { hour: 0, label: '12am', sessions: 0 };
  574. let quietestHour = { hour: 0, label: '12am', sessions: Infinity };
  575. for (let hour = 0; hour < 24; hour++) {
  576. const count = data.hourCounts[hour];
  577. if (count > peakHour.sessions) {
  578. peakHour = { hour, label: formatHour(hour), sessions: count };
  579. }
  580. if (count < quietestHour.sessions) {
  581. quietestHour = { hour, label: formatHour(hour), sessions: count };
  582. }
  583. }
  584. // Weekly patterns
  585. let busiestDay = { name: 'Monday', sessions: 0 };
  586. let quietestDay = { name: 'Monday', sessions: Infinity };
  587. for (let day = 0; day < 7; day++) {
  588. const count = data.weekdayCounts[day];
  589. const name = getDayName(day);
  590. if (count > busiestDay.sessions) {
  591. busiestDay = { name, sessions: count };
  592. }
  593. if (count < quietestDay.sessions) {
  594. quietestDay = { name, sessions: count };
  595. }
  596. }
  597. // Night owl (10pm - 4am)
  598. const nightHours = [22, 23, 0, 1, 2, 3];
  599. const nightSessions = nightHours.reduce((sum, h) => sum + data.hourCounts[h], 0);
  600. let latestSession = null;
  601. let latestHour = -1;
  602. for (const session of data.sessions) {
  603. const hour = session.firstTimestamp.getHours();
  604. const nightHour = hour < 6 ? hour + 24 : hour;
  605. if (nightHour >= 22 && nightHour > latestHour) {
  606. latestHour = nightHour;
  607. latestSession = session;
  608. }
  609. }
  610. // Early bird (5am - 8am)
  611. const earlyHours = [5, 6, 7, 8];
  612. const earlySessions = earlyHours.reduce((sum, h) => sum + data.hourCounts[h], 0);
  613. let earliestSession = null;
  614. let earliestHour = 24;
  615. for (const session of data.sessions) {
  616. const hour = session.firstTimestamp.getHours();
  617. if (hour >= 5 && hour <= 8 && hour < earliestHour) {
  618. earliestHour = hour;
  619. earliestSession = session;
  620. }
  621. }
  622. // Weekend warrior
  623. const weekendSessions = data.weekdayCounts[6] + data.weekdayCounts[0];
  624. return {
  625. hourly: { peakHour, quietestHour },
  626. weekly: { busiestDay, quietestDay },
  627. nightOwl: {
  628. sessions: nightSessions,
  629. percentage: totalSessions > 0 ? Math.round((nightSessions / totalSessions) * 1000) / 10 : 0,
  630. latestTime: latestSession ? formatHour(latestSession.firstTimestamp.getHours()) : null,
  631. },
  632. earlyBird: {
  633. sessions: earlySessions,
  634. percentage: totalSessions > 0 ? Math.round((earlySessions / totalSessions) * 1000) / 10 : 0,
  635. earliestTime: earliestSession ? formatHour(earliestSession.firstTimestamp.getHours()) : null,
  636. },
  637. weekendWarrior: {
  638. percentage: totalSessions > 0 ? Math.round((weekendSessions / totalSessions) * 1000) / 10 : 0,
  639. saturdaySessions: data.weekdayCounts[6],
  640. sundaySessions: data.weekdayCounts[0],
  641. },
  642. };
  643. }
  644. // =============================================================================
  645. // ACHIEVEMENTS CALCULATIONS
  646. // =============================================================================
  647. function calculateAchievements(data) {
  648. // Streaks
  649. const dates = Object.keys(data.daySessions).sort();
  650. let longestStreak = { days: 0, start: null, end: null };
  651. let currentStreak = { days: 0, start: null };
  652. if (dates.length > 0) {
  653. let tempStreak = { days: 1, start: dates[0], end: dates[0] };
  654. for (let i = 1; i < dates.length; i++) {
  655. const prevDate = new Date(dates[i - 1]);
  656. const currDate = new Date(dates[i]);
  657. const diffDays = (currDate - prevDate) / 86400000;
  658. if (diffDays === 1) {
  659. tempStreak.days++;
  660. tempStreak.end = dates[i];
  661. } else {
  662. if (tempStreak.days > longestStreak.days) {
  663. longestStreak = { ...tempStreak };
  664. }
  665. tempStreak = { days: 1, start: dates[i], end: dates[i] };
  666. }
  667. }
  668. if (tempStreak.days > longestStreak.days) {
  669. longestStreak = { ...tempStreak };
  670. }
  671. // Current streak
  672. const today = new Date();
  673. today.setHours(0, 0, 0, 0);
  674. const lastActiveDate = new Date(dates[dates.length - 1]);
  675. const daysSinceLastActive = Math.floor((today - lastActiveDate) / 86400000);
  676. if (daysSinceLastActive <= 1) {
  677. currentStreak.days = 1;
  678. currentStreak.start = dates[dates.length - 1];
  679. for (let i = dates.length - 2; i >= 0; i--) {
  680. const currDate = new Date(dates[i + 1]);
  681. const prevDate = new Date(dates[i]);
  682. if ((currDate - prevDate) / 86400000 === 1) {
  683. currentStreak.days++;
  684. currentStreak.start = dates[i];
  685. } else {
  686. break;
  687. }
  688. }
  689. }
  690. }
  691. // Marathon days (5+ sessions)
  692. const marathonDays = Object.entries(data.daySessions)
  693. .filter(([, count]) => count >= 5)
  694. .sort((a, b) => b[1] - a[1]);
  695. // Busiest week
  696. let busiestWeek = null;
  697. const weeks = Object.entries(data.weekSessions);
  698. if (weeks.length > 0) {
  699. const busiest = weeks.reduce((max, [week, stats]) =>
  700. stats.sessions > (max[1]?.sessions || 0) ? [week, stats] : max, [null, { sessions: 0 }]);
  701. if (busiest[0]) {
  702. busiestWeek = {
  703. week: busiest[0],
  704. dates: getWeekDateRange(busiest[0]),
  705. sessions: busiest[1].sessions,
  706. messages: busiest[1].messages,
  707. };
  708. }
  709. }
  710. return {
  711. longestStreak: longestStreak.days > 0 ? longestStreak : null,
  712. currentStreak: currentStreak.days > 0 ? currentStreak : null,
  713. totalActiveDays: dates.length,
  714. marathonDays: {
  715. count: marathonDays.length,
  716. maxSessions: marathonDays.length > 0 ? marathonDays[0][1] : 0,
  717. },
  718. busiestWeek,
  719. };
  720. }
  721. // =============================================================================
  722. // SUPERLATIVES
  723. // =============================================================================
  724. function decodeProjectName(encodedName) {
  725. const decoded = folderNameToPath(encodedName);
  726. return path.basename(decoded);
  727. }
  728. function calculateSuperlatives(data) {
  729. if (data.sessions.length === 0) {
  730. return {
  731. longestSession: null,
  732. firstSession: null,
  733. lastSession: null,
  734. };
  735. }
  736. const sortedByMessages = [...data.sessions].sort((a, b) => b.messages - a.messages);
  737. const sortedByTime = [...data.sessions].sort((a, b) => a.firstTimestamp - b.firstTimestamp);
  738. const longest = sortedByMessages[0];
  739. const first = sortedByTime[0];
  740. const last = sortedByTime[sortedByTime.length - 1];
  741. return {
  742. longestSession: {
  743. messages: longest.messages,
  744. date: formatDate(longest.firstTimestamp),
  745. project: decodeProjectName(longest.project),
  746. },
  747. firstSession: {
  748. date: formatDate(first.firstTimestamp),
  749. project: decodeProjectName(first.project),
  750. },
  751. lastSession: {
  752. date: formatDate(last.firstTimestamp),
  753. project: decodeProjectName(last.project),
  754. },
  755. };
  756. }
  757. // =============================================================================
  758. // MARKDOWN GENERATION
  759. // =============================================================================
  760. async function getDetailedRepoData(repos, author) {
  761. // Run git log on all repos in parallel
  762. const results = await Promise.all(
  763. repos.map(async (repoPath) => {
  764. const repoName = path.basename(repoPath);
  765. // Get recent commits with Claude co-authoring
  766. const gitArgs = [
  767. '-C', repoPath,
  768. 'log', '--all',
  769. '--grep=Co-Authored-By: Claude',
  770. '--format=%H|%ai|%s',
  771. '-n', '10', // Last 10 commits
  772. ];
  773. if (author) {
  774. gitArgs.push(`--author=${author}`);
  775. }
  776. try {
  777. const { stdout } = await execFileAsync('git', gitArgs, { maxBuffer: 10 * 1024 * 1024 });
  778. const lines = stdout.trim().split('\n').filter(line => line);
  779. const commits = lines.map(line => {
  780. const [hash, dateStr, ...subjectParts] = line.split('|');
  781. return {
  782. hash: hash.slice(0, 7),
  783. date: dateStr,
  784. subject: subjectParts.join('|'),
  785. };
  786. });
  787. if (commits.length > 0) {
  788. return [repoName, { path: repoPath, commits }];
  789. }
  790. } catch {
  791. // Skip repos with errors
  792. }
  793. return null;
  794. })
  795. );
  796. return Object.fromEntries(results.filter(Boolean));
  797. }
  798. function generateMarkdownReport(repoData, projectMessages, user) {
  799. const lines = [];
  800. lines.push('# Claude Code Activity Report');
  801. lines.push('');
  802. lines.push(`Generated: ${new Date().toISOString()}`);
  803. if (user.name) {
  804. lines.push(`User: ${user.name} <${user.email || 'no email'}>`);
  805. }
  806. lines.push('');
  807. // Repos and commits section
  808. lines.push('## Repositories with Claude Commits');
  809. lines.push('');
  810. const repoNames = Object.keys(repoData).sort();
  811. if (repoNames.length === 0) {
  812. lines.push('*No repositories found with Claude co-authored commits.*');
  813. lines.push('');
  814. } else {
  815. for (const repoName of repoNames) {
  816. const repo = repoData[repoName];
  817. lines.push(`### ${repoName}`);
  818. lines.push('');
  819. lines.push(`**Path:** \`${repo.path}\``);
  820. lines.push('');
  821. lines.push('**Recent commits with Claude:**');
  822. lines.push('');
  823. for (const commit of repo.commits) {
  824. lines.push(`- \`${commit.hash}\` (${commit.date.split(' ')[0]}): ${commit.subject}`);
  825. }
  826. lines.push('');
  827. }
  828. }
  829. // Session messages section
  830. lines.push('## Recent Session Messages by Project');
  831. lines.push('');
  832. const projectNames = Object.keys(projectMessages).sort();
  833. if (projectNames.length === 0) {
  834. lines.push('*No recent session messages found.*');
  835. lines.push('');
  836. } else {
  837. for (const projectName of projectNames) {
  838. const project = projectMessages[projectName];
  839. lines.push(`### ${projectName}`);
  840. lines.push('');
  841. lines.push(`**Last active:** ${project.lastActive.toISOString().split('T')[0]}`);
  842. lines.push('');
  843. lines.push('**Recent user messages:**');
  844. lines.push('');
  845. for (const msg of project.messages) {
  846. const dateStr = msg.timestamp ? msg.timestamp.toISOString().split('T')[0] : 'unknown';
  847. const truncatedText = msg.text.length > 200
  848. ? msg.text.slice(0, 200) + '…'
  849. : msg.text;
  850. // Escape any markdown-sensitive characters and format as blockquote
  851. const escapedText = truncatedText.replace(/\n/g, ' ').replace(/>/g, '\\>');
  852. lines.push(`- (${dateStr}) "${escapedText}"`);
  853. }
  854. lines.push('');
  855. }
  856. }
  857. return lines.join('\n');
  858. }
  859. async function writeMarkdownReport(outputPath) {
  860. const user = getGitUser();
  861. // Discover repos
  862. let repos = discoverReposFromProjects();
  863. if (repos.length === 0) {
  864. const defaultPath = path.join(os.homedir(), 'code');
  865. if (fs.existsSync(defaultPath)) {
  866. repos = findGitReposInPath(defaultPath);
  867. }
  868. }
  869. // Get detailed repo data
  870. const repoData = await getDetailedRepoData(repos, user.name);
  871. // Get recent messages per project
  872. const projectMessages = getRecentMessagesPerProject();
  873. // Generate markdown
  874. const markdown = generateMarkdownReport(repoData, projectMessages, user);
  875. // Determine output path - default to skill folder
  876. const scriptDir = path.dirname(new URL(import.meta.url).pathname);
  877. const finalPath = outputPath || path.join(scriptDir, 'activity-report.md');
  878. // Ensure directory exists
  879. const dir = path.dirname(finalPath);
  880. if (!fs.existsSync(dir)) {
  881. fs.mkdirSync(dir, { recursive: true });
  882. }
  883. // Write file
  884. fs.writeFileSync(finalPath, markdown, 'utf-8');
  885. return finalPath;
  886. }
  887. // =============================================================================
  888. // MAIN FUNCTIONS
  889. // =============================================================================
  890. async function getGitStats() {
  891. const user = getGitUser();
  892. // Try to discover repos from Claude projects first (faster)
  893. let repos = discoverReposFromProjects();
  894. // Fallback to filesystem scan if no repos found
  895. if (repos.length === 0) {
  896. const defaultPath = path.join(os.homedir(), 'code');
  897. if (fs.existsSync(defaultPath)) {
  898. repos = findGitReposInPath(defaultPath);
  899. }
  900. }
  901. // Fetch commits from all repos in parallel
  902. const commitArrays = await Promise.all(
  903. repos.map(repo => getClaudeCommitsFromRepo(repo, user.name))
  904. );
  905. const allCommits = commitArrays.flat();
  906. const stats = aggregateGitStats(allCommits);
  907. return {
  908. ...stats,
  909. reposScanned: repos.length,
  910. };
  911. }
  912. function getSessionStats() {
  913. const sessionFiles = findSessionFiles();
  914. const data = aggregateSessionStats(sessionFiles);
  915. return {
  916. totalSessions: data.sessions.length,
  917. totalProjects: Object.keys(data.projectStats).length,
  918. userMessages: data.totalUserMessages,
  919. assistantMessages: data.totalAssistantMessages,
  920. dateRange: {
  921. first: data.firstTimestamp ? formatDate(data.firstTimestamp) : null,
  922. last: data.lastTimestamp ? formatDate(data.lastTimestamp) : null,
  923. },
  924. topProjects: data.topProjects,
  925. patterns: calculatePatterns(data),
  926. achievements: calculateAchievements(data),
  927. superlatives: calculateSuperlatives(data),
  928. sessionFilesProcessed: sessionFiles.length,
  929. };
  930. }
  931. async function getAllStats() {
  932. const user = getGitUser();
  933. const gitStats = await getGitStats();
  934. const sessionStats = getSessionStats();
  935. return {
  936. user,
  937. git: gitStats,
  938. sessions: {
  939. totalSessions: sessionStats.totalSessions,
  940. totalProjects: sessionStats.totalProjects,
  941. userMessages: sessionStats.userMessages,
  942. assistantMessages: sessionStats.assistantMessages,
  943. dateRange: sessionStats.dateRange,
  944. topProjects: sessionStats.topProjects,
  945. },
  946. patterns: sessionStats.patterns,
  947. achievements: sessionStats.achievements,
  948. superlatives: sessionStats.superlatives,
  949. meta: {
  950. generatedAt: new Date().toISOString(),
  951. gitReposScanned: gitStats.reposScanned,
  952. sessionFilesProcessed: sessionStats.sessionFilesProcessed,
  953. },
  954. };
  955. }
  956. // =============================================================================
  957. // CLI
  958. // =============================================================================
  959. function showHelp() {
  960. console.log(`
  961. Unified statistics extraction for Thinkback
  962. Consolidates git commits, session data, and time patterns into a single
  963. narrative-friendly JSON output. Works on any user's machine.
  964. Usage:
  965. node get_all_stats.js # Full output as JSON
  966. node get_all_stats.js --git-only # Just git stats (long-term)
  967. node get_all_stats.js --recent-only # Just session stats (recent ~30 days)
  968. node get_all_stats.js --markdown # Also write markdown report to skill folder
  969. node get_all_stats.js --markdown-path=/path/to/output.md # Write markdown to custom path
  970. node get_all_stats.js --help
  971. Output includes:
  972. - User identity (git config)
  973. - Git stats: commits with Claude, repos, time patterns
  974. - Session stats: messages, projects (recent ~30 days)
  975. - Time patterns: night owl, early bird, weekend warrior
  976. - Achievements: streaks, marathon days, busiest week
  977. - Superlatives: longest session, first/last session
  978. Markdown report includes:
  979. - Every repo with Claude co-authored commits
  980. - Recent commits per repo (up to 10)
  981. - Recent user messages per project (up to 5)
  982. `);
  983. }
  984. async function main() {
  985. const args = parseArgs(process.argv);
  986. if (args.help) {
  987. showHelp();
  988. process.exit(0);
  989. }
  990. let output;
  991. switch (args.mode) {
  992. case 'git':
  993. output = { user: getGitUser(), git: await getGitStats() };
  994. break;
  995. case 'recent':
  996. output = { user: getGitUser(), ...getSessionStats() };
  997. break;
  998. case 'all':
  999. default:
  1000. output = await getAllStats();
  1001. break;
  1002. }
  1003. console.log(JSON.stringify(output, null, 2));
  1004. // Generate markdown report if requested
  1005. if (args.markdown) {
  1006. const reportPath = await writeMarkdownReport(args.markdownPath);
  1007. console.error(`\nMarkdown report written to: ${reportPath}`);
  1008. }
  1009. }
  1010. // Run CLI if executed directly
  1011. const isMain = process.argv[1]?.endsWith('get_all_stats.js');
  1012. if (isMain) {
  1013. main().catch(err => {
  1014. console.error('Error:', err.message);
  1015. process.exit(1);
  1016. });
  1017. }
  1018. // =============================================================================
  1019. // EXPORTS
  1020. // =============================================================================
  1021. export { getAllStats, getGitStats, getSessionStats, getGitUser, writeMarkdownReport };