@workscribe/cli 1.10.0 → 1.12.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/index.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2
2
  "use strict";
3
3
  var __create = Object.create;
4
4
  var __defProp = Object.defineProperty;
@@ -26,24 +26,46 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  // src/index.ts
27
27
  var import_commander = require("commander");
28
28
 
29
+ // src/utils/node-version.ts
30
+ function assertNodeVersion(nodeVersion) {
31
+ const major = Number(nodeVersion.split(".")[0]);
32
+ if (major < 22) {
33
+ process.stderr.write(
34
+ `workscribe requires Node.js 22 or later (you have v${nodeVersion}).
35
+ Upgrade at https://nodejs.org or via your version manager:
36
+ nvm install 22 && nvm use 22
37
+ `
38
+ );
39
+ process.exit(1);
40
+ }
41
+ }
42
+
29
43
  // src/capture/index.ts
30
- var import_fs9 = __toESM(require("fs"));
44
+ var import_fs10 = __toESM(require("fs"));
31
45
 
32
46
  // src/db/index.ts
33
47
  var import_node_sqlite = require("node:sqlite");
34
- var import_fs = __toESM(require("fs"));
48
+ var import_fs2 = __toESM(require("fs"));
35
49
 
36
50
  // src/utils/paths.ts
37
51
  var import_path = __toESM(require("path"));
38
52
  var import_os = __toESM(require("os"));
39
- var WORKSCRIBE_DIR = import_path.default.join(import_os.default.homedir(), ".workscribe");
40
- var DB_PATH = import_path.default.join(WORKSCRIBE_DIR, "workscribe.db");
41
- var CONFIG_PATH = import_path.default.join(WORKSCRIBE_DIR, "config.json");
42
- var DEBUG_LOG_PATH = import_path.default.join(WORKSCRIBE_DIR, "debug.log");
43
- var EXPORTS_DIR = import_path.default.join(WORKSCRIBE_DIR, "exports");
44
- var PAUSE_FLAG_PATH = import_path.default.join(WORKSCRIBE_DIR, "paused");
45
- var WEEKLY_CACHE_DIR = import_path.default.join(WORKSCRIBE_DIR, "weekly-cache");
46
- var PROFILES_DIR = import_path.default.join(WORKSCRIBE_DIR, "profiles");
53
+ var import_fs = __toESM(require("fs"));
54
+ var LEGACY_DIR = import_path.default.join(import_os.default.homedir(), ".workscribe");
55
+ var USE_XDG = !import_fs.default.existsSync(LEGACY_DIR);
56
+ var xdgDataDir = USE_XDG ? import_path.default.join(process.env.XDG_DATA_HOME ?? import_path.default.join(import_os.default.homedir(), ".local", "share"), "workscribe") : LEGACY_DIR;
57
+ var xdgConfigDir = USE_XDG ? import_path.default.join(process.env.XDG_CONFIG_HOME ?? import_path.default.join(import_os.default.homedir(), ".config"), "workscribe") : LEGACY_DIR;
58
+ var xdgStateDir = USE_XDG ? import_path.default.join(process.env.XDG_STATE_HOME ?? import_path.default.join(import_os.default.homedir(), ".local", "state"), "workscribe") : LEGACY_DIR;
59
+ var REQUIRED_DIRS = [.../* @__PURE__ */ new Set([xdgDataDir, xdgConfigDir, xdgStateDir])];
60
+ var WORKSCRIBE_DIR = xdgDataDir;
61
+ var DB_PATH = import_path.default.join(xdgDataDir, "workscribe.db");
62
+ var CONFIG_PATH = import_path.default.join(xdgConfigDir, "config.json");
63
+ var DEBUG_LOG_PATH = import_path.default.join(xdgStateDir, "debug.log");
64
+ var UPDATE_CHECK_PATH = import_path.default.join(xdgStateDir, "update-check.json");
65
+ var EXPORTS_DIR = import_path.default.join(xdgDataDir, "exports");
66
+ var PAUSE_FLAG_PATH = import_path.default.join(xdgStateDir, "paused");
67
+ var WEEKLY_CACHE_DIR = import_path.default.join(xdgDataDir, "weekly-cache");
68
+ var PROFILES_DIR = import_path.default.join(xdgDataDir, "profiles");
47
69
 
48
70
  // src/db/migrations/001_initial_schema.ts
49
71
  var initial_schema_default = {
@@ -112,12 +134,12 @@ var MIGRATIONS = [initial_schema_default, semantic_categories_default];
112
134
  var _db = null;
113
135
  function getDb() {
114
136
  if (_db) return _db;
115
- if (!import_fs.default.existsSync(WORKSCRIBE_DIR)) {
116
- import_fs.default.mkdirSync(WORKSCRIBE_DIR, { recursive: true });
137
+ if (!import_fs2.default.existsSync(WORKSCRIBE_DIR)) {
138
+ import_fs2.default.mkdirSync(WORKSCRIBE_DIR, { recursive: true });
117
139
  }
118
140
  const db = new import_node_sqlite.DatabaseSync(DB_PATH);
119
141
  try {
120
- import_fs.default.chmodSync(DB_PATH, 384);
142
+ import_fs2.default.chmodSync(DB_PATH, 384);
121
143
  } catch {
122
144
  }
123
145
  db.exec("PRAGMA journal_mode = WAL");
@@ -307,7 +329,10 @@ var BUILTIN_PATTERNS = [
307
329
  // Values after long-form secret flags (single-char flags like -t/-p/-k are too ambiguous)
308
330
  /(?<=--(password|passwd|secret|token|key|api[-_]?key|auth[-_]?token)\s+)\S+/gi,
309
331
  // export VAR=VALUE where VAR looks sensitive
310
- /(?<=export\s+(?:[A-Z_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIAL)[A-Z_]*)=)\S+/gi
332
+ /(?<=export\s+(?:[A-Z_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIAL)[A-Z_]*)=)\S+/gi,
333
+ // Inline assignment: VAR_WITH_SENSITIVE_KEYWORD=value cmd (e.g. API_KEY=secret npm start)
334
+ // Lookbehind matches only the variable-name= prefix, so only the value is replaced
335
+ /(?<=[A-Z_][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIAL)[A-Z0-9_]*=)\S+/gi
311
336
  ];
312
337
  function redact(command, extraPatterns = []) {
313
338
  let result = command;
@@ -328,7 +353,7 @@ function redact(command, extraPatterns = []) {
328
353
  // src/capture/git.ts
329
354
  var import_child_process = require("child_process");
330
355
  var import_path2 = __toESM(require("path"));
331
- var import_fs2 = __toESM(require("fs"));
356
+ var import_fs3 = __toESM(require("fs"));
332
357
  function nameFromRemote(root) {
333
358
  try {
334
359
  const url = (0, import_child_process.execSync)("git remote get-url origin", {
@@ -344,7 +369,7 @@ function nameFromRemote(root) {
344
369
  }
345
370
  function nameFromPackageJson(root) {
346
371
  try {
347
- const raw = import_fs2.default.readFileSync(import_path2.default.join(root, "package.json"), "utf-8");
372
+ const raw = import_fs3.default.readFileSync(import_path2.default.join(root, "package.json"), "utf-8");
348
373
  const pkg = JSON.parse(raw);
349
374
  if (!pkg.name) return null;
350
375
  return pkg.name.replace(/^@[^/]+\//, "");
@@ -388,14 +413,14 @@ function getCommitsSince(sha, cwd) {
388
413
  }
389
414
 
390
415
  // src/capture/aider.ts
391
- var import_fs3 = __toESM(require("fs"));
416
+ var import_fs4 = __toESM(require("fs"));
392
417
  var import_path3 = __toESM(require("path"));
393
418
  var HISTORY_FILE = ".aider.chat.history.md";
394
419
  var PROMPT_MAX_LENGTH = 200;
395
420
  function readLastAiderPrompt(cwd) {
396
421
  try {
397
422
  const histPath = import_path3.default.join(cwd, HISTORY_FILE);
398
- const content = import_fs3.default.readFileSync(histPath, "utf-8");
423
+ const content = import_fs4.default.readFileSync(histPath, "utf-8");
399
424
  const matches = [...content.matchAll(/^> (.+)$/gm)];
400
425
  if (!matches.length) return null;
401
426
  return matches[matches.length - 1][1].trim().slice(0, PROMPT_MAX_LENGTH);
@@ -405,7 +430,7 @@ function readLastAiderPrompt(cwd) {
405
430
  }
406
431
 
407
432
  // src/config/registry.ts
408
- var import_fs5 = __toESM(require("fs"));
433
+ var import_fs6 = __toESM(require("fs"));
409
434
  var import_os2 = __toESM(require("os"));
410
435
  var import_path4 = __toESM(require("path"));
411
436
 
@@ -471,7 +496,7 @@ var ai_tools_default = [
471
496
 
472
497
  // src/utils/logger.ts
473
498
  var import_chalk = __toESM(require("chalk"));
474
- var import_fs4 = __toESM(require("fs"));
499
+ var import_fs5 = __toESM(require("fs"));
475
500
  var isDebug = process.env.WORKSCRIBE_DEBUG === "1";
476
501
  var log = {
477
502
  info: (msg) => console.log(import_chalk.default.cyan("\u2192"), msg),
@@ -485,7 +510,7 @@ function debugLog(context, error) {
485
510
  const entry = `[${(/* @__PURE__ */ new Date()).toISOString()}] [${context}] ${error instanceof Error ? error.stack : String(error)}
486
511
  `;
487
512
  try {
488
- import_fs4.default.appendFileSync(DEBUG_LOG_PATH, entry, { mode: 384 });
513
+ import_fs5.default.appendFileSync(DEBUG_LOG_PATH, entry, { mode: 384 });
489
514
  } catch {
490
515
  }
491
516
  }
@@ -495,8 +520,8 @@ var USER_REGISTRY_PATH = import_path4.default.join(import_os2.default.homedir(),
495
520
  function loadAIToolRegistry() {
496
521
  let userEntries = [];
497
522
  try {
498
- if (import_fs5.default.existsSync(USER_REGISTRY_PATH)) {
499
- const raw = import_fs5.default.readFileSync(USER_REGISTRY_PATH, "utf-8");
523
+ if (import_fs6.default.existsSync(USER_REGISTRY_PATH)) {
524
+ const raw = import_fs6.default.readFileSync(USER_REGISTRY_PATH, "utf-8");
500
525
  const parsed = JSON.parse(raw);
501
526
  if (Array.isArray(parsed)) {
502
527
  userEntries = parsed.filter((entry) => typeof entry.tool === "string" && typeof entry.pattern === "string").map((entry) => ({
@@ -518,11 +543,11 @@ function loadAIToolRegistry() {
518
543
  }
519
544
 
520
545
  // src/profiles/loader.ts
521
- var import_fs8 = __toESM(require("fs"));
546
+ var import_fs9 = __toESM(require("fs"));
522
547
  var import_path6 = __toESM(require("path"));
523
548
 
524
549
  // src/config/index.ts
525
- var import_fs6 = __toESM(require("fs"));
550
+ var import_fs7 = __toESM(require("fs"));
526
551
  var DEFAULTS = {
527
552
  ai: {
528
553
  provider: "",
@@ -547,9 +572,9 @@ var DEFAULTS = {
547
572
  }
548
573
  };
549
574
  function readConfig() {
550
- if (!import_fs6.default.existsSync(CONFIG_PATH)) return { ...DEFAULTS };
575
+ if (!import_fs7.default.existsSync(CONFIG_PATH)) return { ...DEFAULTS };
551
576
  try {
552
- const raw = import_fs6.default.readFileSync(CONFIG_PATH, "utf-8");
577
+ const raw = import_fs7.default.readFileSync(CONFIG_PATH, "utf-8");
553
578
  const parsed = JSON.parse(raw);
554
579
  return deepMerge(DEFAULTS, parsed);
555
580
  } catch {
@@ -557,9 +582,9 @@ function readConfig() {
557
582
  }
558
583
  }
559
584
  function writeConfig(config) {
560
- import_fs6.default.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 384 });
585
+ import_fs7.default.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 384 });
561
586
  try {
562
- import_fs6.default.chmodSync(CONFIG_PATH, 384);
587
+ import_fs7.default.chmodSync(CONFIG_PATH, 384);
563
588
  } catch {
564
589
  }
565
590
  }
@@ -624,21 +649,57 @@ function isValidManifest(m) {
624
649
  }
625
650
 
626
651
  // src/profiles/fetcher.ts
627
- var import_fs7 = __toESM(require("fs"));
652
+ var import_fs8 = __toESM(require("fs"));
628
653
  var import_path5 = __toESM(require("path"));
654
+
655
+ // src/utils/fetch.ts
656
+ var DEFAULT_TIMEOUT_MS = 3e4;
657
+ var NETWORK_ERROR_CODES = /* @__PURE__ */ new Set([
658
+ "ENOTFOUND",
659
+ "ECONNREFUSED",
660
+ "ECONNRESET",
661
+ "ETIMEDOUT",
662
+ "EHOSTUNREACH",
663
+ "ENETUNREACH"
664
+ ]);
665
+ function isNetworkError(err) {
666
+ if (!(err instanceof Error)) return false;
667
+ const cause = err.cause;
668
+ const code = err.code ?? (cause instanceof Error ? cause.code : void 0);
669
+ return NETWORK_ERROR_CODES.has(code ?? "");
670
+ }
671
+ async function fetchWithTimeout(url, init, timeoutMs = DEFAULT_TIMEOUT_MS) {
672
+ const controller = new AbortController();
673
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
674
+ try {
675
+ return await fetch(url, { ...init, signal: controller.signal });
676
+ } catch (err) {
677
+ if (err.name === "AbortError") {
678
+ throw new Error(`Request timed out after ${timeoutMs / 1e3}s`);
679
+ }
680
+ if (isNetworkError(err)) {
681
+ throw new Error("No internet connection \u2014 try again when you're online");
682
+ }
683
+ throw err;
684
+ } finally {
685
+ clearTimeout(timer);
686
+ }
687
+ }
688
+
689
+ // src/profiles/fetcher.ts
629
690
  var REGISTRY_BASE = "https://raw.githubusercontent.com/workscribe/profiles/main";
630
691
  function manifestCachePath() {
631
692
  return import_path5.default.join(PROFILES_DIR, "manifest.json");
632
693
  }
633
694
  async function fetchManifest() {
634
- const response = await fetch(`${REGISTRY_BASE}/manifest.json`);
695
+ const response = await fetchWithTimeout(`${REGISTRY_BASE}/manifest.json`, {});
635
696
  if (!response.ok) throw new Error(`Registry unreachable (${response.status})`);
636
697
  const data = await response.json();
637
698
  if (!isValidManifest(data)) throw new Error("Manifest format unrecognised");
638
699
  return data;
639
700
  }
640
701
  async function fetchProfile(name) {
641
- const response = await fetch(`${REGISTRY_BASE}/${name}.json`);
702
+ const response = await fetchWithTimeout(`${REGISTRY_BASE}/${name}.json`, {});
642
703
  if (response.status === 404) throw new Error(`Profile "${name}" not found in registry`);
643
704
  if (!response.ok) throw new Error(`Registry unreachable (${response.status})`);
644
705
  const data = await response.json();
@@ -649,27 +710,27 @@ async function installProfile(name) {
649
710
  ensureProfilesDir();
650
711
  const profile = await fetchProfile(name);
651
712
  const dest = import_path5.default.join(PROFILES_DIR, `${name}.json`);
652
- import_fs7.default.writeFileSync(dest, JSON.stringify(profile, null, 2), { encoding: "utf-8", mode: 384 });
713
+ import_fs8.default.writeFileSync(dest, JSON.stringify(profile, null, 2), { encoding: "utf-8", mode: 384 });
653
714
  debugLog("profiles", `Installed profile "${name}" to ${dest}`);
654
715
  }
655
716
  async function cacheManifest() {
656
717
  ensureProfilesDir();
657
718
  const manifest = await fetchManifest();
658
- import_fs7.default.writeFileSync(manifestCachePath(), JSON.stringify(manifest, null, 2), { encoding: "utf-8", mode: 384 });
719
+ import_fs8.default.writeFileSync(manifestCachePath(), JSON.stringify(manifest, null, 2), { encoding: "utf-8", mode: 384 });
659
720
  return manifest;
660
721
  }
661
722
  function readCachedManifest() {
662
- if (!import_fs7.default.existsSync(manifestCachePath())) return null;
723
+ if (!import_fs8.default.existsSync(manifestCachePath())) return null;
663
724
  try {
664
- const data = JSON.parse(import_fs7.default.readFileSync(manifestCachePath(), "utf-8"));
725
+ const data = JSON.parse(import_fs8.default.readFileSync(manifestCachePath(), "utf-8"));
665
726
  return isValidManifest(data) ? data : null;
666
727
  } catch {
667
728
  return null;
668
729
  }
669
730
  }
670
731
  function ensureProfilesDir() {
671
- if (!import_fs7.default.existsSync(PROFILES_DIR)) {
672
- import_fs7.default.mkdirSync(PROFILES_DIR, { recursive: true, mode: 448 });
732
+ if (!import_fs8.default.existsSync(PROFILES_DIR)) {
733
+ import_fs8.default.mkdirSync(PROFILES_DIR, { recursive: true, mode: 448 });
673
734
  }
674
735
  }
675
736
 
@@ -696,13 +757,13 @@ function compileRule(profileName, rule) {
696
757
  }
697
758
  function loadProfileFromDisk(name) {
698
759
  const filePath = import_path6.default.join(PROFILES_DIR, `${name}.json`);
699
- if (!import_fs8.default.existsSync(filePath)) {
760
+ if (!import_fs9.default.existsSync(filePath)) {
700
761
  debugLog("profiles", `Profile "${name}" not found in cache \u2014 run: workscribe profile add ${name}`);
701
762
  return null;
702
763
  }
703
764
  let candidate;
704
765
  try {
705
- candidate = JSON.parse(import_fs8.default.readFileSync(filePath, "utf-8"));
766
+ candidate = JSON.parse(import_fs9.default.readFileSync(filePath, "utf-8"));
706
767
  } catch {
707
768
  debugLog("profiles", `Profile "${name}" contains invalid JSON \u2014 skipping`);
708
769
  return null;
@@ -734,8 +795,8 @@ function getActiveProfileAiContexts() {
734
795
  });
735
796
  }
736
797
  function getCachedProfiles() {
737
- if (!import_fs8.default.existsSync(PROFILES_DIR)) return [];
738
- return import_fs8.default.readdirSync(PROFILES_DIR).filter((file) => file.endsWith(".json") && file !== "manifest.json").flatMap((file) => {
798
+ if (!import_fs9.default.existsSync(PROFILES_DIR)) return [];
799
+ return import_fs9.default.readdirSync(PROFILES_DIR).filter((file) => file.endsWith(".json") && file !== "manifest.json").flatMap((file) => {
739
800
  const profile = loadProfileFromDisk(file.replace(/\.json$/, ""));
740
801
  return profile ? [profile] : [];
741
802
  });
@@ -747,7 +808,7 @@ function detectProfilesForDirectory(dir) {
747
808
  return manifest.filter((entry) => {
748
809
  if (activeNames.has(entry.name)) return false;
749
810
  const indicatorFiles = entry.detect?.files ?? [];
750
- return indicatorFiles.length > 0 && indicatorFiles.every((file) => import_fs8.default.existsSync(import_path6.default.join(dir, file)));
811
+ return indicatorFiles.length > 0 && indicatorFiles.every((file) => import_fs9.default.existsSync(import_path6.default.join(dir, file)));
751
812
  });
752
813
  }
753
814
 
@@ -1015,7 +1076,7 @@ function isDuplicate(command, cwd, lastEvent, dedupWindowMs = 5e3, now = Date.no
1015
1076
 
1016
1077
  // src/capture/index.ts
1017
1078
  function captureCommand(opts) {
1018
- if (import_fs9.default.existsSync(PAUSE_FLAG_PATH)) return;
1079
+ if (import_fs10.default.existsSync(PAUSE_FLAG_PATH)) return;
1019
1080
  const config = readConfig();
1020
1081
  const { command, cwd, exitCode, durationMs } = opts;
1021
1082
  if (shouldFilter(command, config.capture.ignore)) return;
@@ -1059,8 +1120,11 @@ function captureCommand(opts) {
1059
1120
  }
1060
1121
 
1061
1122
  // src/cli/capture.ts
1123
+ var CAPTURE_HANG_TIMEOUT_MS = 5e3;
1062
1124
  function registerCapture(program2) {
1063
1125
  program2.command("_capture").description("Internal: capture a shell command event (called by shell hook)").option("--cmd <command>", "command that was executed").option("--cwd <path>", "working directory").option("--exit <code>", "exit code", parseInt).option("--duration <ms>", "duration in milliseconds", parseInt).option("--git-before <sha>", "git HEAD SHA before this command ran").action((opts) => {
1126
+ const hangGuard = setTimeout(() => process.exit(0), CAPTURE_HANG_TIMEOUT_MS);
1127
+ hangGuard.unref();
1064
1128
  try {
1065
1129
  if (!opts.cmd || !opts.cwd) return;
1066
1130
  captureCommand({
@@ -1113,188 +1177,723 @@ function registerHook(program2) {
1113
1177
  }
1114
1178
 
1115
1179
  // src/cli/init.ts
1116
- var import_fs12 = __toESM(require("fs"));
1180
+ var import_fs13 = __toESM(require("fs"));
1117
1181
  var import_path9 = __toESM(require("path"));
1118
- var import_os4 = __toESM(require("os"));
1182
+ var import_os5 = __toESM(require("os"));
1119
1183
  var import_prompts = __toESM(require("prompts"));
1120
1184
  var import_chalk2 = __toESM(require("chalk"));
1121
1185
 
1122
- // src/capture/shell-hook.ts
1123
- var import_fs10 = __toESM(require("fs"));
1124
- var import_path7 = __toESM(require("path"));
1125
- var HOOK_START = "# workscribe:hook:start";
1126
- var HOOK_END = "# workscribe:hook:end";
1127
- var HOOK_PREAMBLE = "# Workscribe \u2014 developer work journal";
1128
- function getInstalledHookBlock(rcFile) {
1129
- if (!import_fs10.default.existsSync(rcFile)) return null;
1130
- const content = import_fs10.default.readFileSync(rcFile, "utf-8");
1131
- const startIdx = content.indexOf(HOOK_START);
1132
- const endIdx = content.indexOf(HOOK_END);
1133
- if (startIdx === -1 || endIdx === -1) return null;
1134
- return content.slice(startIdx, endIdx + HOOK_END.length);
1186
+ // src/utils/format.ts
1187
+ function formatDuration(minutes) {
1188
+ if (minutes < 60) return `${Math.round(minutes)}m`;
1189
+ const h = Math.floor(minutes / 60);
1190
+ const m = Math.round(minutes % 60);
1191
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
1135
1192
  }
1136
- function isHookInstalled(rcFile) {
1137
- return getInstalledHookBlock(rcFile) !== null;
1193
+ function formatDate(isoString) {
1194
+ return new Date(isoString).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1138
1195
  }
1139
- function isHookOutdated(rcFile, hookContent) {
1140
- const installed = getInstalledHookBlock(rcFile);
1141
- if (!installed) return false;
1142
- return installed.trim() !== hookContent.trim();
1196
+ function todayDate() {
1197
+ return localDateStr(/* @__PURE__ */ new Date());
1143
1198
  }
1144
- function installHook(rcFile, hookContent) {
1145
- const backup = `${rcFile}.workscribe-backup-${Date.now()}`;
1146
- import_fs10.default.copyFileSync(rcFile, backup);
1147
- log.info(`Backed up ${rcFile} \u2192 ${import_path7.default.basename(backup)}`);
1148
- import_fs10.default.appendFileSync(rcFile, `
1149
- ${hookContent}
1150
- `);
1199
+ function parseDate(input) {
1200
+ const d = /* @__PURE__ */ new Date(input + "T00:00:00");
1201
+ if (isNaN(d.getTime())) throw new Error(`Invalid date: ${input}`);
1202
+ return input;
1151
1203
  }
1152
- function updateHook(rcFile, hookContent) {
1153
- const backup = `${rcFile}.workscribe-backup-${Date.now()}`;
1154
- import_fs10.default.copyFileSync(rcFile, backup);
1155
- log.info(`Backed up ${rcFile} \u2192 ${import_path7.default.basename(backup)}`);
1156
- const content = import_fs10.default.readFileSync(rcFile, "utf-8");
1157
- const startIdx = content.indexOf(HOOK_START);
1158
- const endIdx = content.indexOf(HOOK_END) + HOOK_END.length;
1159
- import_fs10.default.writeFileSync(rcFile, content.slice(0, startIdx) + hookContent + content.slice(endIdx));
1204
+ function localDateStr(d) {
1205
+ const y = d.getFullYear();
1206
+ const m = String(d.getMonth() + 1).padStart(2, "0");
1207
+ const day = String(d.getDate()).padStart(2, "0");
1208
+ return `${y}-${m}-${day}`;
1160
1209
  }
1161
- function removeHook(rcFile) {
1162
- const backup = `${rcFile}.workscribe-backup-${Date.now()}`;
1163
- import_fs10.default.copyFileSync(rcFile, backup);
1164
- log.info(`Backed up ${rcFile} \u2192 ${import_path7.default.basename(backup)}`);
1165
- const content = import_fs10.default.readFileSync(rcFile, "utf-8");
1166
- const startIdx = content.indexOf(HOOK_START);
1167
- const endIdx = content.indexOf(HOOK_END) + HOOK_END.length;
1168
- const preambleIdx = content.lastIndexOf(HOOK_PREAMBLE, startIdx);
1169
- const blockStart = preambleIdx !== -1 ? preambleIdx : startIdx;
1170
- const before = content.slice(0, blockStart).replace(/\n+$/, "\n");
1171
- const after = content.slice(endIdx).replace(/^\n/, "");
1172
- import_fs10.default.writeFileSync(rcFile, before + after);
1210
+ function startOfWeek(dateStr) {
1211
+ const d = /* @__PURE__ */ new Date(dateStr + "T00:00:00");
1212
+ const day = d.getDay();
1213
+ const diff = day === 0 ? -6 : 1 - day;
1214
+ d.setDate(d.getDate() + diff);
1215
+ return localDateStr(d);
1173
1216
  }
1174
-
1175
- // src/capture/hooks/claude-code.ts
1176
- var import_fs11 = __toESM(require("fs"));
1177
- var import_path8 = __toESM(require("path"));
1178
- var import_os3 = __toESM(require("os"));
1179
-
1180
- // src/capture/hooks/utils.ts
1181
- var import_child_process3 = require("child_process");
1182
- function isCommandInstalled(cmd) {
1183
- try {
1184
- (0, import_child_process3.execSync)(`which ${cmd}`, { stdio: "ignore" });
1185
- return true;
1186
- } catch {
1187
- return false;
1217
+ function eachDayOfRange(start, end) {
1218
+ const days = [];
1219
+ const cur = /* @__PURE__ */ new Date(start + "T00:00:00");
1220
+ const last = /* @__PURE__ */ new Date(end + "T00:00:00");
1221
+ while (cur <= last) {
1222
+ days.push(localDateStr(cur));
1223
+ cur.setDate(cur.getDate() + 1);
1188
1224
  }
1225
+ return days;
1189
1226
  }
1190
-
1191
- // src/capture/hooks/claude-code.ts
1192
- var CLAUDE_SETTINGS_PATH = import_path8.default.join(import_os3.default.homedir(), ".claude", "settings.json");
1193
- function readSettings() {
1194
- if (!import_fs11.default.existsSync(CLAUDE_SETTINGS_PATH)) return {};
1195
- try {
1196
- return JSON.parse(import_fs11.default.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
1197
- } catch {
1198
- return {};
1199
- }
1227
+ function isoWeekKey(dateStr) {
1228
+ const d = /* @__PURE__ */ new Date(dateStr + "T00:00:00");
1229
+ const jan4 = new Date(d.getFullYear(), 0, 4);
1230
+ const weekNum = Math.ceil(((d.getTime() - jan4.getTime()) / 864e5 + jan4.getDay() + 1) / 7);
1231
+ return `${d.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
1200
1232
  }
1201
- var claudeCodeIntegration = {
1202
- displayName: "Claude Code",
1203
- isInstalled() {
1204
- return isCommandInstalled("claude");
1205
- },
1206
- isHookInstalled() {
1207
- const settings = readSettings();
1208
- const postToolUse = settings.hooks?.PostToolUse ?? [];
1209
- return postToolUse.some((entry) => {
1210
- const hooks = entry.hooks ?? [];
1211
- return hooks.some((hookItem) => {
1212
- const hook = hookItem;
1213
- return typeof hook.command === "string" && hook.command.includes("workscribe _hook");
1214
- });
1215
- });
1216
- },
1217
- install() {
1218
- const settings = readSettings();
1219
- const existingHooks = settings.hooks ?? {};
1220
- const existingPostToolUse = existingHooks.PostToolUse ?? [];
1221
- settings.hooks = {
1222
- ...existingHooks,
1223
- PostToolUse: [
1224
- ...existingPostToolUse,
1225
- {
1226
- matcher: "Bash",
1227
- hooks: [{ type: "command", command: "workscribe _hook --source claude-code", async: true }]
1228
- }
1229
- ]
1230
- };
1231
- import_fs11.default.mkdirSync(import_path8.default.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
1232
- import_fs11.default.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
1233
- }
1234
- };
1235
1233
 
1236
- // src/capture/hooks/index.ts
1237
- var AI_HOOK_INTEGRATIONS = {
1238
- "claude-code": claudeCodeIntegration
1239
- };
1234
+ // src/ai/prompt.ts
1235
+ var SYSTEM_PROMPT = `You write short developer work summaries. Focus on what the developer accomplished and what changed \u2014 not which tools or commands were used.
1240
1236
 
1241
- // src/cli/init.ts
1242
- function detectShell() {
1243
- const shell = process.env.SHELL ?? "";
1244
- if (shell.includes("zsh")) return { name: "zsh", rcFile: import_path9.default.join(import_os4.default.homedir(), ".zshrc") };
1245
- if (shell.includes("bash")) return { name: "bash", rcFile: import_path9.default.join(import_os4.default.homedir(), ".bashrc") };
1246
- return null;
1237
+ Here is an example:
1238
+
1239
+ INPUT:
1240
+ Project: api-service
1241
+ Branch: feature/auth
1242
+ Duration: 2h 00m
1243
+ Events:
1244
+ - versioning {"branch":"feature/auth"}
1245
+ - 2x dependencies {"tool":"npm","packages":["bcrypt","jsonwebtoken"]}
1246
+ - 8x testing [failed]
1247
+ - testing
1248
+ - 2x files {"tool":"mkdir"}
1249
+ - shipping {"message":"fix: auth token validation"}
1250
+ - shipping {"remote":"origin","branch":"feature/auth"}
1251
+
1252
+ OUTPUT:
1253
+ - Added authentication dependencies (bcrypt, JWT)
1254
+ - Debugged and resolved failing auth tests
1255
+ - Committed and pushed the token validation fix
1256
+
1257
+ RULES \u2014 follow these exactly:
1258
+
1259
+ 1. Interpret repeated events as patterns, not individual steps.
1260
+ - Many testing [failed] followed by a passing test = debugging a failure. Say "resolved failing X tests", not "ran tests multiple times".
1261
+ - Many versioning events = branch housekeeping. Fold into context, do not list each git command.
1262
+
1263
+ 2. Omit utility commands entirely. File size checks, file reads, grep searches, and cat commands are research steps \u2014 they are not outcomes and must not appear in the summary.
1264
+
1265
+ 3. Do not name the AI tool. For ai_session events: focus on what was built or committed. If a commits array is present in metadata, use those commit messages. Never write "via Claude Code", "using Claude Code", or similar \u2014 omit the tool name entirely.
1266
+
1267
+ 4. For other events with a hint field: describe the developer's intent, not the raw command.
1268
+
1269
+ 5. For dependencies events: infer the purpose from package names and describe the domain (bcrypt/jsonwebtoken \u2192 authentication, prisma/pg \u2192 database, jest/vitest \u2192 testing). For unfamiliar packages, name them directly.
1270
+
1271
+ 6. Fold supporting events into the narrative. Building, shipping, versioning, and file operations are context \u2014 only make them a bullet when they are the primary activity (e.g. a standalone build fix or deploy with no accompanying commits).
1272
+
1273
+ 7. Stick to what the events show. Do not infer subject matter from the branch name. Do not add details not derivable from the events.
1274
+
1275
+ Maximum 5 bullets. Output ONLY the bullet points, nothing else.`;
1276
+ function getSystemPrompt() {
1277
+ const contexts = getActiveProfileAiContexts();
1278
+ if (contexts.length === 0) return SYSTEM_PROMPT;
1279
+ return SYSTEM_PROMPT + "\n\n" + contexts.join("\n");
1247
1280
  }
1248
- function registerInit(program2) {
1249
- program2.command("init").description("Set up Workscribe and install or update shell hooks").action(async () => {
1250
- log.plain(import_chalk2.default.bold("\n Workscribe \u2014 Developer Work Journal\n"));
1251
- if (!import_fs12.default.existsSync(WORKSCRIBE_DIR)) {
1252
- import_fs12.default.mkdirSync(WORKSCRIBE_DIR, { recursive: true, mode: 448 });
1253
- log.success(`Created ${WORKSCRIBE_DIR}`);
1254
- } else {
1255
- try {
1256
- import_fs12.default.chmodSync(WORKSCRIBE_DIR, 448);
1257
- } catch {
1281
+ var DEFAULT_EXCLUDED_CATEGORIES = /* @__PURE__ */ new Set(["exploring", "note", "linting"]);
1282
+ function buildExcludedSet(includeCategories = []) {
1283
+ const excluded = new Set(DEFAULT_EXCLUDED_CATEGORIES);
1284
+ for (const cat of includeCategories) excluded.delete(cat);
1285
+ return excluded;
1286
+ }
1287
+ function countExcludedEvents(sessions, excluded) {
1288
+ const counts = /* @__PURE__ */ new Map();
1289
+ for (const session of sessions) {
1290
+ for (const event of session.events) {
1291
+ if (excluded.has(event.category)) {
1292
+ counts.set(event.category, (counts.get(event.category) ?? 0) + 1);
1258
1293
  }
1259
1294
  }
1260
- if (!import_fs12.default.existsSync(PROFILES_DIR)) {
1261
- import_fs12.default.mkdirSync(PROFILES_DIR, { recursive: true, mode: 448 });
1262
- }
1263
- getDb();
1264
- log.success("Database initialized");
1265
- const shell = detectShell();
1266
- if (!shell) {
1267
- log.warn("Could not detect shell. Supported: zsh, bash");
1268
- log.plain(`Manually add the hook from: ${import_chalk2.default.cyan("shell/hook.zsh")} or ${import_chalk2.default.cyan("shell/hook.bash")}`);
1269
- } else {
1270
- const hookFile = import_path9.default.join(__dirname, "..", "shell", `hook.${shell.name}`);
1271
- if (!import_fs12.default.existsSync(hookFile)) {
1272
- log.warn(`Hook template not found at ${hookFile}`);
1273
- } else {
1274
- const hookContent = import_fs12.default.readFileSync(hookFile, "utf-8");
1275
- if (!import_fs12.default.existsSync(shell.rcFile)) import_fs12.default.writeFileSync(shell.rcFile, "");
1276
- if (!isHookInstalled(shell.rcFile)) {
1277
- installHook(shell.rcFile, hookContent);
1278
- log.success(`Shell hook installed in ${shell.rcFile}`);
1279
- } else if (isHookOutdated(shell.rcFile, hookContent)) {
1280
- const { update } = await (0, import_prompts.default)({
1281
- type: "confirm",
1282
- name: "update",
1283
- message: `Shell hook in ${shell.rcFile} is outdated. Update?`,
1284
- initial: true
1285
- });
1286
- if (update) {
1287
- updateHook(shell.rcFile, hookContent);
1288
- log.success(`Shell hook updated in ${shell.rcFile}`);
1289
- } else {
1290
- log.info("Shell hook update skipped");
1291
- }
1292
- } else {
1293
- log.info(`Shell hook up to date in ${shell.rcFile}`);
1294
- }
1295
+ }
1296
+ return counts;
1297
+ }
1298
+ function buildUserMessage(payload, includeCategories = []) {
1299
+ const excluded = buildExcludedSet(includeCategories);
1300
+ const lines = [`Date: ${payload.date}`, ""];
1301
+ for (const session of payload.sessions) {
1302
+ lines.push(`Project: ${session.project ?? "unknown"}`);
1303
+ if (session.branch) lines.push(`Branch: ${session.branch}`);
1304
+ lines.push(`Duration: ${formatDuration(session.durationMinutes)}`);
1305
+ for (const event of session.events) {
1306
+ if (event.category === "note") {
1307
+ lines.push(`NOTE: ${event.metadata.text}`);
1295
1308
  }
1296
1309
  }
1297
- const installedTools = loadAIToolRegistry().filter(
1310
+ lines.push("Events:");
1311
+ const counts = /* @__PURE__ */ new Map();
1312
+ for (const event of session.events) {
1313
+ if (excluded.has(event.category)) continue;
1314
+ const failed = event.exitCode !== null && event.exitCode !== 0 ? " [failed]" : "";
1315
+ let key;
1316
+ if (event.hint) {
1317
+ key = `other [${event.hint}]${failed}`;
1318
+ } else {
1319
+ const meta = Object.keys(event.metadata).length > 0 ? ` ${JSON.stringify(event.metadata)}` : "";
1320
+ key = `${event.category}${meta}${failed}`;
1321
+ }
1322
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1323
+ }
1324
+ for (const [key, count] of counts) {
1325
+ lines.push(` - ${count > 1 ? `${count}x ` : ""}${key}`);
1326
+ }
1327
+ lines.push("");
1328
+ }
1329
+ return lines.join("\n");
1330
+ }
1331
+ var WEEKLY_SYSTEM_PROMPT = `You write short weekly developer summaries. Here is an example:
1332
+
1333
+ INPUT:
1334
+ Week: 2026-05-18 to 2026-05-22
1335
+
1336
+ Monday 2026-05-18:
1337
+ Project: api-service (feature/auth) \u2014 2h 10m
1338
+ Events: 3x code_commit, 2x test_run, dependency_install
1339
+
1340
+ Tuesday 2026-05-19:
1341
+ Project: api-service (feature/auth) \u2014 1h 30m
1342
+ Events: 2x code_commit, test_run, build
1343
+
1344
+ Wednesday 2026-05-20:
1345
+ Project: dashboard (main) \u2014 3h 00m
1346
+ Events: 4x code_commit, 2x build, test_run
1347
+
1348
+ Thursday 2026-05-21:
1349
+ Project: api-service (feature/auth) \u2014 1h 00m
1350
+ Events: code_commit {"message":"feat: merge auth"}, branch_switch
1351
+
1352
+ Friday 2026-05-22:
1353
+ Project: dashboard (main) \u2014 2h 00m
1354
+ Events: 3x code_commit, build
1355
+
1356
+ OUTPUT:
1357
+ ## Week of 18 May \u2014 9h 40m across 2 projects
1358
+
1359
+ **api-service** (Mon\u2013Thu): Built and shipped the auth feature \u2014 JWT setup, token validation fixes, and final merge.
1360
+ **dashboard** (Wed, Fri): Active development across 7 commits with builds passing.
1361
+
1362
+ Now summarise the following week. Output ONLY the summary \u2014 a one-line header then one bullet per active project. Maximum 4 bullets.`;
1363
+ var DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
1364
+ function buildWeeklyMessage(payload) {
1365
+ const lines = [`Week: ${payload.weekStart} to ${payload.weekEnd}`, ""];
1366
+ for (const { date, sessions } of payload.days) {
1367
+ const dayName = DAY_NAMES[(/* @__PURE__ */ new Date(date + "T00:00:00")).getDay()];
1368
+ lines.push(`${dayName} ${date}:`);
1369
+ for (const session of sessions) {
1370
+ const project = session.project ?? "unknown";
1371
+ const branch = session.branch ? ` (${session.branch})` : "";
1372
+ lines.push(` Project: ${project}${branch} \u2014 ${formatDuration(session.durationMinutes)}`);
1373
+ for (const event of session.events) {
1374
+ if (event.category === "note") {
1375
+ lines.push(` NOTE: ${event.metadata.text}`);
1376
+ }
1377
+ }
1378
+ const excluded = buildExcludedSet();
1379
+ const counts = /* @__PURE__ */ new Map();
1380
+ for (const event of session.events) {
1381
+ if (excluded.has(event.category)) continue;
1382
+ const failed = event.exitCode !== null && event.exitCode !== 0 ? " [failed]" : "";
1383
+ let key;
1384
+ if (event.hint) {
1385
+ key = `other [${event.hint}]${failed}`;
1386
+ } else {
1387
+ const meta = Object.keys(event.metadata).length > 0 ? ` ${JSON.stringify(event.metadata)}` : "";
1388
+ key = `${event.category}${meta}${failed}`;
1389
+ }
1390
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1391
+ }
1392
+ const eventLine = [...counts.entries()].map(([key, count]) => `${count > 1 ? `${count}x ` : ""}${key}`).join(", ");
1393
+ lines.push(` Events: ${eventLine}`);
1394
+ }
1395
+ lines.push("");
1396
+ }
1397
+ return lines.join("\n");
1398
+ }
1399
+
1400
+ // src/ai/generic-provider.ts
1401
+ var GenericProvider = class {
1402
+ constructor(config, model, apiKey, baseUrl, includeCategories = []) {
1403
+ this.config = config;
1404
+ this.model = model;
1405
+ this.apiKey = apiKey;
1406
+ this.baseUrl = baseUrl;
1407
+ this.includeCategories = includeCategories;
1408
+ }
1409
+ config;
1410
+ model;
1411
+ apiKey;
1412
+ baseUrl;
1413
+ includeCategories;
1414
+ async call(system, userMsg, maxTokens) {
1415
+ const response = await fetchWithTimeout(this.config.chatUrl(this.baseUrl), {
1416
+ method: "POST",
1417
+ headers: this.config.headers(this.apiKey),
1418
+ body: JSON.stringify(this.config.buildChatBody(this.model, system, userMsg, maxTokens))
1419
+ });
1420
+ if (!response.ok) {
1421
+ throw new Error(this.config.chatError(response.status, await response.text()));
1422
+ }
1423
+ return this.config.parseText(await response.json());
1424
+ }
1425
+ async summarize(payload) {
1426
+ return this.call(getSystemPrompt(), buildUserMessage(payload, this.includeCategories), this.config.dailyTokens);
1427
+ }
1428
+ async summarizeWeek(payload) {
1429
+ return this.call(WEEKLY_SYSTEM_PROMPT, buildWeeklyMessage(payload), this.config.weeklyTokens);
1430
+ }
1431
+ async validate() {
1432
+ const isPost = this.config.validateMethod === "POST";
1433
+ const response = await fetchWithTimeout(this.config.validateUrl(this.baseUrl), {
1434
+ method: this.config.validateMethod,
1435
+ headers: isPost ? this.config.headers(this.apiKey) : void 0,
1436
+ body: isPost && this.config.buildValidateBody ? JSON.stringify(this.config.buildValidateBody(this.model)) : void 0
1437
+ });
1438
+ if (!response.ok) {
1439
+ throw new Error(this.config.validateError(response.status, await response.text(), this.baseUrl));
1440
+ }
1441
+ }
1442
+ };
1443
+
1444
+ // src/ai/provider-config.ts
1445
+ var PROVIDER_CONFIGS = {
1446
+ anthropic: {
1447
+ defaultModel: "claude-haiku-4-5-20251001",
1448
+ defaultBaseUrl: "https://api.anthropic.com/v1",
1449
+ dailyTokens: 1024,
1450
+ weeklyTokens: 1024,
1451
+ chatUrl: (baseUrl) => `${baseUrl}/messages`,
1452
+ validateUrl: (baseUrl) => `${baseUrl}/messages`,
1453
+ validateMethod: "POST",
1454
+ headers: (apiKey) => ({
1455
+ "Content-Type": "application/json",
1456
+ "x-api-key": apiKey,
1457
+ "anthropic-version": "2023-06-01"
1458
+ }),
1459
+ buildChatBody: (model, system, userMsg, maxTokens) => ({
1460
+ model,
1461
+ max_tokens: maxTokens,
1462
+ system,
1463
+ messages: [{ role: "user", content: userMsg }]
1464
+ }),
1465
+ buildValidateBody: (model) => ({
1466
+ model,
1467
+ max_tokens: 1,
1468
+ messages: [{ role: "user", content: "." }]
1469
+ }),
1470
+ parseText: (json) => {
1471
+ const data = json;
1472
+ return data.content.find((b) => b.type === "text")?.text ?? "";
1473
+ },
1474
+ chatError: (status, body) => `Anthropic API error ${status}: ${body}`,
1475
+ validateError: (status, body) => `Anthropic API error ${status}: ${body}`
1476
+ },
1477
+ openai: {
1478
+ defaultModel: "gpt-4o-mini",
1479
+ defaultBaseUrl: "https://api.openai.com/v1",
1480
+ dailyTokens: 1024,
1481
+ weeklyTokens: 1024,
1482
+ chatUrl: (baseUrl) => `${baseUrl}/chat/completions`,
1483
+ validateUrl: (baseUrl) => `${baseUrl}/chat/completions`,
1484
+ validateMethod: "POST",
1485
+ headers: (apiKey) => ({
1486
+ "Content-Type": "application/json",
1487
+ Authorization: `Bearer ${apiKey}`
1488
+ }),
1489
+ buildChatBody: (model, system, userMsg, maxTokens) => ({
1490
+ model,
1491
+ messages: [
1492
+ { role: "system", content: system },
1493
+ { role: "user", content: userMsg }
1494
+ ],
1495
+ max_tokens: maxTokens
1496
+ }),
1497
+ buildValidateBody: (model) => ({
1498
+ model,
1499
+ messages: [{ role: "user", content: "." }],
1500
+ max_tokens: 1
1501
+ }),
1502
+ parseText: (json) => {
1503
+ const data = json;
1504
+ return data.choices[0]?.message?.content ?? "";
1505
+ },
1506
+ chatError: (status, body) => `OpenAI API error ${status}: ${body}`,
1507
+ validateError: (status, body) => `OpenAI API error ${status}: ${body}`
1508
+ },
1509
+ ollama: {
1510
+ defaultModel: "llama3",
1511
+ defaultBaseUrl: "http://localhost:11434",
1512
+ dailyTokens: 300,
1513
+ weeklyTokens: 400,
1514
+ chatUrl: (baseUrl) => `${baseUrl}/api/chat`,
1515
+ validateUrl: (baseUrl) => `${baseUrl}/api/tags`,
1516
+ validateMethod: "GET",
1517
+ headers: () => ({ "Content-Type": "application/json" }),
1518
+ buildChatBody: (model, system, userMsg, maxTokens) => ({
1519
+ model,
1520
+ stream: false,
1521
+ messages: [
1522
+ { role: "system", content: system },
1523
+ { role: "user", content: userMsg }
1524
+ ],
1525
+ options: { num_predict: maxTokens, temperature: 0.3 }
1526
+ }),
1527
+ parseText: (json) => {
1528
+ return json.message?.content ?? "";
1529
+ },
1530
+ chatError: (status, body) => `Ollama error ${status}: ${body}. Is Ollama running? Try: ollama serve`,
1531
+ validateError: (_status, _body, baseUrl) => `Ollama is not reachable at ${baseUrl}. Is it running? Try: ollama serve`
1532
+ }
1533
+ };
1534
+
1535
+ // src/ai/router.ts
1536
+ function getProvider(config) {
1537
+ const { provider, apiKey = "", model, baseUrl } = config.ai;
1538
+ if (!provider) {
1539
+ throw new Error(
1540
+ "No AI provider configured.\nRun: workscribe config set ai.provider <anthropic|openai|ollama|openai-compatible>"
1541
+ );
1542
+ }
1543
+ const configKey = provider === "openai-compatible" ? "openai" : provider;
1544
+ const providerConfig = PROVIDER_CONFIGS[configKey];
1545
+ if (!providerConfig) {
1546
+ throw new Error(
1547
+ `Unknown provider: "${provider}"
1548
+ Supported providers: anthropic, openai, ollama, openai-compatible`
1549
+ );
1550
+ }
1551
+ if (provider !== "ollama" && !apiKey) {
1552
+ throw new Error(`${provider} requires an API key.
1553
+ Run: workscribe config set ai.apiKey <your-key>`);
1554
+ }
1555
+ if (provider === "openai-compatible" && !baseUrl) {
1556
+ throw new Error("openai-compatible requires a base URL.\nRun: workscribe config set ai.baseUrl <url>");
1557
+ }
1558
+ return new GenericProvider(
1559
+ providerConfig,
1560
+ model || providerConfig.defaultModel,
1561
+ apiKey,
1562
+ baseUrl || providerConfig.defaultBaseUrl,
1563
+ config.summary?.includeCategories ?? []
1564
+ );
1565
+ }
1566
+
1567
+ // src/capture/shell-hook.ts
1568
+ var import_fs11 = __toESM(require("fs"));
1569
+ var import_path7 = __toESM(require("path"));
1570
+ var import_os3 = __toESM(require("os"));
1571
+
1572
+ // src/utils/completion-scripts.ts
1573
+ function bashCompletion(commands) {
1574
+ const names = commands.map((command) => command.name).join(" ");
1575
+ return `_workscribe_completion() {
1576
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
1577
+ local prev="\${COMP_WORDS[COMP_CWORD-1]}"
1578
+ local commands="${names}"
1579
+ case "$prev" in
1580
+ completions) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ); return ;;
1581
+ config) COMPREPLY=( $(compgen -W "get set list" -- "$cur") ); return ;;
1582
+ profile) COMPREPLY=( $(compgen -W "list add remove update detect suggest" -- "$cur") ); return ;;
1583
+ esac
1584
+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
1585
+ }
1586
+ complete -F _workscribe_completion workscribe`;
1587
+ }
1588
+ function zshCompletion(commands) {
1589
+ const commandList = commands.map((command) => ` '${command.name}:${command.description}'`).join("\n");
1590
+ return `#compdef workscribe
1591
+ _workscribe() {
1592
+ local state
1593
+ _arguments '1: :->command' '*: :->args'
1594
+ case $state in
1595
+ command)
1596
+ local -a commands
1597
+ commands=(
1598
+ ${commandList}
1599
+ )
1600
+ _describe 'command' commands
1601
+ ;;
1602
+ args)
1603
+ case $words[2] in
1604
+ completions) local -a shells; shells=('bash' 'zsh' 'fish'); _describe 'shell' shells ;;
1605
+ config) local -a sub; sub=('get:Get a value' 'set:Set a value' 'list:List all values'); _describe 'subcommand' sub ;;
1606
+ profile) local -a sub; sub=('list' 'add' 'remove' 'update' 'detect' 'suggest'); _describe 'subcommand' sub ;;
1607
+ esac
1608
+ ;;
1609
+ esac
1610
+ }
1611
+ _workscribe`;
1612
+ }
1613
+ function fishCompletion(commands) {
1614
+ const lines = [
1615
+ "complete -c workscribe -f",
1616
+ ...commands.map(
1617
+ (command) => `complete -c workscribe -n "__fish_use_subcommand" -a ${command.name.padEnd(12)} -d "${command.description}"`
1618
+ ),
1619
+ 'complete -c workscribe -n "__fish_seen_subcommand_from completions" -a "bash zsh fish"',
1620
+ 'complete -c workscribe -n "__fish_seen_subcommand_from config" -a "get set list"',
1621
+ 'complete -c workscribe -n "__fish_seen_subcommand_from profile" -a "list add remove update detect suggest"'
1622
+ ];
1623
+ return lines.join("\n");
1624
+ }
1625
+
1626
+ // src/capture/shell-hook.ts
1627
+ var FISH_COMPLETIONS_DIR = import_path7.default.join(import_os3.default.homedir(), ".config", "fish", "completions");
1628
+ var HOOK_START = "# workscribe:hook:start";
1629
+ var HOOK_END = "# workscribe:hook:end";
1630
+ var HOOK_PREAMBLE = "# Workscribe \u2014 developer work journal";
1631
+ function getInstalledHookBlock(rcFile) {
1632
+ if (!import_fs11.default.existsSync(rcFile)) return null;
1633
+ const content = import_fs11.default.readFileSync(rcFile, "utf-8");
1634
+ const startIdx = content.indexOf(HOOK_START);
1635
+ const endIdx = content.indexOf(HOOK_END);
1636
+ if (startIdx === -1 || endIdx === -1) return null;
1637
+ return content.slice(startIdx, endIdx + HOOK_END.length);
1638
+ }
1639
+ function isHookInstalled(rcFile) {
1640
+ return getInstalledHookBlock(rcFile) !== null;
1641
+ }
1642
+ function isHookOutdated(rcFile, hookContent) {
1643
+ const installed = getInstalledHookBlock(rcFile);
1644
+ if (!installed) return false;
1645
+ return installed.trim() !== hookContent.trim();
1646
+ }
1647
+ function installHook(rcFile, hookContent) {
1648
+ const backup = `${rcFile}.workscribe-backup-${Date.now()}`;
1649
+ import_fs11.default.copyFileSync(rcFile, backup);
1650
+ log.info(`Backed up ${rcFile} \u2192 ${import_path7.default.basename(backup)}`);
1651
+ import_fs11.default.appendFileSync(rcFile, `
1652
+ ${hookContent}
1653
+ `);
1654
+ }
1655
+ function updateHook(rcFile, hookContent) {
1656
+ const backup = `${rcFile}.workscribe-backup-${Date.now()}`;
1657
+ import_fs11.default.copyFileSync(rcFile, backup);
1658
+ log.info(`Backed up ${rcFile} \u2192 ${import_path7.default.basename(backup)}`);
1659
+ const content = import_fs11.default.readFileSync(rcFile, "utf-8");
1660
+ const startIdx = content.indexOf(HOOK_START);
1661
+ const endIdx = content.indexOf(HOOK_END) + HOOK_END.length;
1662
+ import_fs11.default.writeFileSync(rcFile, content.slice(0, startIdx) + hookContent + content.slice(endIdx));
1663
+ }
1664
+ function removeHook(rcFile) {
1665
+ const backup = `${rcFile}.workscribe-backup-${Date.now()}`;
1666
+ import_fs11.default.copyFileSync(rcFile, backup);
1667
+ log.info(`Backed up ${rcFile} \u2192 ${import_path7.default.basename(backup)}`);
1668
+ const content = import_fs11.default.readFileSync(rcFile, "utf-8");
1669
+ const startIdx = content.indexOf(HOOK_START);
1670
+ const endIdx = content.indexOf(HOOK_END) + HOOK_END.length;
1671
+ const preambleIdx = content.lastIndexOf(HOOK_PREAMBLE, startIdx);
1672
+ const blockStart = preambleIdx !== -1 ? preambleIdx : startIdx;
1673
+ const before = content.slice(0, blockStart).replace(/\n+$/, "\n");
1674
+ const after = content.slice(endIdx).replace(/^\n/, "");
1675
+ import_fs11.default.writeFileSync(rcFile, before + after);
1676
+ }
1677
+ function isCompletionsInstalled(shell) {
1678
+ if (shell.name === "fish") {
1679
+ return import_fs11.default.existsSync(import_path7.default.join(FISH_COMPLETIONS_DIR, "workscribe.fish"));
1680
+ }
1681
+ try {
1682
+ return import_fs11.default.readFileSync(shell.rcFile, "utf-8").includes("workscribe completions");
1683
+ } catch {
1684
+ return false;
1685
+ }
1686
+ }
1687
+ function installCompletions(shell, commands) {
1688
+ if (shell.name === "fish") {
1689
+ if (!import_fs11.default.existsSync(FISH_COMPLETIONS_DIR)) {
1690
+ import_fs11.default.mkdirSync(FISH_COMPLETIONS_DIR, { recursive: true });
1691
+ }
1692
+ import_fs11.default.writeFileSync(import_path7.default.join(FISH_COMPLETIONS_DIR, "workscribe.fish"), fishCompletion(commands), { mode: 420 });
1693
+ } else {
1694
+ const script = shell.name === "zsh" ? zshCompletion(commands) : bashCompletion(commands);
1695
+ const cacheFile = import_path7.default.join(WORKSCRIBE_DIR, `completion.${shell.name}`);
1696
+ import_fs11.default.writeFileSync(cacheFile, script, { mode: 420 });
1697
+ const sourceLine = shell.name === "zsh" ? `
1698
+ # Workscribe completions
1699
+ autoload -Uz compinit && compinit
1700
+ source "${cacheFile}"
1701
+ ` : `
1702
+ # Workscribe completions
1703
+ source "${cacheFile}"
1704
+ `;
1705
+ import_fs11.default.appendFileSync(shell.rcFile, sourceLine);
1706
+ }
1707
+ }
1708
+
1709
+ // src/capture/hooks/claude-code.ts
1710
+ var import_fs12 = __toESM(require("fs"));
1711
+ var import_path8 = __toESM(require("path"));
1712
+ var import_os4 = __toESM(require("os"));
1713
+
1714
+ // src/capture/hooks/utils.ts
1715
+ var import_child_process3 = require("child_process");
1716
+ function isCommandInstalled(cmd) {
1717
+ try {
1718
+ (0, import_child_process3.execSync)(`which ${cmd}`, { stdio: "ignore" });
1719
+ return true;
1720
+ } catch {
1721
+ return false;
1722
+ }
1723
+ }
1724
+
1725
+ // src/capture/hooks/claude-code.ts
1726
+ var CLAUDE_SETTINGS_PATH = import_path8.default.join(import_os4.default.homedir(), ".claude", "settings.json");
1727
+ function readSettings() {
1728
+ if (!import_fs12.default.existsSync(CLAUDE_SETTINGS_PATH)) return {};
1729
+ try {
1730
+ return JSON.parse(import_fs12.default.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
1731
+ } catch {
1732
+ return {};
1733
+ }
1734
+ }
1735
+ var claudeCodeIntegration = {
1736
+ displayName: "Claude Code",
1737
+ isInstalled() {
1738
+ return isCommandInstalled("claude");
1739
+ },
1740
+ isHookInstalled() {
1741
+ const settings = readSettings();
1742
+ const postToolUse = settings.hooks?.PostToolUse ?? [];
1743
+ return postToolUse.some((entry) => {
1744
+ const hooks = entry.hooks ?? [];
1745
+ return hooks.some((hookItem) => {
1746
+ const hook = hookItem;
1747
+ return typeof hook.command === "string" && hook.command.includes("workscribe _hook");
1748
+ });
1749
+ });
1750
+ },
1751
+ install() {
1752
+ const settings = readSettings();
1753
+ const existingHooks = settings.hooks ?? {};
1754
+ const existingPostToolUse = existingHooks.PostToolUse ?? [];
1755
+ settings.hooks = {
1756
+ ...existingHooks,
1757
+ PostToolUse: [
1758
+ ...existingPostToolUse,
1759
+ {
1760
+ matcher: "Bash",
1761
+ hooks: [{ type: "command", command: "workscribe _hook --source claude-code", async: true }]
1762
+ }
1763
+ ]
1764
+ };
1765
+ import_fs12.default.mkdirSync(import_path8.default.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
1766
+ import_fs12.default.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
1767
+ }
1768
+ };
1769
+
1770
+ // src/capture/hooks/index.ts
1771
+ var AI_HOOK_INTEGRATIONS = {
1772
+ "claude-code": claudeCodeIntegration
1773
+ };
1774
+
1775
+ // src/cli/completions.ts
1776
+ var HIDDEN_COMMANDS = /* @__PURE__ */ new Set(["_capture", "_hook"]);
1777
+ function getPublicCommands(program2) {
1778
+ return program2.commands.filter((command) => !HIDDEN_COMMANDS.has(command.name())).map((command) => ({ name: command.name(), description: command.description() }));
1779
+ }
1780
+ function registerCompletions(program2) {
1781
+ program2.command("completions [shell]").description("Print shell completion script (bash, zsh, fish)").action((shell) => {
1782
+ const detected = shell ?? (process.env.SHELL ?? "").split("/").at(-1);
1783
+ if (!detected || !["bash", "zsh", "fish"].includes(detected)) {
1784
+ log.error(`Unknown shell: "${detected ?? ""}". Supported: bash, zsh, fish`);
1785
+ log.plain(` Usage: workscribe completions <bash|zsh|fish>`);
1786
+ process.exit(1);
1787
+ }
1788
+ const commands = getPublicCommands(program2);
1789
+ const script = detected === "bash" ? bashCompletion(commands) : detected === "zsh" ? zshCompletion(commands) : fishCompletion(commands);
1790
+ process.stdout.write(script + "\n");
1791
+ });
1792
+ }
1793
+
1794
+ // src/cli/init.ts
1795
+ function detectShell() {
1796
+ const shell = process.env.SHELL ?? "";
1797
+ if (shell.includes("zsh")) return { name: "zsh", rcFile: import_path9.default.join(import_os5.default.homedir(), ".zshrc") };
1798
+ if (shell.includes("bash")) return { name: "bash", rcFile: import_path9.default.join(import_os5.default.homedir(), ".bashrc") };
1799
+ if (shell.includes("fish")) {
1800
+ const confDir = import_path9.default.join(import_os5.default.homedir(), ".config", "fish", "conf.d");
1801
+ return { name: "fish", rcFile: import_path9.default.join(confDir, "workscribe.fish") };
1802
+ }
1803
+ return null;
1804
+ }
1805
+ function step(current, total, label) {
1806
+ log.plain(import_chalk2.default.bold(`
1807
+ [${current}/${total}] ${label}`));
1808
+ }
1809
+ function registerInit(program2) {
1810
+ program2.command("init").description("Set up Workscribe and install or update shell hooks").action(async () => {
1811
+ const TOTAL_STEPS = 5;
1812
+ log.plain(import_chalk2.default.bold("\n Workscribe \u2014 Developer Work Journal\n"));
1813
+ step(1, TOTAL_STEPS, "Creating data directories");
1814
+ for (const dir of REQUIRED_DIRS) {
1815
+ if (!import_fs13.default.existsSync(dir)) {
1816
+ import_fs13.default.mkdirSync(dir, { recursive: true, mode: 448 });
1817
+ log.success(`Created ${dir}`);
1818
+ } else {
1819
+ try {
1820
+ import_fs13.default.chmodSync(dir, 448);
1821
+ } catch {
1822
+ }
1823
+ log.info(`${dir} already exists`);
1824
+ }
1825
+ }
1826
+ if (!import_fs13.default.existsSync(PROFILES_DIR)) {
1827
+ import_fs13.default.mkdirSync(PROFILES_DIR, { recursive: true, mode: 448 });
1828
+ }
1829
+ step(2, TOTAL_STEPS, "Initialising database");
1830
+ getDb();
1831
+ log.success("Database ready");
1832
+ step(3, TOTAL_STEPS, "Installing shell hook");
1833
+ const shell = detectShell();
1834
+ if (!shell) {
1835
+ log.warn("Could not detect shell. Supported: zsh, bash, fish");
1836
+ log.plain(
1837
+ ` Manually add the hook from: ${import_chalk2.default.cyan("shell/hook.zsh")}, ${import_chalk2.default.cyan("shell/hook.bash")}, or ${import_chalk2.default.cyan("shell/hook.fish")}`
1838
+ );
1839
+ } else {
1840
+ const hookFile = import_path9.default.join(__dirname, "..", "shell", `hook.${shell.name}`);
1841
+ if (!import_fs13.default.existsSync(hookFile)) {
1842
+ log.warn(`Hook template not found at ${hookFile}`);
1843
+ } else {
1844
+ const hookContent = import_fs13.default.readFileSync(hookFile, "utf-8");
1845
+ if (shell.name === "fish") {
1846
+ const confDir = import_path9.default.dirname(shell.rcFile);
1847
+ if (!import_fs13.default.existsSync(confDir)) import_fs13.default.mkdirSync(confDir, { recursive: true });
1848
+ }
1849
+ if (!import_fs13.default.existsSync(shell.rcFile)) import_fs13.default.writeFileSync(shell.rcFile, "");
1850
+ if (!isHookInstalled(shell.rcFile)) {
1851
+ log.plain("");
1852
+ log.plain(import_chalk2.default.dim(" Workscribe adds a lightweight hook to your shell that captures each"));
1853
+ log.plain(import_chalk2.default.dim(" command you run \u2014 the text, exit code, duration, and working directory."));
1854
+ log.plain(import_chalk2.default.dim(" It runs in the background and adds no latency to your terminal."));
1855
+ log.plain(import_chalk2.default.dim(" Credentials and secrets are redacted before anything is stored."));
1856
+ log.plain("");
1857
+ installHook(shell.rcFile, hookContent);
1858
+ log.success(`Shell hook installed in ${shell.rcFile}`);
1859
+ } else if (isHookOutdated(shell.rcFile, hookContent)) {
1860
+ const { update } = await (0, import_prompts.default)({
1861
+ type: "confirm",
1862
+ name: "update",
1863
+ message: `Shell hook in ${shell.rcFile} is outdated. Update?`,
1864
+ initial: true
1865
+ });
1866
+ if (update) {
1867
+ updateHook(shell.rcFile, hookContent);
1868
+ log.success(`Shell hook updated in ${shell.rcFile}`);
1869
+ } else {
1870
+ log.info("Shell hook update skipped");
1871
+ }
1872
+ } else {
1873
+ log.info(`Shell hook up to date in ${shell.rcFile}`);
1874
+ }
1875
+ if (isCompletionsInstalled(shell)) {
1876
+ log.info("Shell completions already installed");
1877
+ } else {
1878
+ const { installComps } = await (0, import_prompts.default)({
1879
+ type: "confirm",
1880
+ name: "installComps",
1881
+ message: "Install shell completions (tab-complete workscribe commands)?",
1882
+ initial: true
1883
+ });
1884
+ if (installComps) {
1885
+ try {
1886
+ installCompletions(shell, getPublicCommands(program2));
1887
+ log.success("Shell completions installed");
1888
+ } catch (error) {
1889
+ log.warn(`Completions install failed: ${error.message}`);
1890
+ }
1891
+ }
1892
+ }
1893
+ }
1894
+ }
1895
+ step(4, TOTAL_STEPS, "Detecting AI coding tools");
1896
+ const installedTools = loadAIToolRegistry().filter(
1298
1897
  (entry) => entry.hook !== null && AI_HOOK_INTEGRATIONS[entry.hook]?.isInstalled()
1299
1898
  );
1300
1899
  if (installedTools.length === 0) {
@@ -1348,6 +1947,8 @@ function registerInit(program2) {
1348
1947
  } catch {
1349
1948
  log.info("Could not reach profile registry \u2014 run workscribe profile detect after connecting");
1350
1949
  }
1950
+ step(5, TOTAL_STEPS, "Configuring AI provider");
1951
+ let providerConfigured = !!config.ai.provider;
1351
1952
  if (config.ai.provider) {
1352
1953
  log.info(`AI provider already configured: ${import_chalk2.default.cyan(config.ai.provider)}`);
1353
1954
  } else {
@@ -1364,7 +1965,10 @@ function registerInit(program2) {
1364
1965
  { title: "Skip \u2014 configure later", value: "skip" }
1365
1966
  ]
1366
1967
  });
1367
- if (provider && provider !== "skip") {
1968
+ if (!provider || provider === "skip") {
1969
+ providerConfigured = false;
1970
+ log.info("Skipped \u2014 run later: workscribe config set ai.provider <provider>");
1971
+ } else {
1368
1972
  config.ai.provider = provider;
1369
1973
  if (provider === "ollama") {
1370
1974
  log.plain(`
@@ -1389,90 +1993,78 @@ function registerInit(program2) {
1389
1993
  if (baseUrl) config.ai.baseUrl = baseUrl;
1390
1994
  }
1391
1995
  }
1392
- writeConfig(config);
1393
- log.success(`AI provider set to ${import_chalk2.default.cyan(provider)}`);
1394
- } else if (provider === "skip" || !provider) {
1395
- log.info("Skipped \u2014 run later: workscribe config set ai.provider <provider>");
1996
+ log.plain("");
1997
+ log.info(`Validating ${import_chalk2.default.cyan(provider)} connection\u2026`);
1998
+ try {
1999
+ const testProvider = getProvider(config);
2000
+ await testProvider.validate();
2001
+ log.success("API key verified");
2002
+ writeConfig(config);
2003
+ providerConfigured = true;
2004
+ } catch (error) {
2005
+ log.warn(`Validation failed: ${error.message}`);
2006
+ const { retry } = await (0, import_prompts.default)({
2007
+ type: "confirm",
2008
+ name: "retry",
2009
+ message: "Save anyway and configure later?",
2010
+ initial: false
2011
+ });
2012
+ if (retry) {
2013
+ writeConfig(config);
2014
+ providerConfigured = false;
2015
+ log.info("Config saved \u2014 run workscribe config set ai.apiKey <key> to update");
2016
+ } else {
2017
+ log.info("API key not saved \u2014 run workscribe init again to configure");
2018
+ providerConfigured = false;
2019
+ }
2020
+ }
1396
2021
  }
1397
2022
  }
1398
- if (!import_fs12.default.existsSync(CONFIG_PATH)) {
2023
+ if (!import_fs13.default.existsSync(CONFIG_PATH)) {
1399
2024
  writeConfig(config);
1400
- log.success("Config file created");
1401
2025
  }
1402
2026
  log.plain("");
1403
2027
  log.plain(import_chalk2.default.bold(" Setup complete!"));
2028
+ log.plain("");
1404
2029
  if (shell) {
2030
+ log.plain(` Activate capture now:`);
2031
+ if (shell.name === "fish") {
2032
+ log.plain(` Restart your terminal`);
2033
+ log.plain(` ${import_chalk2.default.dim(`or run: source ${shell.rcFile}`)}`);
2034
+ } else {
2035
+ log.plain(` ${import_chalk2.default.cyan(`source ${shell.rcFile}`)}`);
2036
+ log.plain(` Or restart your terminal.`);
2037
+ }
1405
2038
  log.plain("");
1406
- log.plain(` Activate capture now: ${import_chalk2.default.cyan(`source ${shell.rcFile}`)}`);
1407
- log.plain(` Or restart your terminal.`);
1408
2039
  }
1409
- log.plain("");
1410
- log.plain(` ${import_chalk2.default.dim("workscribe summary")} \u2014 generate today's work summary`);
1411
- log.plain(` ${import_chalk2.default.dim("workscribe status")} \u2014 check capture is working`);
2040
+ if (providerConfigured) {
2041
+ log.plain(` Then run a few commands and generate your first summary:`);
2042
+ log.plain(` ${import_chalk2.default.cyan("workscribe summary")}`);
2043
+ log.plain("");
2044
+ log.plain(` Check capture is working:`);
2045
+ log.plain(` ${import_chalk2.default.cyan("workscribe status")}`);
2046
+ } else {
2047
+ log.plain(` Before generating summaries, configure an AI provider:`);
2048
+ log.plain(` ${import_chalk2.default.cyan("workscribe config set ai.provider anthropic")}`);
2049
+ log.plain(` ${import_chalk2.default.cyan("workscribe config set ai.apiKey <your-key>")}`);
2050
+ log.plain("");
2051
+ log.plain(` Then run:`);
2052
+ log.plain(` ${import_chalk2.default.cyan("workscribe summary")}`);
2053
+ }
1412
2054
  log.plain("");
1413
2055
  });
1414
2056
  }
1415
2057
 
1416
2058
  // src/cli/status.ts
1417
- var import_fs13 = __toESM(require("fs"));
2059
+ var import_fs14 = __toESM(require("fs"));
1418
2060
  var import_chalk3 = __toESM(require("chalk"));
1419
-
1420
- // src/utils/format.ts
1421
- function formatDuration(minutes) {
1422
- if (minutes < 60) return `${Math.round(minutes)}m`;
1423
- const h = Math.floor(minutes / 60);
1424
- const m = Math.round(minutes % 60);
1425
- return m > 0 ? `${h}h ${m}m` : `${h}h`;
1426
- }
1427
- function formatDate(isoString) {
1428
- return new Date(isoString).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1429
- }
1430
- function todayDate() {
1431
- return localDateStr(/* @__PURE__ */ new Date());
1432
- }
1433
- function parseDate(input) {
1434
- const d = /* @__PURE__ */ new Date(input + "T00:00:00");
1435
- if (isNaN(d.getTime())) throw new Error(`Invalid date: ${input}`);
1436
- return input;
1437
- }
1438
- function localDateStr(d) {
1439
- const y = d.getFullYear();
1440
- const m = String(d.getMonth() + 1).padStart(2, "0");
1441
- const day = String(d.getDate()).padStart(2, "0");
1442
- return `${y}-${m}-${day}`;
1443
- }
1444
- function startOfWeek(dateStr) {
1445
- const d = /* @__PURE__ */ new Date(dateStr + "T00:00:00");
1446
- const day = d.getDay();
1447
- const diff = day === 0 ? -6 : 1 - day;
1448
- d.setDate(d.getDate() + diff);
1449
- return localDateStr(d);
1450
- }
1451
- function eachDayOfRange(start, end) {
1452
- const days = [];
1453
- const cur = /* @__PURE__ */ new Date(start + "T00:00:00");
1454
- const last = /* @__PURE__ */ new Date(end + "T00:00:00");
1455
- while (cur <= last) {
1456
- days.push(localDateStr(cur));
1457
- cur.setDate(cur.getDate() + 1);
1458
- }
1459
- return days;
1460
- }
1461
- function isoWeekKey(dateStr) {
1462
- const d = /* @__PURE__ */ new Date(dateStr + "T00:00:00");
1463
- const jan4 = new Date(d.getFullYear(), 0, 4);
1464
- const weekNum = Math.ceil(((d.getTime() - jan4.getTime()) / 864e5 + jan4.getDay() + 1) / 7);
1465
- return `${d.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
1466
- }
1467
-
1468
- // src/cli/status.ts
1469
2061
  function registerStatus(program2) {
1470
2062
  program2.command("status").description("Show capture status and event counts").action(() => {
1471
2063
  const config = readConfig();
1472
2064
  const total = getEventCount();
1473
2065
  const todayEvents = getEventsForDate(todayDate());
1474
2066
  const last = getLastEvent();
1475
- const isPaused = import_fs13.default.existsSync(PAUSE_FLAG_PATH);
2067
+ const isPaused = import_fs14.default.existsSync(PAUSE_FLAG_PATH);
1476
2068
  log.plain("");
1477
2069
  log.plain(import_chalk3.default.bold(" Workscribe Status"));
1478
2070
  log.plain("");
@@ -1515,181 +2107,28 @@ function registerStatus(program2) {
1515
2107
  }
1516
2108
 
1517
2109
  // src/cli/events.ts
1518
- var import_chalk4 = __toESM(require("chalk"));
1519
- var CATEGORY_COLORS = {
1520
- shipping: import_chalk4.default.green,
1521
- versioning: import_chalk4.default.blue,
1522
- building: import_chalk4.default.cyan,
1523
- running: import_chalk4.default.white,
1524
- testing: import_chalk4.default.magenta,
1525
- linting: import_chalk4.default.yellow,
1526
- dependencies: import_chalk4.default.yellow,
1527
- infrastructure: import_chalk4.default.red,
1528
- releasing: import_chalk4.default.bold.green,
1529
- database: import_chalk4.default.bold.blue,
1530
- files: import_chalk4.default.dim.yellow,
1531
- exploring: import_chalk4.default.dim.cyan,
1532
- ai_session: import_chalk4.default.bold.magenta,
1533
- other: import_chalk4.default.gray
1534
- };
1535
- var AI_TOOL_DISPLAY_NAMES = Object.fromEntries(
1536
- loadAIToolRegistry().map((entry) => [entry.tool, entry.displayName])
1537
- );
1538
- function registerEvents(program2) {
1539
- program2.command("events").description("List captured events").option("--date <YYYY-MM-DD>", "date to list events for (default: today)").option("--limit <n>", "max events to show", parseInt).action((opts) => {
1540
- let date = todayDate();
1541
- if (opts.date) {
1542
- try {
1543
- date = parseDate(opts.date);
1544
- } catch (e) {
1545
- log.error(e.message);
1546
- process.exit(1);
1547
- }
1548
- }
1549
- const events = getEventsForDate(date);
1550
- const limit = opts.limit ?? events.length;
1551
- if (events.length === 0) {
1552
- log.plain(`No events captured for ${date}`);
1553
- return;
1554
- }
1555
- log.plain("");
1556
- log.plain(import_chalk4.default.bold(` Events for ${date} (${events.length} total)`));
1557
- log.plain("");
1558
- for (const event of events.slice(0, limit)) {
1559
- const colorFn = CATEGORY_COLORS[event.category] ?? import_chalk4.default.gray;
1560
- const time = formatDate(event.captured_at);
1561
- const category = colorFn(event.category.padEnd(20));
1562
- const repo = event.repo_name ? import_chalk4.default.dim(` [${event.repo_name}]`) : "";
1563
- const exitBadge = event.exit_code && event.exit_code !== 0 ? import_chalk4.default.red(` \u2717${event.exit_code}`) : "";
1564
- let cmdDisplay;
1565
- if (event.category === "ai_session") {
1566
- let meta = {};
1567
- try {
1568
- meta = event.metadata ? JSON.parse(event.metadata) : {};
1569
- } catch {
1570
- }
1571
- const toolKey = typeof meta.tool === "string" ? meta.tool : "";
1572
- const toolName = AI_TOOL_DISPLAY_NAMES[toolKey] ?? toolKey;
1573
- cmdDisplay = import_chalk4.default.white(toolName.padEnd(20)) + import_chalk4.default.magenta(" \u2726");
1574
- } else {
1575
- cmdDisplay = import_chalk4.default.dim(event.command.slice(0, 55));
1576
- }
1577
- log.plain(` ${import_chalk4.default.dim(time)} ${category} ${cmdDisplay}${repo}${exitBadge}`);
1578
- }
1579
- if (limit < events.length) {
1580
- log.plain("");
1581
- log.plain(import_chalk4.default.dim(` \u2026 and ${events.length - limit} more (remove --limit to see all)`));
1582
- }
1583
- log.plain("");
1584
- });
1585
- }
1586
-
1587
- // src/cli/sessions.ts
1588
- var import_chalk5 = __toESM(require("chalk"));
1589
-
1590
- // src/sessions/grouper.ts
1591
- function splitByIdleGap(events, idleMs) {
1592
- if (events.length === 0) return [];
1593
- const groups = [[events[0]]];
1594
- for (let i = 1; i < events.length; i++) {
1595
- const prev = new Date(events[i - 1].captured_at).getTime();
1596
- const curr = new Date(events[i].captured_at).getTime();
1597
- if (curr - prev >= idleMs) {
1598
- groups.push([events[i]]);
1599
- } else {
1600
- groups[groups.length - 1].push(events[i]);
1601
- }
1602
- }
1603
- return groups;
1604
- }
1605
- function splitByRepo(events) {
1606
- if (events.length === 0) return [];
1607
- const groups = [[events[0]]];
1608
- for (let i = 1; i < events.length; i++) {
1609
- const prevRepo = events[i - 1].repo_name;
1610
- const currRepo = events[i].repo_name;
1611
- if (currRepo !== prevRepo) {
1612
- groups.push([events[i]]);
1613
- } else {
1614
- groups[groups.length - 1].push(events[i]);
1615
- }
1616
- }
1617
- return groups;
1618
- }
1619
- function minutesBetween(start, end) {
1620
- return (new Date(end).getTime() - new Date(start).getTime()) / 6e4;
1621
- }
1622
-
1623
- // src/sessions/detector.ts
1624
- function getOrBuildSessions(date) {
1625
- const existing = getSessionsForDate(date);
1626
- const events = getEventsForDate(date);
1627
- if (events.length === 0) return [];
1628
- const cachedEventCount = existing.reduce((sum, sessionRow) => sum + sessionRow.event_count, 0);
1629
- if (existing.length > 0 && cachedEventCount === events.length) {
1630
- return existing.map((sessionRow) => ({
1631
- id: sessionRow.id,
1632
- date: sessionRow.date,
1633
- startedAt: sessionRow.started_at,
1634
- endedAt: sessionRow.ended_at,
1635
- repoName: sessionRow.repo_name,
1636
- durationMinutes: minutesBetween(sessionRow.started_at, sessionRow.ended_at),
1637
- eventCount: sessionRow.event_count,
1638
- summary: sessionRow.summary,
1639
- events: getEventsBySessionId(sessionRow.id)
1640
- }));
1641
- }
1642
- const savedSummaries = /* @__PURE__ */ new Map();
1643
- for (const s of existing) {
1644
- if (s.summary) savedSummaries.set(`${s.repo_name}|${s.started_at}`, s.summary);
1645
- }
1646
- clearSessionsForDate(date);
1647
- return buildSessions(date, events, savedSummaries);
1648
- }
1649
- function buildSessions(date, events, savedSummaries = /* @__PURE__ */ new Map()) {
1650
- const config = readConfig();
1651
- const idleMs = config.session.idleTimeout * 60 * 1e3;
1652
- const sessions = [];
1653
- const timeGroups = splitByIdleGap(events, idleMs);
1654
- for (const group of timeGroups) {
1655
- if (group.length === 0) continue;
1656
- const repoGroups = splitByRepo(group);
1657
- for (const repoGroup of repoGroups) {
1658
- if (repoGroup.length === 0) continue;
1659
- const first = repoGroup[0];
1660
- const last = repoGroup[repoGroup.length - 1];
1661
- const repoName = last.repo_name ?? first.repo_name ?? null;
1662
- const summary = savedSummaries.get(`${repoName}|${first.captured_at}`) ?? null;
1663
- const sessionId = insertSession({
1664
- date,
1665
- started_at: first.captured_at,
1666
- ended_at: last.captured_at,
1667
- repo_name: repoName,
1668
- event_count: repoGroup.length,
1669
- summary
1670
- });
1671
- for (const event of repoGroup) {
1672
- insertSessionEvent(sessionId, event.id);
1673
- }
1674
- sessions.push({
1675
- id: sessionId,
1676
- date,
1677
- startedAt: first.captured_at,
1678
- endedAt: last.captured_at,
1679
- repoName,
1680
- durationMinutes: minutesBetween(first.captured_at, last.captured_at),
1681
- eventCount: repoGroup.length,
1682
- summary,
1683
- events: repoGroup
1684
- });
1685
- }
1686
- }
1687
- return sessions;
1688
- }
1689
-
1690
- // src/cli/sessions.ts
1691
- function registerSessions(program2) {
1692
- program2.command("sessions").description("List detected work sessions").option("--date <YYYY-MM-DD>", "date to show sessions for (default: today)").action((opts) => {
2110
+ var import_chalk4 = __toESM(require("chalk"));
2111
+ var CATEGORY_COLORS = {
2112
+ shipping: import_chalk4.default.green,
2113
+ versioning: import_chalk4.default.blue,
2114
+ building: import_chalk4.default.cyan,
2115
+ running: import_chalk4.default.white,
2116
+ testing: import_chalk4.default.magenta,
2117
+ linting: import_chalk4.default.yellow,
2118
+ dependencies: import_chalk4.default.yellow,
2119
+ infrastructure: import_chalk4.default.red,
2120
+ releasing: import_chalk4.default.bold.green,
2121
+ database: import_chalk4.default.bold.blue,
2122
+ files: import_chalk4.default.dim.yellow,
2123
+ exploring: import_chalk4.default.dim.cyan,
2124
+ ai_session: import_chalk4.default.bold.magenta,
2125
+ other: import_chalk4.default.gray
2126
+ };
2127
+ var AI_TOOL_DISPLAY_NAMES = Object.fromEntries(
2128
+ loadAIToolRegistry().map((entry) => [entry.tool, entry.displayName])
2129
+ );
2130
+ function registerEvents(program2) {
2131
+ program2.command("events").description("List captured events").option("--date <YYYY-MM-DD>", "date to list events for (default: today)").option("--limit <n>", "max events to show", parseInt).action((opts) => {
1693
2132
  let date = todayDate();
1694
2133
  if (opts.date) {
1695
2134
  try {
@@ -1699,383 +2138,196 @@ function registerSessions(program2) {
1699
2138
  process.exit(1);
1700
2139
  }
1701
2140
  }
1702
- const sessions = getOrBuildSessions(date);
1703
- if (sessions.length === 0) {
1704
- log.plain(`No sessions detected for ${date}`);
2141
+ const events = getEventsForDate(date);
2142
+ const limit = opts.limit ?? events.length;
2143
+ if (events.length === 0) {
2144
+ log.plain(`No events captured for ${date}`);
1705
2145
  return;
1706
2146
  }
1707
2147
  log.plain("");
1708
- log.plain(import_chalk5.default.bold(` Sessions for ${date} (${sessions.length} session${sessions.length > 1 ? "s" : ""})`));
1709
- log.plain("");
1710
- for (const session of sessions) {
1711
- const start = formatDate(session.startedAt);
1712
- const end = formatDate(session.endedAt);
1713
- const duration = formatDuration(session.durationMinutes);
1714
- const repo = session.repoName ? import_chalk5.default.cyan(session.repoName) : import_chalk5.default.gray("no repo");
1715
- log.plain(
1716
- ` ${import_chalk5.default.dim(start)} \u2013 ${import_chalk5.default.dim(end)} ${repo} ${import_chalk5.default.dim(duration)} ${import_chalk5.default.dim(session.eventCount + " events")}`
1717
- );
1718
- }
2148
+ log.plain(import_chalk4.default.bold(` Events for ${date} (${events.length} total)`));
1719
2149
  log.plain("");
1720
- });
1721
- }
1722
-
1723
- // src/cli/summary.ts
1724
- var import_chalk6 = __toESM(require("chalk"));
1725
- var import_fs14 = __toESM(require("fs"));
1726
- var import_os5 = __toESM(require("os"));
1727
- var import_path10 = __toESM(require("path"));
1728
-
1729
- // src/ai/prompt.ts
1730
- var SYSTEM_PROMPT = `You write short developer work summaries. Here is an example:
1731
-
1732
- INPUT:
1733
- Project: api-service
1734
- Branch: feature/auth
1735
- Duration: 2h 00m
1736
- Events:
1737
- - versioning {"branch":"feature/auth"}
1738
- - 2x dependencies {"tool":"npm","packages":["bcrypt","jsonwebtoken"]}
1739
- - 2x testing {"tool":"jest"} [failed]
1740
- - testing {"tool":"jest"}
1741
- - 2x files {"tool":"mkdir"}
1742
- - shipping {"message":"fix: auth token validation"}
1743
- - shipping {"remote":"origin","branch":"feature/auth"}
1744
-
1745
- OUTPUT:
1746
- - Switched to the authentication feature branch and added authentication dependencies (bcrypt, JWT)
1747
- - Resolved failing auth tests
1748
- - Committed and pushed authentication fixes
1749
-
1750
- For dependencies events with a packages field: infer the purpose of packages from their names and describe the domain, not just the packages. Examples: bcrypt/jsonwebtoken \u2192 authentication, prisma/mongoose/pg \u2192 database, jest/vitest/mocha \u2192 testing, axios/got/node-fetch \u2192 HTTP client, stripe/paypal \u2192 payments, socket.io/ws \u2192 real-time. Use your knowledge of common packages. For unfamiliar packages, name them directly without guessing their domain.
1751
-
1752
- Events like shipping (pushes), building, versioning (branch switches, stash), files (file ops, env vars), and infrastructure provide context \u2014 fold them into the surrounding narrative rather than listing them as separate bullets. Only surface them directly when they are the main activity (e.g. a standalone build fix, a deploy, or a significant restructure with no accompanying commits).
1753
-
1754
- For ai_session events: describe what was built during the AI session using the tool name (e.g. "via Claude Code"). If a commits array is present in metadata, use those commit messages to describe what was built. Group all activity from the session into one narrative bullet.
1755
-
1756
- For other events with a hint field: interpret the command snippet and describe the activity in plain language. Do not quote the raw command.
1757
-
1758
- Maximum 5 bullets. Output ONLY the bullet points, nothing else.`;
1759
- function getSystemPrompt() {
1760
- const contexts = getActiveProfileAiContexts();
1761
- if (contexts.length === 0) return SYSTEM_PROMPT;
1762
- return SYSTEM_PROMPT + "\n\n" + contexts.join("\n");
1763
- }
1764
- var EXCLUDED = /* @__PURE__ */ new Set(["exploring"]);
1765
- function buildUserMessage(payload) {
1766
- const lines = [`Date: ${payload.date}`, ""];
1767
- for (const session of payload.sessions) {
1768
- lines.push(`Project: ${session.project ?? "unknown"}`);
1769
- if (session.branch) lines.push(`Branch: ${session.branch}`);
1770
- lines.push(`Duration: ${formatDuration(session.durationMinutes)}`);
1771
- lines.push("Events:");
1772
- const counts = /* @__PURE__ */ new Map();
1773
- for (const event of session.events) {
1774
- if (EXCLUDED.has(event.category)) continue;
1775
- const failed = event.exitCode !== null && event.exitCode !== 0 ? " [failed]" : "";
1776
- let key;
1777
- if (event.hint) {
1778
- key = `other [${event.hint}]${failed}`;
2150
+ for (const event of events.slice(0, limit)) {
2151
+ const colorFn = CATEGORY_COLORS[event.category] ?? import_chalk4.default.gray;
2152
+ const time = formatDate(event.captured_at);
2153
+ const category = colorFn(event.category.padEnd(20));
2154
+ const repo = event.repo_name ? import_chalk4.default.dim(` [${event.repo_name}]`) : "";
2155
+ const exitBadge = event.exit_code && event.exit_code !== 0 ? import_chalk4.default.red(` \u2717${event.exit_code}`) : "";
2156
+ let cmdDisplay;
2157
+ if (event.category === "ai_session") {
2158
+ let meta = {};
2159
+ try {
2160
+ meta = event.metadata ? JSON.parse(event.metadata) : {};
2161
+ } catch {
2162
+ }
2163
+ const toolKey = typeof meta.tool === "string" ? meta.tool : "";
2164
+ const toolName = AI_TOOL_DISPLAY_NAMES[toolKey] ?? toolKey;
2165
+ cmdDisplay = import_chalk4.default.white(toolName.padEnd(20)) + import_chalk4.default.magenta(" \u2726");
1779
2166
  } else {
1780
- const meta = Object.keys(event.metadata).length > 0 ? ` ${JSON.stringify(event.metadata)}` : "";
1781
- key = `${event.category}${meta}${failed}`;
2167
+ cmdDisplay = import_chalk4.default.dim(event.command.slice(0, 55));
1782
2168
  }
1783
- counts.set(key, (counts.get(key) ?? 0) + 1);
2169
+ log.plain(` ${import_chalk4.default.dim(time)} ${category} ${cmdDisplay}${repo}${exitBadge}`);
1784
2170
  }
1785
- for (const [key, count] of counts) {
1786
- lines.push(` - ${count > 1 ? `${count}x ` : ""}${key}`);
2171
+ if (limit < events.length) {
2172
+ log.plain("");
2173
+ log.plain(import_chalk4.default.dim(` \u2026 and ${events.length - limit} more (remove --limit to see all)`));
1787
2174
  }
1788
- lines.push("");
1789
- }
1790
- return lines.join("\n");
2175
+ log.plain("");
2176
+ });
1791
2177
  }
1792
- var WEEKLY_SYSTEM_PROMPT = `You write short weekly developer summaries. Here is an example:
1793
-
1794
- INPUT:
1795
- Week: 2026-05-18 to 2026-05-22
1796
-
1797
- Monday 2026-05-18:
1798
- Project: api-service (feature/auth) \u2014 2h 10m
1799
- Events: 3x code_commit, 2x test_run, dependency_install
1800
-
1801
- Tuesday 2026-05-19:
1802
- Project: api-service (feature/auth) \u2014 1h 30m
1803
- Events: 2x code_commit, test_run, build
1804
-
1805
- Wednesday 2026-05-20:
1806
- Project: dashboard (main) \u2014 3h 00m
1807
- Events: 4x code_commit, 2x build, test_run
1808
-
1809
- Thursday 2026-05-21:
1810
- Project: api-service (feature/auth) \u2014 1h 00m
1811
- Events: code_commit {"message":"feat: merge auth"}, branch_switch
1812
-
1813
- Friday 2026-05-22:
1814
- Project: dashboard (main) \u2014 2h 00m
1815
- Events: 3x code_commit, build
1816
-
1817
- OUTPUT:
1818
- ## Week of 18 May \u2014 9h 40m across 2 projects
1819
-
1820
- **api-service** (Mon\u2013Thu): Built and shipped the auth feature \u2014 JWT setup, token validation fixes, and final merge.
1821
- **dashboard** (Wed, Fri): Active development across 7 commits with builds passing.
1822
2178
 
1823
- Now summarise the following week. Output ONLY the summary \u2014 a one-line header then one bullet per active project. Maximum 4 bullets.`;
1824
- var DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
1825
- function buildWeeklyMessage(payload) {
1826
- const lines = [`Week: ${payload.weekStart} to ${payload.weekEnd}`, ""];
1827
- for (const { date, sessions } of payload.days) {
1828
- const dayName = DAY_NAMES[(/* @__PURE__ */ new Date(date + "T00:00:00")).getDay()];
1829
- lines.push(`${dayName} ${date}:`);
1830
- for (const s of sessions) {
1831
- const project = s.project ?? "unknown";
1832
- const branch = s.branch ? ` (${s.branch})` : "";
1833
- lines.push(` Project: ${project}${branch} \u2014 ${formatDuration(s.durationMinutes)}`);
1834
- const counts = /* @__PURE__ */ new Map();
1835
- for (const e of s.events) {
1836
- if (EXCLUDED.has(e.category)) continue;
1837
- const failed = e.exitCode !== null && e.exitCode !== 0 ? " [failed]" : "";
1838
- let key;
1839
- if (e.hint) {
1840
- key = `other [${e.hint}]${failed}`;
1841
- } else {
1842
- const meta = Object.keys(e.metadata).length > 0 ? ` ${JSON.stringify(e.metadata)}` : "";
1843
- key = `${e.category}${meta}${failed}`;
1844
- }
1845
- counts.set(key, (counts.get(key) ?? 0) + 1);
1846
- }
1847
- const eventLine = [...counts.entries()].map(([k, n]) => `${n > 1 ? `${n}x ` : ""}${k}`).join(", ");
1848
- lines.push(` Events: ${eventLine}`);
1849
- }
1850
- lines.push("");
1851
- }
1852
- return lines.join("\n");
1853
- }
2179
+ // src/cli/sessions.ts
2180
+ var import_chalk5 = __toESM(require("chalk"));
1854
2181
 
1855
- // src/ai/fetch.ts
1856
- var DEFAULT_TIMEOUT_MS = 3e4;
1857
- async function fetchWithTimeout(url, init, timeoutMs = DEFAULT_TIMEOUT_MS) {
1858
- const controller = new AbortController();
1859
- const timer = setTimeout(() => controller.abort(), timeoutMs);
1860
- try {
1861
- return await fetch(url, { ...init, signal: controller.signal });
1862
- } catch (err) {
1863
- if (err.name === "AbortError") {
1864
- throw new Error(`Request timed out after ${timeoutMs / 1e3}s`);
2182
+ // src/sessions/grouper.ts
2183
+ function splitByIdleGap(events, idleMs) {
2184
+ if (events.length === 0) return [];
2185
+ const groups = [[events[0]]];
2186
+ for (let i = 1; i < events.length; i++) {
2187
+ const prev = new Date(events[i - 1].captured_at).getTime();
2188
+ const curr = new Date(events[i].captured_at).getTime();
2189
+ if (curr - prev >= idleMs) {
2190
+ groups.push([events[i]]);
2191
+ } else {
2192
+ groups[groups.length - 1].push(events[i]);
1865
2193
  }
1866
- throw err;
1867
- } finally {
1868
- clearTimeout(timer);
1869
2194
  }
2195
+ return groups;
1870
2196
  }
1871
-
1872
- // src/ai/providers/anthropic.ts
1873
- var AnthropicProvider = class {
1874
- apiKey;
1875
- model;
1876
- constructor(apiKey, model) {
1877
- this.apiKey = apiKey;
1878
- this.model = model || "claude-haiku-4-5-20251001";
1879
- }
1880
- async summarize(payload) {
1881
- const response = await fetchWithTimeout("https://api.anthropic.com/v1/messages", {
1882
- method: "POST",
1883
- headers: {
1884
- "Content-Type": "application/json",
1885
- "x-api-key": this.apiKey,
1886
- "anthropic-version": "2023-06-01"
1887
- },
1888
- body: JSON.stringify({
1889
- model: this.model,
1890
- max_tokens: 1024,
1891
- system: getSystemPrompt(),
1892
- messages: [{ role: "user", content: buildUserMessage(payload) }]
1893
- })
1894
- });
1895
- if (!response.ok) {
1896
- const body = await response.text();
1897
- throw new Error(`Anthropic API error ${response.status}: ${body}`);
1898
- }
1899
- const data = await response.json();
1900
- return data.content.find((block) => block.type === "text")?.text ?? "";
1901
- }
1902
- async summarizeWeek(payload) {
1903
- const response = await fetchWithTimeout("https://api.anthropic.com/v1/messages", {
1904
- method: "POST",
1905
- headers: {
1906
- "Content-Type": "application/json",
1907
- "x-api-key": this.apiKey,
1908
- "anthropic-version": "2023-06-01"
1909
- },
1910
- body: JSON.stringify({
1911
- model: this.model,
1912
- max_tokens: 1024,
1913
- system: WEEKLY_SYSTEM_PROMPT,
1914
- messages: [{ role: "user", content: buildWeeklyMessage(payload) }]
1915
- })
1916
- });
1917
- if (!response.ok) {
1918
- const body = await response.text();
1919
- throw new Error(`Anthropic API error ${response.status}: ${body}`);
1920
- }
1921
- const data = await response.json();
1922
- return data.content.find((block) => block.type === "text")?.text ?? "";
1923
- }
1924
- };
1925
-
1926
- // src/ai/providers/openai.ts
1927
- var OpenAIProvider = class {
1928
- apiKey;
1929
- model;
1930
- baseUrl;
1931
- constructor(apiKey, model, baseUrl) {
1932
- this.apiKey = apiKey;
1933
- this.model = model || "gpt-4o-mini";
1934
- this.baseUrl = baseUrl || "https://api.openai.com/v1";
1935
- }
1936
- async summarize(payload) {
1937
- const response = await fetchWithTimeout(`${this.baseUrl}/chat/completions`, {
1938
- method: "POST",
1939
- headers: {
1940
- "Content-Type": "application/json",
1941
- Authorization: `Bearer ${this.apiKey}`
1942
- },
1943
- body: JSON.stringify({
1944
- model: this.model,
1945
- messages: [
1946
- { role: "system", content: getSystemPrompt() },
1947
- { role: "user", content: buildUserMessage(payload) }
1948
- ],
1949
- max_tokens: 1024
1950
- })
1951
- });
1952
- if (!response.ok) {
1953
- const body = await response.text();
1954
- throw new Error(`OpenAI API error ${response.status}: ${body}`);
1955
- }
1956
- const data = await response.json();
1957
- return data.choices[0]?.message?.content ?? "";
1958
- }
1959
- async summarizeWeek(payload) {
1960
- const response = await fetchWithTimeout(`${this.baseUrl}/chat/completions`, {
1961
- method: "POST",
1962
- headers: {
1963
- "Content-Type": "application/json",
1964
- Authorization: `Bearer ${this.apiKey}`
1965
- },
1966
- body: JSON.stringify({
1967
- model: this.model,
1968
- messages: [
1969
- { role: "system", content: WEEKLY_SYSTEM_PROMPT },
1970
- { role: "user", content: buildWeeklyMessage(payload) }
1971
- ],
1972
- max_tokens: 1024
1973
- })
1974
- });
1975
- if (!response.ok) {
1976
- const body = await response.text();
1977
- throw new Error(`OpenAI API error ${response.status}: ${body}`);
1978
- }
1979
- const data = await response.json();
1980
- return data.choices[0]?.message?.content ?? "";
1981
- }
1982
- };
1983
-
1984
- // src/ai/providers/ollama.ts
1985
- var OllamaProvider = class {
1986
- model;
1987
- baseUrl;
1988
- constructor(model, baseUrl) {
1989
- this.model = model || "llama3";
1990
- this.baseUrl = baseUrl || "http://localhost:11434";
1991
- }
1992
- async summarize(payload) {
1993
- const response = await fetchWithTimeout(`${this.baseUrl}/api/chat`, {
1994
- method: "POST",
1995
- headers: { "Content-Type": "application/json" },
1996
- body: JSON.stringify({
1997
- model: this.model,
1998
- stream: false,
1999
- messages: [
2000
- { role: "system", content: getSystemPrompt() },
2001
- { role: "user", content: buildUserMessage(payload) }
2002
- ],
2003
- options: {
2004
- num_predict: 300,
2005
- // hard cap on output tokens — forces conciseness
2006
- temperature: 0.3
2007
- // lower temperature = more focused, less wandering
2008
- }
2009
- })
2010
- });
2011
- if (!response.ok) {
2012
- const body = await response.text();
2013
- throw new Error(`Ollama error ${response.status}: ${body}. Is Ollama running? Try: ollama serve`);
2014
- }
2015
- const data = await response.json();
2016
- return data.message?.content ?? "";
2017
- }
2018
- async summarizeWeek(payload) {
2019
- const response = await fetchWithTimeout(`${this.baseUrl}/api/chat`, {
2020
- method: "POST",
2021
- headers: { "Content-Type": "application/json" },
2022
- body: JSON.stringify({
2023
- model: this.model,
2024
- stream: false,
2025
- messages: [
2026
- { role: "system", content: WEEKLY_SYSTEM_PROMPT },
2027
- { role: "user", content: buildWeeklyMessage(payload) }
2028
- ],
2029
- options: {
2030
- num_predict: 400,
2031
- temperature: 0.3
2032
- }
2033
- })
2034
- });
2035
- if (!response.ok) {
2036
- const body = await response.text();
2037
- throw new Error(`Ollama error ${response.status}: ${body}. Is Ollama running? Try: ollama serve`);
2197
+ function splitByRepo(events) {
2198
+ if (events.length === 0) return [];
2199
+ const groups = [[events[0]]];
2200
+ for (let i = 1; i < events.length; i++) {
2201
+ const prevRepo = events[i - 1].repo_name;
2202
+ const currRepo = events[i].repo_name;
2203
+ if (currRepo !== prevRepo) {
2204
+ groups.push([events[i]]);
2205
+ } else {
2206
+ groups[groups.length - 1].push(events[i]);
2038
2207
  }
2039
- const data = await response.json();
2040
- return data.message?.content ?? "";
2041
2208
  }
2042
- };
2209
+ return groups;
2210
+ }
2211
+ function minutesBetween(start, end) {
2212
+ return (new Date(end).getTime() - new Date(start).getTime()) / 6e4;
2213
+ }
2043
2214
 
2044
- // src/ai/router.ts
2045
- function getProvider(config) {
2046
- const { provider, apiKey, model, baseUrl } = config.ai;
2047
- if (!provider) {
2048
- throw new Error(
2049
- "No AI provider configured.\nRun: workscribe config set ai.provider <anthropic|openai|ollama|openai-compatible>"
2050
- );
2215
+ // src/sessions/detector.ts
2216
+ function getOrBuildSessions(date) {
2217
+ const existing = getSessionsForDate(date);
2218
+ const events = getEventsForDate(date);
2219
+ if (events.length === 0) return [];
2220
+ const cachedEventCount = existing.reduce((sum, sessionRow) => sum + sessionRow.event_count, 0);
2221
+ if (existing.length > 0 && cachedEventCount === events.length) {
2222
+ return existing.map((sessionRow) => ({
2223
+ id: sessionRow.id,
2224
+ date: sessionRow.date,
2225
+ startedAt: sessionRow.started_at,
2226
+ endedAt: sessionRow.ended_at,
2227
+ repoName: sessionRow.repo_name,
2228
+ durationMinutes: minutesBetween(sessionRow.started_at, sessionRow.ended_at),
2229
+ eventCount: sessionRow.event_count,
2230
+ summary: sessionRow.summary,
2231
+ events: getEventsBySessionId(sessionRow.id)
2232
+ }));
2051
2233
  }
2052
- switch (provider) {
2053
- case "anthropic": {
2054
- if (!apiKey) throw new Error("Anthropic requires an API key.\nRun: workscribe config set ai.apiKey <your-key>");
2055
- return new AnthropicProvider(apiKey, model || void 0);
2056
- }
2057
- case "openai": {
2058
- if (!apiKey) throw new Error("OpenAI requires an API key.\nRun: workscribe config set ai.apiKey <your-key>");
2059
- return new OpenAIProvider(apiKey, model || void 0);
2234
+ const savedSummaries = /* @__PURE__ */ new Map();
2235
+ for (const s of existing) {
2236
+ if (s.summary) savedSummaries.set(`${s.repo_name}|${s.started_at}`, s.summary);
2237
+ }
2238
+ clearSessionsForDate(date);
2239
+ return buildSessions(date, events, savedSummaries);
2240
+ }
2241
+ function buildSessions(date, events, savedSummaries = /* @__PURE__ */ new Map()) {
2242
+ const config = readConfig();
2243
+ const idleMs = config.session.idleTimeout * 60 * 1e3;
2244
+ const sessions = [];
2245
+ const timeGroups = splitByIdleGap(events, idleMs);
2246
+ for (const group of timeGroups) {
2247
+ if (group.length === 0) continue;
2248
+ const repoGroups = splitByRepo(group);
2249
+ for (const repoGroup of repoGroups) {
2250
+ if (repoGroup.length === 0) continue;
2251
+ const first = repoGroup[0];
2252
+ const last = repoGroup[repoGroup.length - 1];
2253
+ const repoName = last.repo_name ?? first.repo_name ?? null;
2254
+ const summary = savedSummaries.get(`${repoName}|${first.captured_at}`) ?? null;
2255
+ const sessionId = insertSession({
2256
+ date,
2257
+ started_at: first.captured_at,
2258
+ ended_at: last.captured_at,
2259
+ repo_name: repoName,
2260
+ event_count: repoGroup.length,
2261
+ summary
2262
+ });
2263
+ for (const event of repoGroup) {
2264
+ insertSessionEvent(sessionId, event.id);
2265
+ }
2266
+ sessions.push({
2267
+ id: sessionId,
2268
+ date,
2269
+ startedAt: first.captured_at,
2270
+ endedAt: last.captured_at,
2271
+ repoName,
2272
+ durationMinutes: minutesBetween(first.captured_at, last.captured_at),
2273
+ eventCount: repoGroup.length,
2274
+ summary,
2275
+ events: repoGroup
2276
+ });
2060
2277
  }
2061
- case "openai-compatible": {
2062
- if (!apiKey)
2063
- throw new Error("This provider requires an API key.\nRun: workscribe config set ai.apiKey <your-key>");
2064
- if (!baseUrl)
2065
- throw new Error("openai-compatible requires a base URL.\nRun: workscribe config set ai.baseUrl <url>");
2066
- return new OpenAIProvider(apiKey, model || void 0, baseUrl);
2278
+ }
2279
+ return sessions;
2280
+ }
2281
+
2282
+ // src/cli/sessions.ts
2283
+ function registerSessions(program2) {
2284
+ program2.command("sessions").description("List detected work sessions").option("--date <YYYY-MM-DD>", "date to show sessions for (default: today)").action((opts) => {
2285
+ let date = todayDate();
2286
+ if (opts.date) {
2287
+ try {
2288
+ date = parseDate(opts.date);
2289
+ } catch (error) {
2290
+ log.error(error.message);
2291
+ process.exit(1);
2292
+ }
2067
2293
  }
2068
- case "ollama": {
2069
- return new OllamaProvider(model || void 0, baseUrl || void 0);
2294
+ const sessions = getOrBuildSessions(date);
2295
+ if (sessions.length === 0) {
2296
+ log.plain(`No sessions detected for ${date}`);
2297
+ return;
2070
2298
  }
2071
- default:
2072
- throw new Error(
2073
- `Unknown provider: "${provider}"
2074
- Supported providers: anthropic, openai, ollama, openai-compatible`
2299
+ log.plain("");
2300
+ log.plain(import_chalk5.default.bold(` Sessions for ${date} (${sessions.length} session${sessions.length > 1 ? "s" : ""})`));
2301
+ log.plain("");
2302
+ for (const session of sessions) {
2303
+ const start = formatDate(session.startedAt);
2304
+ const end = formatDate(session.endedAt);
2305
+ const duration = formatDuration(session.durationMinutes);
2306
+ const repo = session.repoName ? import_chalk5.default.cyan(session.repoName) : import_chalk5.default.gray("no repo");
2307
+ log.plain(
2308
+ ` ${import_chalk5.default.dim(start)} \u2013 ${import_chalk5.default.dim(end)} ${repo} ${import_chalk5.default.dim(duration)} ${import_chalk5.default.dim(session.eventCount + " events")}`
2075
2309
  );
2076
- }
2310
+ for (const event of session.events) {
2311
+ if (event.category === "note") {
2312
+ let text = "";
2313
+ try {
2314
+ text = JSON.parse(event.metadata ?? "{}").text ?? "";
2315
+ } catch {
2316
+ }
2317
+ if (text) log.plain(` ${import_chalk5.default.yellow("note")} ${import_chalk5.default.dim(text)}`);
2318
+ }
2319
+ }
2320
+ }
2321
+ log.plain("");
2322
+ });
2077
2323
  }
2078
2324
 
2325
+ // src/cli/summary.ts
2326
+ var import_chalk6 = __toESM(require("chalk"));
2327
+ var import_fs15 = __toESM(require("fs"));
2328
+ var import_os6 = __toESM(require("os"));
2329
+ var import_path10 = __toESM(require("path"));
2330
+
2079
2331
  // src/integrations/webhook.ts
2080
2332
  async function postToWebhook(url, text) {
2081
2333
  const response = await fetchWithTimeout(
@@ -2121,7 +2373,7 @@ function eventRowToPayload(row) {
2121
2373
  };
2122
2374
  if (row.category === "other" && typeof metadata.hint === "string") {
2123
2375
  payload.hint = metadata.hint;
2124
- const { hint: _h, tool: _t, ...rest } = metadata;
2376
+ const { hint: _hint, tool: _tool, ...rest } = metadata;
2125
2377
  payload.metadata = rest;
2126
2378
  }
2127
2379
  return payload;
@@ -2142,25 +2394,25 @@ async function handleWebhook(opts, config, summary) {
2142
2394
  if (webhookUrl) {
2143
2395
  try {
2144
2396
  log.info("Posting to webhook\u2026");
2145
- const username = import_os5.default.userInfo().username;
2397
+ const username = import_os6.default.userInfo().username;
2146
2398
  const body = isSlackWebhookUrl(webhookUrl) ? formatForSlack(summary, username) : summary;
2147
2399
  await postToWebhook(webhookUrl, body);
2148
2400
  log.success("Posted.");
2149
- } catch (e) {
2150
- log.error(`Webhook failed: ${e.message}`);
2401
+ } catch (error) {
2402
+ log.error(`Webhook failed: ${error.message}`);
2151
2403
  }
2152
2404
  } else if (opts.slack && !config.integrations?.slackWebhook) {
2153
2405
  log.warn("--slack requires integrations.slackWebhook to be set in config");
2154
2406
  }
2155
2407
  }
2156
2408
  function registerSummary(program2) {
2157
- program2.command("summary").description("Generate an AI work summary").option("--date <YYYY-MM-DD>", "date to summarize (default: today)").option("--week", "summarize the current week").option("--fresh", "bypass cache and regenerate").option("--slack", "post summary to Slack webhook from config").option("--webhook <url>", "post summary to a custom webhook URL").action(async (opts) => {
2409
+ program2.command("summary").description("Generate an AI work summary").option("--date <YYYY-MM-DD>", "date to summarize (default: today)").option("--week", "summarize the current week").option("--fresh", "bypass cache and regenerate").option("--verbose", "show all captured events including those not sent to AI").option("--slack", "post summary to Slack webhook from config").option("--webhook <url>", "post summary to a custom webhook URL").action(async (opts) => {
2158
2410
  const config = readConfig();
2159
2411
  let provider;
2160
2412
  try {
2161
2413
  provider = getProvider(config);
2162
- } catch (e) {
2163
- log.error(e.message);
2414
+ } catch (error) {
2415
+ log.error(error.message);
2164
2416
  process.exit(1);
2165
2417
  }
2166
2418
  if (opts.week) {
@@ -2180,8 +2432,8 @@ function registerSummary(program2) {
2180
2432
  }
2181
2433
  const cacheKey = isoWeekKey(today);
2182
2434
  const cacheFile = import_path10.default.join(WEEKLY_CACHE_DIR, `${cacheKey}.md`);
2183
- if (!opts.fresh && import_fs14.default.existsSync(cacheFile)) {
2184
- const cached = import_fs14.default.readFileSync(cacheFile, "utf-8");
2435
+ if (!opts.fresh && import_fs15.default.existsSync(cacheFile)) {
2436
+ const cached = import_fs15.default.readFileSync(cacheFile, "utf-8");
2185
2437
  log.plain("");
2186
2438
  log.plain(cached);
2187
2439
  log.plain("");
@@ -2197,11 +2449,11 @@ function registerSummary(program2) {
2197
2449
  log.plain("");
2198
2450
  log.plain(summary);
2199
2451
  log.plain("");
2200
- import_fs14.default.mkdirSync(WEEKLY_CACHE_DIR, { recursive: true });
2201
- import_fs14.default.writeFileSync(cacheFile, summary, { encoding: "utf-8", mode: 384 });
2452
+ import_fs15.default.mkdirSync(WEEKLY_CACHE_DIR, { recursive: true });
2453
+ import_fs15.default.writeFileSync(cacheFile, summary, { encoding: "utf-8", mode: 384 });
2202
2454
  await handleWebhook(opts, config, summary);
2203
- } catch (e) {
2204
- log.error(`Weekly summary failed [${config.ai.provider}]: ${e.message}`);
2455
+ } catch (error) {
2456
+ log.error(`Weekly summary failed [${config.ai.provider}]: ${error.message}`);
2205
2457
  process.exit(1);
2206
2458
  }
2207
2459
  return;
@@ -2210,8 +2462,8 @@ function registerSummary(program2) {
2210
2462
  if (opts.date) {
2211
2463
  try {
2212
2464
  date = parseDate(opts.date);
2213
- } catch (e) {
2214
- log.error(e.message);
2465
+ } catch (error) {
2466
+ log.error(error.message);
2215
2467
  process.exit(1);
2216
2468
  }
2217
2469
  }
@@ -2243,12 +2495,35 @@ function registerSummary(program2) {
2243
2495
  return;
2244
2496
  }
2245
2497
  }
2498
+ const excluded = buildExcludedSet(config.summary?.includeCategories ?? []);
2499
+ const allSessionPayloads = sessions.map((session) => sessionToPayload(session, date));
2500
+ if (opts.verbose) {
2501
+ log.plain("");
2502
+ log.plain(import_chalk6.default.bold(" Captured events (--verbose):"));
2503
+ for (const sessionPayload of allSessionPayloads) {
2504
+ const branch = sessionPayload.branch ? ` (${sessionPayload.branch})` : "";
2505
+ log.plain(
2506
+ import_chalk6.default.dim(
2507
+ `
2508
+ ${sessionPayload.project ?? "unknown"}${branch} \u2014 ${formatDuration(sessionPayload.durationMinutes)}`
2509
+ )
2510
+ );
2511
+ for (const event of sessionPayload.events) {
2512
+ const tag = excluded.has(event.category) ? import_chalk6.default.dim(` [excluded] ${event.category}`) : ` ${event.category}`;
2513
+ const hint = event.hint ? import_chalk6.default.dim(` [${event.hint}]`) : "";
2514
+ const meta = Object.keys(event.metadata).length > 0 ? import_chalk6.default.dim(` ${JSON.stringify(event.metadata)}`) : "";
2515
+ log.plain(` ${tag}${hint}${meta}`);
2516
+ }
2517
+ }
2518
+ log.plain("");
2519
+ }
2246
2520
  log.plain("");
2247
2521
  log.info(`Generating summary via ${import_chalk6.default.cyan(config.ai.provider)}\u2026`);
2248
2522
  const allSummaries = [];
2249
2523
  const jsonSessions = [];
2250
- for (const session of sessions) {
2251
- const sessionPayload = { date, sessions: [sessionToPayload(session, date)] };
2524
+ for (let index = 0; index < sessions.length; index++) {
2525
+ const session = sessions[index];
2526
+ const sessionPayload = { date, sessions: [allSessionPayloads[index]] };
2252
2527
  try {
2253
2528
  const bullets = await provider.summarize(sessionPayload);
2254
2529
  const project = session.repoName ?? "unknown";
@@ -2267,8 +2542,8 @@ ${bullets.trim()}`;
2267
2542
  bullets: parseBullets(bullets)
2268
2543
  });
2269
2544
  }
2270
- } catch (e) {
2271
- log.error(`Summary failed [${config.ai.provider}]: ${e.message}`);
2545
+ } catch (error) {
2546
+ log.error(`Summary failed [${config.ai.provider}]: ${error.message}`);
2272
2547
  process.exit(1);
2273
2548
  }
2274
2549
  }
@@ -2280,12 +2555,20 @@ ${bullets.trim()}`;
2280
2555
  log.plain(fullSummary);
2281
2556
  }
2282
2557
  log.plain("");
2558
+ const excludedCounts = countExcludedEvents(allSessionPayloads, excluded);
2559
+ if (excludedCounts.size > 0) {
2560
+ const total = [...excludedCounts.values()].reduce((sum, count) => sum + count, 0);
2561
+ const breakdown = [...excludedCounts.entries()].map(([category, count]) => `${category} \xD7${count}`).join(", ");
2562
+ log.plain(import_chalk6.default.dim(` \u2139 ${total} event${total === 1 ? "" : "s"} not included in summary (${breakdown})`));
2563
+ log.plain(import_chalk6.default.dim(" Use --verbose to inspect \xB7 workscribe config set summary.includeCategories to opt in"));
2564
+ log.plain("");
2565
+ }
2283
2566
  await handleWebhook(opts, config, fullSummary);
2284
2567
  });
2285
2568
  }
2286
2569
 
2287
2570
  // src/cli/export.ts
2288
- var import_fs15 = __toESM(require("fs"));
2571
+ var import_fs16 = __toESM(require("fs"));
2289
2572
  var import_path11 = __toESM(require("path"));
2290
2573
  function eventRowToPayload2(row) {
2291
2574
  let metadata = {};
@@ -2343,8 +2626,8 @@ function registerExport(program2) {
2343
2626
  log.info(`Generating summary via ${config.ai.provider}\u2026`);
2344
2627
  const summary = await provider.summarize(payload);
2345
2628
  const outputPath = opts.output ?? import_path11.default.join(EXPORTS_DIR, `workscribe-${date}.md`);
2346
- if (!import_fs15.default.existsSync(EXPORTS_DIR)) import_fs15.default.mkdirSync(EXPORTS_DIR, { recursive: true, mode: 448 });
2347
- import_fs15.default.writeFileSync(outputPath, `# Work Summary \u2014 ${date}
2629
+ if (!import_fs16.default.existsSync(EXPORTS_DIR)) import_fs16.default.mkdirSync(EXPORTS_DIR, { recursive: true, mode: 448 });
2630
+ import_fs16.default.writeFileSync(outputPath, `# Work Summary \u2014 ${date}
2348
2631
 
2349
2632
  ${summary}
2350
2633
  `, { encoding: "utf-8", mode: 384 });
@@ -2416,22 +2699,22 @@ function printConfig(obj, indent) {
2416
2699
  }
2417
2700
 
2418
2701
  // src/cli/pause.ts
2419
- var import_fs16 = __toESM(require("fs"));
2702
+ var import_fs17 = __toESM(require("fs"));
2420
2703
  function registerPause(program2) {
2421
2704
  program2.command("pause").description("Pause capture temporarily").action(() => {
2422
- if (import_fs16.default.existsSync(PAUSE_FLAG_PATH)) {
2705
+ if (import_fs17.default.existsSync(PAUSE_FLAG_PATH)) {
2423
2706
  log.info("Capture is already paused. Run workscribe resume to restart.");
2424
2707
  return;
2425
2708
  }
2426
- import_fs16.default.writeFileSync(PAUSE_FLAG_PATH, "", { mode: 384 });
2709
+ import_fs17.default.writeFileSync(PAUSE_FLAG_PATH, "", { mode: 384 });
2427
2710
  log.success("Capture paused. Run workscribe resume to restart.");
2428
2711
  });
2429
2712
  program2.command("resume").description("Resume capture after a pause").action(() => {
2430
- if (!import_fs16.default.existsSync(PAUSE_FLAG_PATH)) {
2713
+ if (!import_fs17.default.existsSync(PAUSE_FLAG_PATH)) {
2431
2714
  log.info("Capture is not paused.");
2432
2715
  return;
2433
2716
  }
2434
- import_fs16.default.rmSync(PAUSE_FLAG_PATH);
2717
+ import_fs17.default.rmSync(PAUSE_FLAG_PATH);
2435
2718
  log.success("Capture resumed.");
2436
2719
  });
2437
2720
  }
@@ -2516,18 +2799,19 @@ function registerClear(program2) {
2516
2799
  }
2517
2800
 
2518
2801
  // src/cli/uninstall.ts
2519
- var import_fs17 = __toESM(require("fs"));
2802
+ var import_fs18 = __toESM(require("fs"));
2520
2803
  var import_path12 = __toESM(require("path"));
2521
- var import_os6 = __toESM(require("os"));
2804
+ var import_os7 = __toESM(require("os"));
2522
2805
  var import_prompts3 = __toESM(require("prompts"));
2523
2806
  var import_chalk9 = __toESM(require("chalk"));
2524
2807
  function detectShellRcFiles() {
2525
2808
  const candidates = [
2526
- { name: "zsh", rcFile: import_path12.default.join(import_os6.default.homedir(), ".zshrc") },
2527
- { name: "bash", rcFile: import_path12.default.join(import_os6.default.homedir(), ".bashrc") },
2528
- { name: "bash", rcFile: import_path12.default.join(import_os6.default.homedir(), ".bash_profile") }
2809
+ { name: "zsh", rcFile: import_path12.default.join(import_os7.default.homedir(), ".zshrc") },
2810
+ { name: "bash", rcFile: import_path12.default.join(import_os7.default.homedir(), ".bashrc") },
2811
+ { name: "bash", rcFile: import_path12.default.join(import_os7.default.homedir(), ".bash_profile") },
2812
+ { name: "fish", rcFile: import_path12.default.join(import_os7.default.homedir(), ".config", "fish", "conf.d", "workscribe.fish") }
2529
2813
  ];
2530
- return candidates.filter((candidate) => import_fs17.default.existsSync(candidate.rcFile) && isHookInstalled(candidate.rcFile));
2814
+ return candidates.filter((candidate) => import_fs18.default.existsSync(candidate.rcFile) && isHookInstalled(candidate.rcFile));
2531
2815
  }
2532
2816
  function registerUninstall(program2) {
2533
2817
  program2.command("uninstall").description("Remove shell hooks and optionally delete all Workscribe data").option("--hooks-only", "remove shell hooks only, skip data deletion prompt (used by npm preuninstall)").action(async (opts) => {
@@ -2537,16 +2821,23 @@ function registerUninstall(program2) {
2537
2821
  log.info("No shell hooks found");
2538
2822
  } else {
2539
2823
  for (const { name, rcFile } of hooked) {
2540
- removeHook(rcFile);
2541
- log.success(`Shell hook removed from ${rcFile}`);
2542
- log.plain(` ${import_chalk9.default.dim(`Run: source ${rcFile}`)} (or restart your terminal to take effect)`);
2543
- log.plain(
2544
- ` ${import_chalk9.default.dim(`${name === "zsh" ? "unfunction" : "unset -f"} _workscribe_preexec _workscribe_precmd`)}`
2545
- );
2824
+ if (name === "fish") {
2825
+ import_fs18.default.unlinkSync(rcFile);
2826
+ log.success(`Shell hook removed: ${rcFile}`);
2827
+ log.plain(` ${import_chalk9.default.dim("Restart your terminal to take effect")}`);
2828
+ } else {
2829
+ removeHook(rcFile);
2830
+ log.success(`Shell hook removed from ${rcFile}`);
2831
+ log.plain(` ${import_chalk9.default.dim(`Run: source ${rcFile}`)} (or restart your terminal to take effect)`);
2832
+ log.plain(
2833
+ ` ${import_chalk9.default.dim(`${name === "zsh" ? "unfunction" : "unset -f"} _workscribe_preexec _workscribe_precmd`)}`
2834
+ );
2835
+ }
2546
2836
  }
2547
2837
  }
2548
2838
  if (opts.hooksOnly) return;
2549
- if (import_fs17.default.existsSync(WORKSCRIBE_DIR)) {
2839
+ const existingDirs = [...new Set(REQUIRED_DIRS)].filter((dir) => import_fs18.default.existsSync(dir));
2840
+ if (existingDirs.length > 0) {
2550
2841
  log.plain("");
2551
2842
  const { deleteData } = await (0, import_prompts3.default)({
2552
2843
  type: "confirm",
@@ -2555,8 +2846,10 @@ function registerUninstall(program2) {
2555
2846
  initial: false
2556
2847
  });
2557
2848
  if (deleteData) {
2558
- import_fs17.default.rmSync(WORKSCRIBE_DIR, { recursive: true, force: true });
2559
- log.success(`Deleted ${WORKSCRIBE_DIR}`);
2849
+ for (const dir of existingDirs) {
2850
+ import_fs18.default.rmSync(dir, { recursive: true, force: true });
2851
+ log.success(`Deleted ${dir}`);
2852
+ }
2560
2853
  } else {
2561
2854
  log.info(`Data kept at ${WORKSCRIBE_DIR}`);
2562
2855
  }
@@ -2568,7 +2861,7 @@ function registerUninstall(program2) {
2568
2861
  }
2569
2862
 
2570
2863
  // src/cli/profile.ts
2571
- var import_fs18 = __toESM(require("fs"));
2864
+ var import_fs19 = __toESM(require("fs"));
2572
2865
  var import_path13 = __toESM(require("path"));
2573
2866
  var import_chalk10 = __toESM(require("chalk"));
2574
2867
  var MIN_SUGGEST_COUNT = 5;
@@ -2615,7 +2908,7 @@ function registerProfile(program2) {
2615
2908
  log.plain(import_chalk10.default.dim(" " + "\u2500".repeat(60)));
2616
2909
  for (const entry of entries) {
2617
2910
  const badge = activeNames.has(entry.name) ? import_chalk10.default.green(" [active]") : "";
2618
- const installed = import_fs18.default.existsSync(import_path13.default.join(PROFILES_DIR, `${entry.name}.json`)) ? import_chalk10.default.dim(" installed") : "";
2911
+ const installed = import_fs19.default.existsSync(import_path13.default.join(PROFILES_DIR, `${entry.name}.json`)) ? import_chalk10.default.dim(" installed") : "";
2619
2912
  log.plain(` ${import_chalk10.default.cyan(entry.name.padEnd(18))} ${entry.displayName.padEnd(28)}${badge}${installed}`);
2620
2913
  if (entry.description) log.plain(` ${import_chalk10.default.dim(" ".repeat(18) + entry.description)}`);
2621
2914
  }
@@ -2652,7 +2945,7 @@ function registerProfile(program2) {
2652
2945
  }
2653
2946
  activeNames.delete(name);
2654
2947
  const cachePath = import_path13.default.join(PROFILES_DIR, `${name}.json`);
2655
- if (import_fs18.default.existsSync(cachePath)) import_fs18.default.unlinkSync(cachePath);
2948
+ if (import_fs19.default.existsSync(cachePath)) import_fs19.default.unlinkSync(cachePath);
2656
2949
  log.success(`Profile "${name}" removed`);
2657
2950
  }
2658
2951
  config.capture.profiles = [...activeNames];
@@ -2716,14 +3009,37 @@ function registerProfile(program2) {
2716
3009
  const suggestions = [];
2717
3010
  for (const entry of manifest) {
2718
3011
  if (activeNames.has(entry.name)) continue;
2719
- const matchedCount = toolCounts.filter((tc) => entry.keywords.some((kw) => tc.tool.toLowerCase().includes(kw.toLowerCase()))).reduce((sum, tc) => sum + tc.count, 0);
3012
+ const matchedCount = toolCounts.filter(
3013
+ (toolCount) => entry.keywords.some((keyword) => toolCount.tool.toLowerCase().includes(keyword.toLowerCase()))
3014
+ ).reduce((total, toolCount) => total + toolCount.count, 0);
2720
3015
  if (matchedCount >= MIN_SUGGEST_COUNT) {
2721
3016
  suggestions.push({ entry, count: matchedCount });
2722
3017
  }
2723
3018
  }
2724
3019
  suggestions.sort((a, b) => b.count - a.count);
2725
3020
  if (suggestions.length === 0) {
2726
- log.plain("No profile matches found. Your commands are well covered.");
3021
+ const activeEntries = manifest.filter((entry) => activeNames.has(entry.name));
3022
+ const isToolCoveredByActiveProfile = (tool) => activeEntries.some(
3023
+ (entry) => entry.keywords.some((keyword) => tool.toLowerCase().includes(keyword.toLowerCase()))
3024
+ );
3025
+ const uncoveredTools = toolCounts.filter((toolCount) => !isToolCoveredByActiveProfile(toolCount.tool));
3026
+ if (uncoveredTools.length === 0) {
3027
+ log.plain("");
3028
+ log.plain("Your active profiles cover all unclassified commands.");
3029
+ log.plain(import_chalk10.default.dim(" Future captures will be classified correctly."));
3030
+ log.plain("");
3031
+ } else {
3032
+ log.plain("");
3033
+ log.plain("No profile matches found for your unclassified commands.");
3034
+ log.plain("");
3035
+ log.plain(import_chalk10.default.dim(" Top unclassified tools:"));
3036
+ for (const { tool, count } of uncoveredTools.slice(0, 5)) {
3037
+ log.plain(import_chalk10.default.dim(` ${tool.padEnd(24)} ${count} event${count === 1 ? "" : "s"}`));
3038
+ }
3039
+ log.plain("");
3040
+ log.plain(import_chalk10.default.dim(" Run workscribe events --category other to review them."));
3041
+ log.plain("");
3042
+ }
2727
3043
  return;
2728
3044
  }
2729
3045
  log.plain("");
@@ -2735,14 +3051,281 @@ function registerProfile(program2) {
2735
3051
  );
2736
3052
  }
2737
3053
  log.plain("");
2738
- log.plain(` Run: ${import_chalk10.default.cyan("workscribe profile add " + suggestions.map((s) => s.entry.name).join(" "))}`);
3054
+ log.plain(
3055
+ ` Run: ${import_chalk10.default.cyan("workscribe profile add " + suggestions.map((suggestion) => suggestion.entry.name).join(" "))}`
3056
+ );
3057
+ log.plain("");
3058
+ const allCoveredEntries = [
3059
+ ...manifest.filter((entry) => activeNames.has(entry.name)),
3060
+ ...suggestions.map((suggestion) => suggestion.entry)
3061
+ ];
3062
+ const isToolCoveredAfterInstall = (tool) => allCoveredEntries.some(
3063
+ (entry) => entry.keywords.some((keyword) => tool.toLowerCase().includes(keyword.toLowerCase()))
3064
+ );
3065
+ const stillUncovered = toolCounts.filter((toolCount) => !isToolCoveredAfterInstall(toolCount.tool)).slice(0, 5);
3066
+ if (stillUncovered.length > 0) {
3067
+ log.plain(import_chalk10.default.dim(" Still uncovered after installing:"));
3068
+ for (const { tool, count } of stillUncovered) {
3069
+ log.plain(import_chalk10.default.dim(` ${tool.padEnd(24)} ${count} event${count === 1 ? "" : "s"}`));
3070
+ }
3071
+ log.plain("");
3072
+ }
3073
+ });
3074
+ }
3075
+
3076
+ // src/cli/note.ts
3077
+ function registerNote(program2) {
3078
+ program2.command("note <text>").description("Attach a free-text annotation to the current session").action((text) => {
3079
+ const { repoName, branch } = getGitContext(process.cwd());
3080
+ insertEvent({
3081
+ captured_at: (/* @__PURE__ */ new Date()).toISOString(),
3082
+ command: "workscribe note",
3083
+ cwd: process.cwd(),
3084
+ exit_code: null,
3085
+ duration_ms: null,
3086
+ repo_name: repoName,
3087
+ branch,
3088
+ category: "note",
3089
+ metadata: JSON.stringify({ text })
3090
+ });
3091
+ log.success(`Note saved${repoName ? ` to ${repoName}` : ""}`);
3092
+ });
3093
+ }
3094
+
3095
+ // src/cli/week.ts
3096
+ var import_chalk11 = __toESM(require("chalk"));
3097
+ var import_fs20 = __toESM(require("fs"));
3098
+ var import_path14 = __toESM(require("path"));
3099
+ var import_os8 = __toESM(require("os"));
3100
+ function eventRowToPayload3(row) {
3101
+ let metadata = {};
3102
+ try {
3103
+ if (row.metadata) metadata = JSON.parse(row.metadata);
3104
+ } catch {
3105
+ }
3106
+ const payload = {
3107
+ category: row.category,
3108
+ metadata,
3109
+ exitCode: row.exit_code,
3110
+ durationMs: row.duration_ms
3111
+ };
3112
+ if (row.category === "other" && typeof metadata.hint === "string") {
3113
+ payload.hint = metadata.hint;
3114
+ const { hint: _hint, tool: _tool, ...rest } = metadata;
3115
+ payload.metadata = rest;
3116
+ }
3117
+ return payload;
3118
+ }
3119
+ function sessionToPayload2(session, date) {
3120
+ return {
3121
+ date,
3122
+ project: session.repoName,
3123
+ branch: session.events[0]?.branch ?? null,
3124
+ startTime: formatDate(session.startedAt),
3125
+ endTime: formatDate(session.endedAt),
3126
+ durationMinutes: session.durationMinutes,
3127
+ events: session.events.map(eventRowToPayload3)
3128
+ };
3129
+ }
3130
+ async function postSummary(opts, config, summary) {
3131
+ const webhookUrl = opts.webhook ?? (opts.slack ? config.integrations?.slackWebhook : void 0);
3132
+ if (!webhookUrl) {
3133
+ if (opts.slack) log.warn("--slack requires integrations.slackWebhook to be set in config");
3134
+ return;
3135
+ }
3136
+ try {
3137
+ log.info("Posting to webhook\u2026");
3138
+ const username = import_os8.default.userInfo().username;
3139
+ const body = isSlackWebhookUrl(webhookUrl) ? formatForSlack(summary, username) : summary;
3140
+ await postToWebhook(webhookUrl, body);
3141
+ log.success("Posted.");
3142
+ } catch (error) {
3143
+ log.error(`Webhook failed: ${error.message}`);
3144
+ }
3145
+ }
3146
+ function registerWeek(program2) {
3147
+ program2.command("week").description("Generate an AI weekly digest (Mon\u2013today)").option("--fresh", "bypass cache and regenerate").option("--slack", "post to Slack webhook from config").option("--webhook <url>", "post to a custom webhook URL").action(async (opts) => {
3148
+ const config = readConfig();
3149
+ let provider;
3150
+ try {
3151
+ provider = getProvider(config);
3152
+ } catch (error) {
3153
+ log.error(error.message);
3154
+ process.exit(1);
3155
+ }
3156
+ const today = todayDate();
3157
+ const weekStart = startOfWeek(today);
3158
+ const days = eachDayOfRange(weekStart, today);
3159
+ const weeklyDays = [];
3160
+ for (const day of days) {
3161
+ const sessions = getOrBuildSessions(day);
3162
+ if (sessions.length > 0) {
3163
+ weeklyDays.push({ date: day, sessions: sessions.map((session) => sessionToPayload2(session, day)) });
3164
+ }
3165
+ }
3166
+ if (weeklyDays.length === 0) {
3167
+ log.plain("No activity captured this week.");
3168
+ return;
3169
+ }
3170
+ const cacheKey = isoWeekKey(today);
3171
+ const cacheFile = import_path14.default.join(WEEKLY_CACHE_DIR, `${cacheKey}.md`);
3172
+ if (!opts.fresh && import_fs20.default.existsSync(cacheFile)) {
3173
+ const cached = import_fs20.default.readFileSync(cacheFile, "utf-8");
3174
+ log.plain("");
3175
+ log.plain(cached);
3176
+ log.plain("");
3177
+ log.plain(import_chalk11.default.dim(" (cached \u2014 use --fresh to regenerate)"));
3178
+ await postSummary(opts, config, cached);
3179
+ return;
3180
+ }
3181
+ const payload = { weekStart, weekEnd: today, days: weeklyDays };
3182
+ log.plain("");
3183
+ log.info(`Generating weekly digest via ${import_chalk11.default.cyan(config.ai.provider)}\u2026`);
3184
+ try {
3185
+ const summary = provider.summarizeWeek ? await provider.summarizeWeek(payload) : await provider.summarize({ date: today, sessions: weeklyDays.flatMap((day) => day.sessions) });
3186
+ log.plain("");
3187
+ log.plain(summary);
3188
+ log.plain("");
3189
+ import_fs20.default.mkdirSync(WEEKLY_CACHE_DIR, { recursive: true });
3190
+ import_fs20.default.writeFileSync(cacheFile, summary, { encoding: "utf-8", mode: 384 });
3191
+ await postSummary(opts, config, summary);
3192
+ } catch (error) {
3193
+ log.error(`Weekly digest failed [${config.ai.provider}]: ${error.message}`);
3194
+ process.exit(1);
3195
+ }
3196
+ });
3197
+ }
3198
+
3199
+ // src/cli/bug-report.ts
3200
+ var import_fs21 = __toESM(require("fs"));
3201
+ var import_os9 = __toESM(require("os"));
3202
+ var import_path15 = __toESM(require("path"));
3203
+ var import_chalk12 = __toESM(require("chalk"));
3204
+ var GITHUB_ISSUES_URL = "https://github.com/workscribe/workscribe/issues/new";
3205
+ var DEBUG_LOG_TAIL_LINES = 100;
3206
+ function tailFile(filePath, lines) {
3207
+ if (!import_fs21.default.existsSync(filePath)) return "(no debug log found \u2014 set WORKSCRIBE_DEBUG=1 to enable)";
3208
+ const content = import_fs21.default.readFileSync(filePath, "utf-8");
3209
+ const allLines = content.split("\n");
3210
+ return allLines.slice(-lines).join("\n");
3211
+ }
3212
+ function redactConfig() {
3213
+ try {
3214
+ const config = readConfig();
3215
+ if (config.ai.apiKey) config.ai.apiKey = "<redacted>";
3216
+ if (config.integrations?.slackWebhook) config.integrations.slackWebhook = "<redacted>";
3217
+ if (config.integrations?.webhook) config.integrations.webhook = "<redacted>";
3218
+ return JSON.stringify(config, null, 2);
3219
+ } catch {
3220
+ return "(could not read config)";
3221
+ }
3222
+ }
3223
+ function registerBugReport(program2) {
3224
+ program2.command("bug-report").description("Generate a diagnostic report for filing a bug").action(() => {
3225
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3226
+ const reportPath = import_path15.default.join(WORKSCRIBE_DIR, `bug-report-${timestamp}.txt`);
3227
+ const systemInfo = [
3228
+ `Workscribe version : ${"1.12.0"}`,
3229
+ `Node.js version : ${process.version}`,
3230
+ `Platform : ${process.platform}`,
3231
+ `Architecture : ${process.arch}`,
3232
+ `OS release : ${import_os9.default.release()}`,
3233
+ `Config path : ${CONFIG_PATH}`,
3234
+ `Data directory : ${WORKSCRIBE_DIR}`
3235
+ ].join("\n");
3236
+ const report = [
3237
+ "=== Workscribe Bug Report ===",
3238
+ `Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
3239
+ "",
3240
+ "--- System ---",
3241
+ systemInfo,
3242
+ "",
3243
+ "--- Config (secrets redacted) ---",
3244
+ redactConfig(),
3245
+ "",
3246
+ `--- Debug log (last ${DEBUG_LOG_TAIL_LINES} lines) ---`,
3247
+ tailFile(DEBUG_LOG_PATH, DEBUG_LOG_TAIL_LINES)
3248
+ ].join("\n");
3249
+ try {
3250
+ if (!import_fs21.default.existsSync(WORKSCRIBE_DIR)) {
3251
+ import_fs21.default.mkdirSync(WORKSCRIBE_DIR, { recursive: true, mode: 448 });
3252
+ }
3253
+ import_fs21.default.writeFileSync(reportPath, report, { mode: 384 });
3254
+ } catch (error) {
3255
+ log.error(`Could not write report: ${error.message}`);
3256
+ process.exit(1);
3257
+ }
3258
+ const params = new URLSearchParams({
3259
+ title: `Bug report \u2014 workscribe ${"1.12.0"}`,
3260
+ body: `<!-- Attach ${import_path15.default.basename(reportPath)} or paste the relevant sections below -->
3261
+
3262
+ **Describe the issue:**
3263
+
3264
+ **Steps to reproduce:**
3265
+
3266
+ **Expected behaviour:**`
3267
+ });
3268
+ log.success(`Report written to: ${import_chalk12.default.cyan(reportPath)}`);
3269
+ log.plain("");
3270
+ log.plain(` Open a GitHub issue:`);
3271
+ log.plain(` ${import_chalk12.default.cyan(`${GITHUB_ISSUES_URL}?${params}`)}`);
3272
+ log.plain("");
3273
+ log.plain(import_chalk12.default.dim(` Attach the report or paste the relevant sections into the issue.`));
2739
3274
  log.plain("");
2740
3275
  });
2741
3276
  }
2742
3277
 
3278
+ // src/utils/update-check.ts
3279
+ var import_fs22 = __toESM(require("fs"));
3280
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
3281
+ function readCache() {
3282
+ try {
3283
+ return JSON.parse(import_fs22.default.readFileSync(UPDATE_CHECK_PATH, "utf-8"));
3284
+ } catch {
3285
+ return null;
3286
+ }
3287
+ }
3288
+ function writeCache(cache) {
3289
+ try {
3290
+ import_fs22.default.writeFileSync(UPDATE_CHECK_PATH, JSON.stringify(cache), { mode: 384 });
3291
+ } catch {
3292
+ }
3293
+ }
3294
+ function isNewer(latest, current) {
3295
+ const parse = (version) => version.replace(/^v/, "").split(".").map(Number);
3296
+ const [latestMajor, latestMinor, latestPatch] = parse(latest);
3297
+ const [currentMajor, currentMinor, currentPatch] = parse(current);
3298
+ if (latestMajor !== currentMajor) return latestMajor > currentMajor;
3299
+ if (latestMinor !== currentMinor) return latestMinor > currentMinor;
3300
+ return latestPatch > currentPatch;
3301
+ }
3302
+ function checkForUpdate() {
3303
+ const current = "1.12.0";
3304
+ const cache = readCache();
3305
+ if (cache && isNewer(cache.latestVersion, current)) {
3306
+ process.stderr.write(
3307
+ `
3308
+ Update available ${current} \u2192 ${cache.latestVersion}
3309
+ Run: npm install -g @workscribe/cli
3310
+
3311
+ `
3312
+ );
3313
+ }
3314
+ const now = Date.now();
3315
+ if (!cache || now - cache.lastChecked > CHECK_INTERVAL_MS) {
3316
+ fetch("https://registry.npmjs.org/@workscribe/cli/latest").then((response) => response.json()).then((data) => {
3317
+ const latest = data.version;
3318
+ if (latest) writeCache({ lastChecked: now, latestVersion: latest });
3319
+ }).catch(() => {
3320
+ });
3321
+ }
3322
+ }
3323
+
2743
3324
  // src/index.ts
3325
+ assertNodeVersion(process.versions.node);
3326
+ checkForUpdate();
2744
3327
  var program = new import_commander.Command();
2745
- program.name("workscribe").description("Automatic developer work journal").version("1.10.0");
3328
+ program.name("workscribe").description("Automatic developer work journal").version("1.12.0");
2746
3329
  registerCapture(program);
2747
3330
  registerHook(program);
2748
3331
  registerInit(program);
@@ -2757,5 +3340,9 @@ registerUndo(program);
2757
3340
  registerClear(program);
2758
3341
  registerUninstall(program);
2759
3342
  registerProfile(program);
3343
+ registerNote(program);
3344
+ registerWeek(program);
3345
+ registerBugReport(program);
3346
+ registerCompletions(program);
2760
3347
  program.parse();
2761
3348
  //# sourceMappingURL=index.js.map