embertime 0.1.3 → 0.1.5

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "ember",
3
3
  "description": "Ember coding-time tracker: records Claude Code sessions, prompts and file edits as anonymised Ember heartbeats (no prompt text, file contents or paths are sent).",
4
- "version": "0.1.3"
4
+ "version": "0.1.4"
5
5
  }
package/cli.mjs CHANGED
@@ -193,6 +193,7 @@ function uninstall(o) {
193
193
  const HELP = `embertime: track coding time across editors and AI agents
194
194
 
195
195
  npx embertime setup --key <ember_key> [--api-url <url>] [--vsix <file>] [--skip-editors]
196
+ npx embertime setup re-run with the key you already saved
196
197
  npx embertime status
197
198
  npx embertime uninstall [--purge]
198
199
 
package/ember-hook.mjs CHANGED
@@ -9,19 +9,22 @@
9
9
  // * never writes to stdout for Claude Code / Codex (they may interpret it); for
10
10
  // Cursor it prints the minimal JSON reply Cursor expects. Always exits 0
11
11
  // * never sends file contents, raw paths or prompt text: files are reduced
12
- // to sha1(absolute path) and the project to the git root's basename
12
+ // to an HMAC of the absolute path, keyed with a random per-machine secret
13
+ // (~/.ember/entity.key), and the project to the git root's basename
13
14
  // * heartbeats are appended to ~/.ember/queue.jsonl before any network I/O,
14
15
  // so nothing is lost when the server is down
15
16
  //
16
- // Environment overrides: EMBER_HOME (default ~/.ember), EMBER_API_URL, EMBER_API_KEY.
17
+ // Environment overrides: EMBER_HOME (default ~/.ember), EMBER_API_URL, EMBER_API_KEY,
18
+ // EMBER_SEND_BRANCH=0 (don't send branch names).
17
19
 
18
20
  import fs from 'node:fs';
19
21
  import os from 'node:os';
20
22
  import path from 'node:path';
21
23
  import crypto from 'node:crypto';
24
+ import { execFileSync } from 'node:child_process';
22
25
  import { fileURLToPath } from 'node:url';
23
26
 
24
- export const VERSION = '0.1.3';
27
+ export const VERSION = '0.1.5';
25
28
  export const AGENTS = ['claude-code', 'codex', 'cursor-agent'];
26
29
 
27
30
  /** Events whose heartbeats are throttled: they fire on every tool call or file edit. */
@@ -59,6 +62,7 @@ function paths(home) {
59
62
  state: path.join(home, 'hook-state.json'),
60
63
  log: path.join(home, 'hook.log'),
61
64
  lastError: path.join(home, 'last_error'),
65
+ entityKey: path.join(home, 'entity.key'),
62
66
  };
63
67
  }
64
68
 
@@ -70,12 +74,16 @@ function readJson(file, fallback) {
70
74
  }
71
75
  }
72
76
 
73
- /** Resolve API URL + key: env overrides win over ~/.ember/config.json. */
77
+ /**
78
+ * Resolve API URL + key: env overrides win over ~/.ember/config.json.
79
+ * `send_branch: false` (or EMBER_SEND_BRANCH=0) keeps branch names on this machine.
80
+ */
74
81
  export function loadConfig(home, env = process.env) {
75
82
  const cfg = readJson(paths(home).config, {}) || {};
76
83
  const apiUrl = String(env.EMBER_API_URL || cfg.api_url || DEFAULT_API_URL).replace(/\/+$/, '');
77
84
  const apiKey = env.EMBER_API_KEY || cfg.api_key || '';
78
- return { apiUrl, apiKey };
85
+ const sendBranch = env.EMBER_SEND_BRANCH !== undefined ? env.EMBER_SEND_BRANCH !== '0' : cfg.send_branch !== false;
86
+ return { apiUrl, apiKey, sendBranch };
79
87
  }
80
88
 
81
89
  function writeAtomic(file, data) {
@@ -361,6 +369,56 @@ export function isWriteTool(toolName) {
361
369
  return typeof toolName === 'string' && WRITE_TOOLS.has(toolName.toLowerCase());
362
370
  }
363
371
 
372
+ /** Shell tools (Claude Code's Bash, Codex's shell). They can write files without naming them. */
373
+ const SHELL_TOOLS = new Set(['bash', 'shell', 'local_shell', 'exec_command']);
374
+
375
+ export function isShellTool(toolName) {
376
+ return typeof toolName === 'string' && SHELL_TOOLS.has(toolName.toLowerCase());
377
+ }
378
+
379
+ const SHELL_LOOKBACK_S = 600; // never credit a shell command with edits older than this
380
+ const SHELL_FIRST_LOOKBACK_S = 120; // when there is no earlier event to measure from
381
+ const GIT_TIMEOUT_MS = 1_500;
382
+ const MAX_CHANGED_FILES = 5_000;
383
+
384
+ /**
385
+ * The file a shell command most likely wrote: among files git reports as
386
+ * modified, or new and not ignored, under `root`, the one changed most recently
387
+ * after `sinceSec`. Ignored paths (node_modules, build output) never count, and
388
+ * `git pull` or `git checkout` leave nothing modified, so they don't either.
389
+ */
390
+ export function recentlyChangedFile(root, sinceSec) {
391
+ let out;
392
+ try {
393
+ out = execFileSync('git', ['-C', root, 'ls-files', '--modified', '--others', '--exclude-standard', '-z'], {
394
+ encoding: 'utf8',
395
+ timeout: GIT_TIMEOUT_MS,
396
+ maxBuffer: 8 * 1024 * 1024,
397
+ stdio: ['ignore', 'pipe', 'ignore'],
398
+ });
399
+ } catch {
400
+ return undefined; // not a git checkout, git missing, or too slow
401
+ }
402
+ let best;
403
+ let bestTime = sinceSec;
404
+ const names = out.split('\0');
405
+ for (let i = 0; i < names.length && i < MAX_CHANGED_FILES; i++) {
406
+ if (!names[i]) continue;
407
+ const abs = path.join(root, names[i]);
408
+ let mtime;
409
+ try {
410
+ mtime = fs.statSync(abs).mtimeMs / 1000;
411
+ } catch {
412
+ continue; // deleted
413
+ }
414
+ if (mtime > bestTime) {
415
+ best = abs;
416
+ bestTime = mtime;
417
+ }
418
+ }
419
+ return best;
420
+ }
421
+
364
422
  /** All file paths named in an apply_patch body ("*** Update File: path", Add/Delete/Move to). */
365
423
  export function parseApplyPatchPaths(text) {
366
424
  if (typeof text !== 'string') return [];
@@ -409,8 +467,57 @@ export function extractFilePath(toolInput, cwd) {
409
467
  return path.resolve(cwd || process.cwd(), p);
410
468
  }
411
469
 
412
- export function sha1(text) {
413
- return crypto.createHash('sha1').update(text).digest('hex');
470
+ /**
471
+ * File identity sent as `entity`: HMAC-SHA256 of the absolute path under the
472
+ * per-machine key, cut to the 40 hex characters the server accepts. A plain hash
473
+ * of a path could be reversed by guessing likely paths; without the key it can't.
474
+ */
475
+ export function entityHash(key, file) {
476
+ return crypto.createHmac('sha256', key).update(file).digest('hex').slice(0, 40);
477
+ }
478
+
479
+ const ENTITY_KEY_RE = /^[a-f0-9]{64}$/;
480
+
481
+ function readEntityKey(file) {
482
+ try {
483
+ const key = fs.readFileSync(file, 'utf8').trim();
484
+ return ENTITY_KEY_RE.test(key) ? key : undefined;
485
+ } catch {
486
+ return undefined;
487
+ }
488
+ }
489
+
490
+ /**
491
+ * The random key shared by every collector on this machine (mode 0600), created
492
+ * on first use. Concurrent first runs agree on one key: the first hard link wins.
493
+ * If it can't be stored, a one-off key is used: entities then differ between runs,
494
+ * which only weakens de-duplication, never privacy.
495
+ */
496
+ export function loadEntityKey(home) {
497
+ const file = paths(home).entityKey;
498
+ const existing = readEntityKey(file);
499
+ if (existing) return existing;
500
+ const key = crypto.randomBytes(32).toString('hex');
501
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
502
+ try {
503
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
504
+ fs.writeFileSync(tmp, `${key}\n`, { mode: 0o600 });
505
+ try {
506
+ fs.linkSync(tmp, file);
507
+ return key;
508
+ } catch (e) {
509
+ if (e.code !== 'EEXIST') throw e;
510
+ const winner = readEntityKey(file);
511
+ if (winner) return winner;
512
+ fs.renameSync(tmp, file); // existing file is corrupt: replace it
513
+ return key;
514
+ }
515
+ } catch (e) {
516
+ log(home, 'warn', `could not store entity key: ${e.message}`);
517
+ return key;
518
+ } finally {
519
+ fs.rmSync(tmp, { force: true });
520
+ }
414
521
  }
415
522
 
416
523
  export function parsePayload(text) {
@@ -423,15 +530,22 @@ export function parsePayload(text) {
423
530
  }
424
531
  }
425
532
 
533
+ /** Working directory of a hook payload. Claude Code and Codex send `cwd`; Cursor sends `workspace_roots` (and sets CURSOR_PROJECT_DIR). */
534
+ export function payloadCwd(payload, env = process.env) {
535
+ const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots.filter((r) => typeof r === 'string' && r) : [];
536
+ return (typeof payload.cwd === 'string' && payload.cwd) || roots[0] || env.CURSOR_PROJECT_DIR || process.cwd();
537
+ }
538
+
426
539
  /**
427
540
  * Build exactly one heartbeat from a hook payload. Only derived, non-sensitive
428
541
  * fields are copied: never prompt text, tool output, file contents or paths.
542
+ * `changedFile` is a file a shell command was found to have written.
429
543
  */
430
- export function buildHeartbeat(payload, { agent, now = Date.now() / 1000, env = process.env } = {}) {
431
- // Claude Code and Codex send `cwd`; Cursor sends `workspace_roots` (and sets CURSOR_PROJECT_DIR).
432
- const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots.filter((r) => typeof r === 'string' && r) : [];
433
- const cwd =
434
- (typeof payload.cwd === 'string' && payload.cwd) || roots[0] || env.CURSOR_PROJECT_DIR || process.cwd();
544
+ export function buildHeartbeat(
545
+ payload,
546
+ { agent, now = Date.now() / 1000, env = process.env, entityKey, sendBranch = true, changedFile } = {},
547
+ ) {
548
+ const cwd = payloadCwd(payload, env);
435
549
  const root = findGitRoot(cwd);
436
550
  const projectDir = root || path.resolve(cwd);
437
551
  const hb = {
@@ -439,18 +553,19 @@ export function buildHeartbeat(payload, { agent, now = Date.now() / 1000, env =
439
553
  project: path.basename(projectDir) || 'unknown',
440
554
  editor: agent,
441
555
  category: 'ai',
442
- is_write: isWriteTool(payload.tool_name) || payload.hook_event_name === 'afterFileEdit',
556
+ is_write: Boolean(changedFile) || isWriteTool(payload.tool_name) || payload.hook_event_name === 'afterFileEdit',
443
557
  };
444
- if (root) {
558
+ if (root && sendBranch) {
445
559
  const branch = readBranch(root);
446
560
  if (branch) hb.branch = branch;
447
561
  }
448
562
  // Cursor's afterFileEdit puts the edited file at the top level.
449
563
  const file =
564
+ changedFile ||
450
565
  extractFilePath(payload.tool_input, cwd) ||
451
566
  (typeof payload.file_path === 'string' && payload.file_path.trim() ? path.resolve(cwd, payload.file_path.trim()) : undefined);
452
567
  if (file) {
453
- hb.entity = sha1(file);
568
+ hb.entity = entityHash(entityKey ?? loadEntityKey(emberHome(env)), file);
454
569
  const language = languageForPath(file);
455
570
  if (language) hb.language = language;
456
571
  }
@@ -758,20 +873,39 @@ export async function runHook({ argv = [], env = process.env, stdinText = '' } =
758
873
  }
759
874
  const payload = parsePayload(stdinText);
760
875
  const event = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
761
- const hb = buildHeartbeat(payload, { agent, env });
762
- const { apiUrl, apiKey } = loadConfig(home, env);
876
+ const { apiUrl, apiKey, sendBranch } = loadConfig(home, env);
877
+ let hb = buildHeartbeat(payload, { agent, env, sendBranch });
878
+
879
+ // Every event records when this agent last acted in this project, so a shell
880
+ // command is only credited with files changed since the agent's previous step.
881
+ const state = readState(home);
882
+ const key = `${hb.editor}|${hb.project}`;
883
+ const previous = state.seen?.[key];
884
+ state.seen = { ...(state.seen || {}), [key]: hb.time };
885
+
886
+ if (!hb.is_write && event === 'PostToolUse' && isShellTool(payload.tool_name) && !shouldThrottle(state, hb, event)) {
887
+ const root = findGitRoot(payloadCwd(payload, env));
888
+ const since = typeof previous === 'number'
889
+ ? Math.max(previous, hb.time - SHELL_LOOKBACK_S)
890
+ : hb.time - SHELL_FIRST_LOOKBACK_S;
891
+ const changedFile = root && recentlyChangedFile(root, since);
892
+ if (changedFile) hb = buildHeartbeat(payload, { agent, env, sendBranch, changedFile, now: hb.time });
893
+ }
763
894
 
764
895
  // Only an agent changing a file counts as coding. Opening a session,
765
896
  // sending a prompt or reading files does not; those events just deliver
766
897
  // anything still queued.
767
898
  if (!hb.is_write) {
899
+ writeState(home, state);
768
900
  await flushQueue(home, { apiUrl, apiKey });
769
901
  return { status: 'not-writing' };
770
902
  }
771
903
 
772
- const state = readState(home);
773
- if (shouldThrottle(state, hb, event)) return { status: 'throttled' };
774
- state.last = { ...(state.last || {}), [`${hb.editor}|${hb.project}`]: hb.time };
904
+ if (shouldThrottle(state, hb, event)) {
905
+ writeState(home, state);
906
+ return { status: 'throttled' };
907
+ }
908
+ state.last = { ...(state.last || {}), [key]: hb.time };
775
909
  writeState(home, state);
776
910
 
777
911
  appendToQueue(home, hb);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "embertime",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Connect VS Code, Cursor, Windsurf, Claude Code and Codex to Ember in one command. Tracks coding time, never code or prompts.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "cli.mjs",
11
11
  "editors.mjs",
12
+ "postinstall.mjs",
12
13
  "ember-hook.mjs",
13
14
  "install.mjs",
14
15
  "hooks",
@@ -30,7 +31,8 @@
30
31
  "heatmap"
31
32
  ],
32
33
  "scripts": {
33
- "test": "node --test test/*.test.mjs"
34
+ "test": "node --test test/*.test.mjs",
35
+ "postinstall": "node postinstall.mjs"
34
36
  },
35
37
  "repository": {
36
38
  "type": "git",
@@ -0,0 +1,20 @@
1
+ // Runs after `npm install -g embertime` (and upgrades). If Ember was already set
2
+ // up on this machine, refresh the hook copy in ~/.ember/agent-hook so agents run
3
+ // the new version straight away. Your key in ~/.ember/config.json is untouched.
4
+ // Best effort: it never fails the install and does nothing on a fresh machine.
5
+
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ try {
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+ const dest = path.join(process.env.EMBER_HOME || path.join(os.homedir(), '.ember'), 'agent-hook');
14
+ if (fs.existsSync(path.join(dest, 'ember-hook.mjs'))) {
15
+ for (const f of ['ember-hook.mjs', 'install.mjs', 'package.json']) fs.copyFileSync(path.join(here, f), path.join(dest, f));
16
+ console.log('embertime: updated the hook in ~/.ember/agent-hook (your key is unchanged).');
17
+ }
18
+ } catch {
19
+ /* never block an install */
20
+ }