usehour 0.4.0 → 0.4.1

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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/hour.mjs +80 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Your year of building with AI, as Wrapped-style cards.
4
4
 
5
5
  ```
6
- npx usehour@0.4.0
6
+ npx usehour@0.4.1
7
7
  ```
8
8
 
9
9
  - Asks before it reads anything, and lists the folders it will read.
@@ -11,7 +11,7 @@ npx usehour@0.4.0
11
11
  - Counts your habits, and finds a few of your best prompts (anything that looks like a key, password, email or home folder already hidden). You can pick some for your profile and black out words, but not add or change any.
12
12
  - Opens your cards in your browser. Save any card as an image to post it.
13
13
  - Optional: claim usehour.com/@you from the cards page. You see exactly what is sent first. The script itself never connects to the internet.
14
- - Counts only sessions you started yourself: sub-agents and runs started by scripts or SDKs are left out. `npx usehour@0.4.0 --explain` shows what was counted and what was left out, in your terminal only.
14
+ - Counts only sessions you started yourself: sub-agents and runs started by scripts or SDKs are left out. `npx usehour@0.4.1 --explain` shows what was counted and what was left out, in your terminal only.
15
15
  - No dependencies, nothing installed.
16
16
 
17
17
  It's one file: read [hour.mjs](https://usehour.com/hour.mjs) before you run it.
package/hour.mjs CHANGED
@@ -31,7 +31,7 @@ import { spawn } from "node:child_process";
31
31
  import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
32
32
  import { fileURLToPath } from "node:url";
33
33
 
34
- const VERSION = "0.4.0";
34
+ const VERSION = "0.4.1";
35
35
  const API_DEFAULT = "https://usehour.com/api";
36
36
 
37
37
  /* ------------------------------------------------------------------ patterns */
@@ -1017,7 +1017,36 @@ function explain(summaries) {
1017
1017
  }
1018
1018
 
1019
1019
  /* ------------------------------------------------------------------ the run */
1020
- const B = (s) => `\x1b[1m${s}\x1b[0m`, DIM = (s) => `\x1b[2m${s}\x1b[0m`;
1020
+ /* Terminal look. Colour only on a real terminal, never with NO_COLOR or
1021
+ TERM=dumb. 24-bit colour where the terminal says it has it, otherwise the
1022
+ nearest of the 256 standard colours. No packages: plain escape codes. */
1023
+ const COLOR = !!process.stdout.isTTY && !("NO_COLOR" in process.env) && process.env.TERM !== "dumb";
1024
+ const TRUECOLOR = /truecolor|24bit/i.test(process.env.COLORTERM || "") || /iTerm|WezTerm|vscode|ghostty|Warp/i.test(process.env.TERM_PROGRAM || "");
1025
+ const E = (c) => (COLOR ? `\x1b[${c}m` : "");
1026
+ const B = (s) => E(1) + s + E(0), DIM = (s) => E(2) + s + E(0);
1027
+ const c256 = ([r, g, b]) => { const q = (v) => (v < 48 ? 0 : v < 115 ? 1 : Math.min(5, Math.round((v - 35) / 40))); return 16 + 36 * q(r) + 6 * q(g) + q(b); };
1028
+ const FG = (rgb) => E(TRUECOLOR ? `38;2;${rgb.join(";")}` : `38;5;${c256(rgb)}`), BG = (rgb) => E(TRUECOLOR ? `48;2;${rgb.join(";")}` : `48;5;${c256(rgb)}`);
1029
+ const HUE = { lav: [176, 172, 254], green: [95, 211, 141], blue: [79, 136, 255], purple: [139, 95, 217], orange: [242, 96, 31], mustard: [219, 160, 4], pink: [251, 101, 194] };
1030
+ const tint = (h, s) => FG(HUE[h]) + s + E(0);
1031
+ const GSTOP = [[0, [79, 136, 255]], [0.38, [176, 172, 254]], [0.72, [251, 101, 194]], [1, [253, 41, 128]]];
1032
+ const gcol = (t) => { t = Math.max(0, Math.min(1, t)); let i = 0; while (i < GSTOP.length - 2 && t > GSTOP[i + 1][0]) i++; const [a, x] = GSTOP[i], [b, y] = GSTOP[i + 1], f = (t - a) / (b - a); return x.map((v, k) => Math.round(v + (y[k] - v) * f)); };
1033
+ const grad = (s, bold) => (COLOR ? [...s].map((ch, i, a) => (ch === " " ? " " : FG(gcol(i / Math.max(1, a.length - 1))) + (bold ? E(1) : "") + ch)).join("") + E(0) : s);
1034
+ const KEY = (s) => (COLOR ? E(7) + " " + s + " " + E(27) : "[" + s + "]");
1035
+ /* The Hour ring as a dot matrix, like the cards: bright dots in the logo's
1036
+ gradient on a faint grid. Plain characters, so every terminal shows it. */
1037
+ function markLines(n = 7, R = 2.8) {
1038
+ const c0 = (n - 1) / 2, out = [];
1039
+ for (let r = 0; r < n; r++) {
1040
+ let l = "";
1041
+ for (let c = 0; c < n; c++) {
1042
+ const d = Math.hypot(c - c0, r - c0);
1043
+ l += (Math.abs(d - R) < 0.5 ? FG(gcol((c + r) / (2 * (n - 1)))) + "●" + E(0) : d < R + 1.1 ? DIM("·") : " ") + (c < n - 1 ? " " : "");
1044
+ }
1045
+ out.push(l);
1046
+ }
1047
+ return out;
1048
+ }
1049
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1021
1050
  /* One reader for the whole run, so answers typed ahead are never lost. */
1022
1051
  let RL = null, LINES = [], WAIT = null;
1023
1052
  function ask(q) {
@@ -1031,7 +1060,7 @@ function ask(q) {
1031
1060
  return new Promise((res) => { WAIT = res; });
1032
1061
  }
1033
1062
  function done() { if (RL) RL.close(); }
1034
- const yes = (a) => /^y(es)?$/i.test(a);
1063
+ const yes = (a) => !/^n(o)?$/i.test(a); // Enter means yes: running the command was the choice
1035
1064
  function openFile(f) {
1036
1065
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
1037
1066
  const args = process.platform === "win32" ? ["/c", "start", "", f] : [f];
@@ -1050,25 +1079,45 @@ async function main() {
1050
1079
  ].map((s) => ({ ...s, files: walk(s.dir) }));
1051
1080
  const found = sources.filter((s) => s.files.length);
1052
1081
  const share = !flag("--no-share");
1053
- console.log(`\n ${B("Hour Wrapped")} ${DIM("v" + VERSION)} Your year of building with AI.\n`);
1082
+ if (COLOR) {
1083
+ const mk = markLines(), mid = Math.floor(mk.length / 2);
1084
+ console.log("");
1085
+ mk.forEach((l, i) => console.log(" " + l + " " + (i === mid - 1 ? `${B("Hour Wrapped")} ${DIM("v" + VERSION)}` : i === mid ? "Your year of building with AI." : "")));
1086
+ console.log("");
1087
+ } else console.log(`\n Hour Wrapped v${VERSION} Your year of building with AI.\n`);
1054
1088
  if (!found.length) {
1055
1089
  console.log(` No Claude Code or Codex sessions found.\n Looked in:\n${sources.map((s) => " " + s.dir).join("\n")}\n\n If yours live somewhere else, run with --claude-dir or --codex-dir.\n`);
1056
1090
  process.exitCode = 1; return;
1057
1091
  }
1058
1092
  const bytes = found.reduce((a, s) => a + s.files.reduce((b, f) => { try { return b + fs.statSync(f).size; } catch { return b; } }, 0), 0);
1059
- console.log(" This reads your session logs, on this machine only:");
1060
- for (const s of found) console.log(` ${s.label.padEnd(12)} ${s.files.length} files ${DIM(s.dir)}`);
1061
- console.log(`\n ${B("Private by design.")} It keeps counts and labels only. No prompt you wrote,`);
1062
- console.log(` no code and no file contents leave this computer, and this script never`);
1063
- console.log(` connects to the internet. (Try it with Wi-Fi off: it still works.)\n`);
1093
+ const here = process.platform === "darwin" ? "this Mac" : "this computer";
1094
+ const tick = (t) => " " + tint("green", "✓") + " " + t;
1095
+ console.log(` ${tint("lav", "●")} ${B("Offline.")} ${tint("lav", "Nothing leaves this computer.")}`);
1096
+ console.log(tick(`Reads your logs here, on ${here}`));
1097
+ console.log(tick("Keeps counts and labels only. No prompts, code or files are sent"));
1098
+ console.log(tick(`No internet needed. ${DIM("Try it with Wi-Fi off.")}`));
1099
+ console.log(`\n ${DIM("Found")}`);
1100
+ const tilde = (p) => (p.startsWith(home) ? "~" + p.slice(home.length) : p);
1101
+ const wFiles = Math.max(...found.map((s) => String(s.files.length).length));
1102
+ for (const s of found) console.log(` ${s.label.padEnd(13)} ${B(String(s.files.length).padStart(wFiles))} ${s.files.length === 1 ? "file " : "files"} ${DIM(tilde(s.dir))}`);
1103
+ console.log("");
1064
1104
  if (!flag("--yes")) {
1065
1105
  if (!process.stdin.isTTY) { console.log(" Run this in a terminal (or add --yes)."); process.exitCode = 1; return; }
1066
- const a = await ask(` Read ${(bytes / 1e9).toFixed(1)} GB of logs and make your cards? [y/N] `);
1106
+ const size = bytes >= 1e9 ? (bytes / 1e9).toFixed(1) + " GB" : Math.max(1, Math.round(bytes / 1e6)) + " MB";
1107
+ const a = await ask(` Read ${B(size)} of logs and make your cards? ${COLOR ? `${KEY("Enter")} yes ${DIM("·")} ${KEY("n")} no` : "[Y/n]"} `);
1067
1108
  if (!yes(a)) { console.log("\n Stopped. Nothing was read.\n"); return; }
1068
1109
  }
1069
1110
  const jobs = found.flatMap((s) => s.files.map((file) => ({ file, src: s.src, size: fs.statSync(file).size }))).sort((a, b) => b.size - a.size);
1070
1111
  const summaries = []; let done = 0, doneBytes = 0; const t0 = Date.now();
1071
- const tick = () => { if (process.stdout.isTTY) process.stdout.write(`\r Reading ${done}/${jobs.length} files ${Math.round(doneBytes / Math.max(1, bytes) * 100)}% `); };
1112
+ const TTY = !!process.stdout.isTTY, BAR = Math.max(10, Math.min(24, (process.stdout.columns || 80) - 50)), nf = (n) => n.toLocaleString("en");
1113
+ let found_p = 0, last = 0;
1114
+ const bar = (p) => { const k = Math.round(p * BAR); let o = ""; for (let i = 0; i < BAR; i++) o += i < k ? (COLOR ? FG(gcol(i / (BAR - 1))) + "●" : "●") : (COLOR ? E(2) + "·" : "·"); return o + E(0); };
1115
+ const progress = (force) => {
1116
+ if (!TTY) return; const now = Date.now(); if (!force && now - last < 60) return; last = now;
1117
+ const p = doneBytes / Math.max(1, bytes);
1118
+ process.stdout.write(`\r\x1b[K ${bar(p)} ${B(String(Math.round(p * 100)).padStart(3) + "%")} ${DIM(`${nf(done)}/${nf(jobs.length)} files ·`)} ${B(nf(found_p))} ${DIM("prompts")}`);
1119
+ };
1120
+ if (TTY) console.log(`\n Reading your logs ${DIM("· on this computer, offline")}\n`);
1072
1121
  const nWorkers = Math.max(1, Math.min(4, (os.availableParallelism ? os.availableParallelism() : os.cpus().length) - 1));
1073
1122
  await new Promise((resolve) => {
1074
1123
  let next = 0, active = 0;
@@ -1077,13 +1126,13 @@ async function main() {
1077
1126
  const feed = (w) => {
1078
1127
  if (next >= jobs.length) { w.terminate(); if (--active === 0) resolve(); return; }
1079
1128
  const job = jobs[next++];
1080
- w.once("message", (r) => { if (r.ok) summaries.push(r.s); done++; doneBytes += job.size; tick(); feed(w); });
1129
+ w.once("message", (r) => { if (r.ok) { summaries.push(r.s); if (!r.s.sub && r.s.how !== "script") found_p += r.s.prompts.length; } done++; doneBytes += job.size; progress(); feed(w); });
1081
1130
  w.postMessage({ file: job.file, src: job.src, examples: false });
1082
1131
  };
1083
1132
  workers.forEach((w) => { active++; feed(w); });
1084
1133
  });
1085
- if (process.stdout.isTTY) process.stdout.write("\r" + " ".repeat(60) + "\r");
1086
- console.log(` Read ${jobs.length} files in ${Math.round((Date.now() - t0) / 1000)}s.`);
1134
+ if (TTY) { doneBytes = bytes; progress(true); process.stdout.write("\n"); }
1135
+ console.log(` ${tint("green", "✓")} Read ${nf(jobs.length)} files in ${Math.round((Date.now() - t0) / 1000)}s. ${DIM("Nothing was sent.")}`);
1087
1136
  if (flag("--explain")) { explain(summaries); return; }
1088
1137
  const m = compute(summaries);
1089
1138
  if (!m) { console.log("\n Found the logs but no prompts in them. Nothing to make cards from.\n"); return; }
@@ -1097,13 +1146,24 @@ async function main() {
1097
1146
  const examples = [];
1098
1147
  const suggest = (() => { try { const u = os.userInfo().username.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "").slice(0, 24); return ["root", "admin", "user", "ubuntu", "runner", "administrator", "guest", "owner"].includes(u) ? "" : u; } catch { return ""; } })();
1099
1148
  fs.writeFileSync(page, pageHTML(m, C, { api: share ? (opt("--api") || API_DEFAULT).replace(/\/+$/, "") : null, examples, suggest: suggest.length >= 3 ? suggest : "" }));
1100
- const type = TYPES[builderType(m)].name;
1101
- console.log(`\n ${B("You're " + type + ".")} ${m.sessions.total} sessions, ${m.prompts.total.toLocaleString("en")} prompts, busiest at ${hourLabel(m.busiestHour)}.`);
1102
- console.log(` Your cards: ${page}\n`);
1103
- if (!flag("--no-open")) openFile(page);
1149
+ const T = TYPES[builderType(m)], sc = hourScore(m), nf2 = (n) => Number(n).toLocaleString("en");
1150
+ const facts = [["blue", nf2(m.sessions.total), "sessions", found.length > 1 ? "in Claude Code and Codex" : "in " + found[0].label]];
1151
+ facts.push(["purple", nf2(m.prompts.total), "prompts", `busiest at ${hourLabel(m.busiestHour)}`]);
1152
+ if (m.parallelMax > 1 && m.parallelMax <= MAX_AT_ONCE) facts.push(["orange", String(m.parallelMax), "agents at once", m.atOnceAt ? "at " + atOnceWhen(m.atOnceAt) : ""]);
1153
+ if (m.shipped && m.shipped.commits) facts.push(["mustard", nf2(m.shipped.commits), "commits", m.shipped.prs ? `and ${nf2(m.shipped.prs)} pull request${m.shipped.prs > 1 ? "s" : ""}, made by your agents` : "made by your agents"]);
1154
+ const w = Math.max(...facts.map(([, n, l]) => n.length + 1 + l.length)) + 3;
1155
+ console.log("");
1156
+ for (const [h, n, l, d] of facts) { console.log(` ${tint(h, "●")} ${B(n)} ${l}${" ".repeat(w - n.length - 1 - l.length)}${DIM(d)}`); if (TTY) await sleep(140); }
1157
+ console.log(`\n You're ${grad(T.name + ".", true)}`);
1158
+ const first = String(T.line || "").split(/(?<=\.)\s/)[0];
1159
+ console.log(` ${DIM(first + (sc ? " Hour score" : ""))}${sc ? " " + B(String(sc.total)) + DIM(".") : ""}`);
1160
+ const open = !flag("--no-open");
1161
+ console.log(`\n ${B((open ? "→ Opening your " : "Your ") + C.length + " cards" + (open ? " in the browser" : " are ready"))}`);
1162
+ console.log(` ${DIM(page.startsWith(home) ? "~" + page.slice(home.length) : page)}\n`);
1163
+ if (open) openFile(page);
1104
1164
  if (share) {
1105
- console.log(` Want a profile? The last card lets you claim your handle at usehour.com/@you.`);
1106
- console.log(` Nothing is sent unless you press the buttons there. You see exactly what goes first.`);
1165
+ console.log(` ${DIM("Want a profile? The last card lets you claim")} usehour.com/@you${DIM(".")}`);
1166
+ console.log(` ${DIM("Nothing is sent unless you press the buttons there. You see exactly what goes first.")}`);
1107
1167
  if (examples.length) console.log(DIM(` (The cards page holds ${examples.length} of your prompts so you can choose. It stays on this computer.)`));
1108
1168
  console.log("");
1109
1169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usehour",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Your year of building with AI, as Wrapped-style cards, and an optional private profile. Reads your Claude Code and Codex logs locally.",
5
5
  "type": "module",
6
6
  "bin": { "usehour": "hour.mjs" },