@workscribe/cli 1.9.0 → 1.11.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,8 +26,22 @@ 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_fs7 = __toESM(require("fs"));
44
+ var import_fs9 = __toESM(require("fs"));
31
45
 
32
46
  // src/db/index.ts
33
47
  var import_node_sqlite = require("node:sqlite");
@@ -43,6 +57,7 @@ var DEBUG_LOG_PATH = import_path.default.join(WORKSCRIBE_DIR, "debug.log");
43
57
  var EXPORTS_DIR = import_path.default.join(WORKSCRIBE_DIR, "exports");
44
58
  var PAUSE_FLAG_PATH = import_path.default.join(WORKSCRIBE_DIR, "paused");
45
59
  var WEEKLY_CACHE_DIR = import_path.default.join(WORKSCRIBE_DIR, "weekly-cache");
60
+ var PROFILES_DIR = import_path.default.join(WORKSCRIBE_DIR, "profiles");
46
61
 
47
62
  // src/db/migrations/001_initial_schema.ts
48
63
  var initial_schema_default = {
@@ -85,8 +100,27 @@ var initial_schema_default = {
85
100
  `
86
101
  };
87
102
 
103
+ // src/db/migrations/002_semantic_categories.ts
104
+ var migration = {
105
+ version: 2,
106
+ description: "Rename granular categories to 13 universal semantic categories",
107
+ sql: `
108
+ UPDATE events SET category = 'shipping' WHERE category IN ('code_commit','code_push','code_merge');
109
+ UPDATE events SET category = 'versioning' WHERE category IN ('code_pull','branch_switch','code_stash','git_other');
110
+ UPDATE events SET category = 'building' WHERE category = 'build';
111
+ UPDATE events SET category = 'running' WHERE category IN ('server_run','script_run');
112
+ UPDATE events SET category = 'testing' WHERE category = 'test_run';
113
+ UPDATE events SET category = 'linting' WHERE category = 'lint_run';
114
+ UPDATE events SET category = 'dependencies' WHERE category IN ('dependency_install','dependency_remove');
115
+ UPDATE events SET category = 'infrastructure' WHERE category = 'infrastructure';
116
+ UPDATE events SET category = 'files' WHERE category IN ('file_ops','environment');
117
+ UPDATE events SET category = 'exploring' WHERE category IN ('code_search','code_read');
118
+ `
119
+ };
120
+ var semantic_categories_default = migration;
121
+
88
122
  // src/db/migrations/index.ts
89
- var MIGRATIONS = [initial_schema_default];
123
+ var MIGRATIONS = [initial_schema_default, semantic_categories_default];
90
124
 
91
125
  // src/db/index.ts
92
126
  var _db = null;
@@ -123,13 +157,13 @@ function applyMigrations(db) {
123
157
  const applied = new Set(
124
158
  db.prepare("SELECT version FROM schema_migrations").all().map((row) => row.version)
125
159
  );
126
- for (const migration of MIGRATIONS) {
127
- if (applied.has(migration.version)) continue;
160
+ for (const migration2 of MIGRATIONS) {
161
+ if (applied.has(migration2.version)) continue;
128
162
  db.exec("BEGIN");
129
163
  try {
130
- db.exec(migration.sql);
164
+ db.exec(migration2.sql);
131
165
  db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES ($version, datetime('now'))").run({
132
- version: migration.version
166
+ version: migration2.version
133
167
  });
134
168
  db.exec("COMMIT");
135
169
  } catch (err) {
@@ -137,7 +171,7 @@ function applyMigrations(db) {
137
171
  db.exec("ROLLBACK");
138
172
  } catch {
139
173
  }
140
- throw new Error(`Migration ${migration.version} (${migration.description}) failed: ${err.message}`);
174
+ throw new Error(`Migration ${migration2.version} (${migration2.description}) failed: ${err.message}`);
141
175
  }
142
176
  }
143
177
  }
@@ -232,6 +266,28 @@ function deleteEventsForDate(date) {
232
266
  const result = db.prepare(`DELETE FROM events WHERE date(captured_at, 'localtime') = $date`).run({ date });
233
267
  return result.changes;
234
268
  }
269
+ function getOtherEventToolCounts(days) {
270
+ const db = getDb();
271
+ return db.prepare(
272
+ `SELECT json_extract(metadata, '$.tool') as tool, COUNT(*) as count
273
+ FROM events
274
+ WHERE category = 'other'
275
+ AND captured_at >= datetime('now', $offset)
276
+ AND json_extract(metadata, '$.tool') IS NOT NULL
277
+ GROUP BY tool
278
+ ORDER BY count DESC`
279
+ ).all({ offset: `-${days} days` });
280
+ }
281
+ function getRecentOtherRate(days) {
282
+ const db = getDb();
283
+ const row = db.prepare(
284
+ `SELECT COUNT(*) as total,
285
+ SUM(CASE WHEN category = 'other' THEN 1 ELSE 0 END) as otherCount
286
+ FROM events
287
+ WHERE captured_at >= datetime('now', $offset)`
288
+ ).get({ offset: `-${days} days` });
289
+ return row;
290
+ }
235
291
  function clearSessionsForDate(date) {
236
292
  const db = getDb();
237
293
  db.prepare(
@@ -265,7 +321,10 @@ var BUILTIN_PATTERNS = [
265
321
  // Values after long-form secret flags (single-char flags like -t/-p/-k are too ambiguous)
266
322
  /(?<=--(password|passwd|secret|token|key|api[-_]?key|auth[-_]?token)\s+)\S+/gi,
267
323
  // export VAR=VALUE where VAR looks sensitive
268
- /(?<=export\s+(?:[A-Z_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIAL)[A-Z_]*)=)\S+/gi
324
+ /(?<=export\s+(?:[A-Z_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIAL)[A-Z_]*)=)\S+/gi,
325
+ // Inline assignment: VAR_WITH_SENSITIVE_KEYWORD=value cmd (e.g. API_KEY=secret npm start)
326
+ // Lookbehind matches only the variable-name= prefix, so only the value is replaced
327
+ /(?<=[A-Z_][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIAL)[A-Z0-9_]*=)\S+/gi
269
328
  ];
270
329
  function redact(command, extraPatterns = []) {
271
330
  let result = command;
@@ -475,6 +534,240 @@ function loadAIToolRegistry() {
475
534
  return [...filteredBuiltins, ...userEntries];
476
535
  }
477
536
 
537
+ // src/profiles/loader.ts
538
+ var import_fs8 = __toESM(require("fs"));
539
+ var import_path6 = __toESM(require("path"));
540
+
541
+ // src/config/index.ts
542
+ var import_fs6 = __toESM(require("fs"));
543
+ var DEFAULTS = {
544
+ ai: {
545
+ provider: "",
546
+ apiKey: "",
547
+ model: "",
548
+ baseUrl: ""
549
+ },
550
+ session: {
551
+ idleTimeout: 30
552
+ },
553
+ capture: {
554
+ ignore: ["cd", "ls", "ll", "la", "ls -la", "ls -l", "ls -a", "pwd", "clear", "exit", "history", "man"],
555
+ aiTools: true,
556
+ includeAiderPrompt: true,
557
+ profiles: []
558
+ },
559
+ redact: {
560
+ extra: []
561
+ },
562
+ summary: {
563
+ format: "markdown"
564
+ }
565
+ };
566
+ function readConfig() {
567
+ if (!import_fs6.default.existsSync(CONFIG_PATH)) return { ...DEFAULTS };
568
+ try {
569
+ const raw = import_fs6.default.readFileSync(CONFIG_PATH, "utf-8");
570
+ const parsed = JSON.parse(raw);
571
+ return deepMerge(DEFAULTS, parsed);
572
+ } catch {
573
+ return { ...DEFAULTS };
574
+ }
575
+ }
576
+ function writeConfig(config) {
577
+ import_fs6.default.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 384 });
578
+ try {
579
+ import_fs6.default.chmodSync(CONFIG_PATH, 384);
580
+ } catch {
581
+ }
582
+ }
583
+ function getConfigValue(config, key) {
584
+ const parts = key.split(".");
585
+ let cur = config;
586
+ for (const part of parts) {
587
+ if (cur == null || typeof cur !== "object") return void 0;
588
+ cur = cur[part];
589
+ }
590
+ return cur;
591
+ }
592
+ function setConfigValue(config, key, value) {
593
+ const parts = key.split(".");
594
+ const result = JSON.parse(JSON.stringify(config));
595
+ let cur = result;
596
+ for (let i = 0; i < parts.length - 1; i++) {
597
+ const part = parts[i];
598
+ if (cur[part] == null || typeof cur[part] !== "object") {
599
+ cur[part] = {};
600
+ }
601
+ cur = cur[part];
602
+ }
603
+ const lastKey = parts[parts.length - 1];
604
+ if (value === "true") cur[lastKey] = true;
605
+ else if (value === "false") cur[lastKey] = false;
606
+ else if (!isNaN(Number(value))) cur[lastKey] = Number(value);
607
+ else cur[lastKey] = value;
608
+ return result;
609
+ }
610
+ function deepMerge(defaults, overrides) {
611
+ const result = { ...defaults };
612
+ for (const key of Object.keys(overrides)) {
613
+ const val = overrides[key];
614
+ if (val !== null && typeof val === "object" && !Array.isArray(val) && typeof defaults[key] === "object") {
615
+ result[key] = deepMerge(defaults[key], val);
616
+ } else if (val !== void 0) {
617
+ result[key] = val;
618
+ }
619
+ }
620
+ return result;
621
+ }
622
+
623
+ // src/profiles/schema.ts
624
+ function isNonEmptyString(value) {
625
+ return typeof value === "string" && value.length > 0;
626
+ }
627
+ function isValidRule(rule) {
628
+ if (!rule || typeof rule !== "object") return false;
629
+ const { pattern, category } = rule;
630
+ return isNonEmptyString(pattern) && isNonEmptyString(category);
631
+ }
632
+ function isValidProfile(candidate) {
633
+ if (!candidate || typeof candidate !== "object") return false;
634
+ const { name, displayName, version, rules } = candidate;
635
+ if (!isNonEmptyString(name) || !isNonEmptyString(displayName) || !isNonEmptyString(version)) return false;
636
+ if (!Array.isArray(rules)) return false;
637
+ return rules.every(isValidRule);
638
+ }
639
+ function isValidManifest(m) {
640
+ return Array.isArray(m) && m.every((e) => e && typeof e === "object" && typeof e.name === "string");
641
+ }
642
+
643
+ // src/profiles/fetcher.ts
644
+ var import_fs7 = __toESM(require("fs"));
645
+ var import_path5 = __toESM(require("path"));
646
+ var REGISTRY_BASE = "https://raw.githubusercontent.com/workscribe/profiles/main";
647
+ function manifestCachePath() {
648
+ return import_path5.default.join(PROFILES_DIR, "manifest.json");
649
+ }
650
+ async function fetchManifest() {
651
+ const response = await fetch(`${REGISTRY_BASE}/manifest.json`);
652
+ if (!response.ok) throw new Error(`Registry unreachable (${response.status})`);
653
+ const data = await response.json();
654
+ if (!isValidManifest(data)) throw new Error("Manifest format unrecognised");
655
+ return data;
656
+ }
657
+ async function fetchProfile(name) {
658
+ const response = await fetch(`${REGISTRY_BASE}/${name}.json`);
659
+ if (response.status === 404) throw new Error(`Profile "${name}" not found in registry`);
660
+ if (!response.ok) throw new Error(`Registry unreachable (${response.status})`);
661
+ const data = await response.json();
662
+ if (!isValidProfile(data)) throw new Error(`Profile "${name}" failed schema validation`);
663
+ return data;
664
+ }
665
+ async function installProfile(name) {
666
+ ensureProfilesDir();
667
+ const profile = await fetchProfile(name);
668
+ const dest = import_path5.default.join(PROFILES_DIR, `${name}.json`);
669
+ import_fs7.default.writeFileSync(dest, JSON.stringify(profile, null, 2), { encoding: "utf-8", mode: 384 });
670
+ debugLog("profiles", `Installed profile "${name}" to ${dest}`);
671
+ }
672
+ async function cacheManifest() {
673
+ ensureProfilesDir();
674
+ const manifest = await fetchManifest();
675
+ import_fs7.default.writeFileSync(manifestCachePath(), JSON.stringify(manifest, null, 2), { encoding: "utf-8", mode: 384 });
676
+ return manifest;
677
+ }
678
+ function readCachedManifest() {
679
+ if (!import_fs7.default.existsSync(manifestCachePath())) return null;
680
+ try {
681
+ const data = JSON.parse(import_fs7.default.readFileSync(manifestCachePath(), "utf-8"));
682
+ return isValidManifest(data) ? data : null;
683
+ } catch {
684
+ return null;
685
+ }
686
+ }
687
+ function ensureProfilesDir() {
688
+ if (!import_fs7.default.existsSync(PROFILES_DIR)) {
689
+ import_fs7.default.mkdirSync(PROFILES_DIR, { recursive: true, mode: 448 });
690
+ }
691
+ }
692
+
693
+ // src/profiles/loader.ts
694
+ function compileRule(profileName, rule) {
695
+ let regex;
696
+ try {
697
+ regex = new RegExp(rule.pattern);
698
+ } catch {
699
+ debugLog("profiles", `Profile "${profileName}": invalid pattern "${rule.pattern}" \u2014 skipping rule`);
700
+ return null;
701
+ }
702
+ return {
703
+ match(cmd) {
704
+ const match = cmd.match(regex);
705
+ if (!match) return null;
706
+ const metadata = {};
707
+ for (const [key, value] of Object.entries(rule.metadata ?? {})) {
708
+ metadata[key] = value.replace(/\$(\d+)/g, (_, index) => match[Number(index)] ?? "");
709
+ }
710
+ return { category: rule.category, metadata };
711
+ }
712
+ };
713
+ }
714
+ function loadProfileFromDisk(name) {
715
+ const filePath = import_path6.default.join(PROFILES_DIR, `${name}.json`);
716
+ if (!import_fs8.default.existsSync(filePath)) {
717
+ debugLog("profiles", `Profile "${name}" not found in cache \u2014 run: workscribe profile add ${name}`);
718
+ return null;
719
+ }
720
+ let candidate;
721
+ try {
722
+ candidate = JSON.parse(import_fs8.default.readFileSync(filePath, "utf-8"));
723
+ } catch {
724
+ debugLog("profiles", `Profile "${name}" contains invalid JSON \u2014 skipping`);
725
+ return null;
726
+ }
727
+ if (!isValidProfile(candidate)) {
728
+ debugLog("profiles", `Profile "${name}" failed schema validation \u2014 skipping`);
729
+ return null;
730
+ }
731
+ return candidate;
732
+ }
733
+ function getActiveProfileRules() {
734
+ const activeNames = readConfig().capture.profiles;
735
+ if (activeNames.length === 0) return [];
736
+ return activeNames.flatMap((name) => {
737
+ const profile = loadProfileFromDisk(name);
738
+ if (!profile) return [];
739
+ return profile.rules.flatMap((rule) => {
740
+ const compiled = compileRule(profile.name, rule);
741
+ return compiled ? [compiled] : [];
742
+ });
743
+ });
744
+ }
745
+ function getActiveProfileAiContexts() {
746
+ const activeNames = readConfig().capture.profiles;
747
+ if (activeNames.length === 0) return [];
748
+ return activeNames.flatMap((name) => {
749
+ const profile = loadProfileFromDisk(name);
750
+ return profile?.aiContext ? [profile.aiContext] : [];
751
+ });
752
+ }
753
+ function getCachedProfiles() {
754
+ if (!import_fs8.default.existsSync(PROFILES_DIR)) return [];
755
+ return import_fs8.default.readdirSync(PROFILES_DIR).filter((file) => file.endsWith(".json") && file !== "manifest.json").flatMap((file) => {
756
+ const profile = loadProfileFromDisk(file.replace(/\.json$/, ""));
757
+ return profile ? [profile] : [];
758
+ });
759
+ }
760
+ function detectProfilesForDirectory(dir) {
761
+ const manifest = readCachedManifest();
762
+ if (!manifest) return [];
763
+ const activeNames = new Set(readConfig().capture.profiles);
764
+ return manifest.filter((entry) => {
765
+ if (activeNames.has(entry.name)) return false;
766
+ const indicatorFiles = entry.detect?.files ?? [];
767
+ return indicatorFiles.length > 0 && indicatorFiles.every((file) => import_fs8.default.existsSync(import_path6.default.join(dir, file)));
768
+ });
769
+ }
770
+
478
771
  // src/normalize/patterns.ts
479
772
  function makeRule(pattern, category, extract = () => ({})) {
480
773
  return {
@@ -490,114 +783,131 @@ function extractPackages(cmd, afterKeyword) {
490
783
  if (idx === -1) return [];
491
784
  return cmd.slice(idx + afterKeyword.length).trim().split(/\s+/).filter((part) => part && !part.startsWith("-"));
492
785
  }
493
- var RULES = [
786
+ var BASE_RULES = [
494
787
  // ─── Git operations ───────────────────────────────────────────────────────
495
- makeRule(/^git\s+commit\b/, "code_commit", (_m, cmd) => {
788
+ makeRule(/^git\s+commit\b/, "shipping", (_m, cmd) => {
496
789
  const msgMatch = cmd.match(/-m\s+["']?([^"']+)["']?/);
497
790
  return { message: msgMatch ? msgMatch[1].trim() : null };
498
791
  }),
499
- makeRule(/^git\s+push\b/, "code_push", (_m, cmd) => {
792
+ makeRule(/^git\s+push\b/, "shipping", (_m, cmd) => {
500
793
  const parts = cmd.split(/\s+/);
501
794
  return { remote: parts[2] ?? "origin", branch: parts[3] ?? null };
502
795
  }),
503
- makeRule(/^git\s+(pull|fetch)\b/, "code_pull", (_m, cmd) => {
796
+ makeRule(/^git\s+(pull|fetch)\b/, "versioning", (_m, cmd) => {
504
797
  const parts = cmd.split(/\s+/);
505
798
  return { subcommand: parts[1], remote: parts[2] ?? "origin" };
506
799
  }),
507
- makeRule(/^git\s+(checkout|switch)\b/, "branch_switch", (_m, cmd) => {
800
+ makeRule(/^git\s+(checkout|switch)\b/, "versioning", (_m, cmd) => {
508
801
  const parts = cmd.split(/\s+/);
509
802
  const branch = parts.find((part) => !part.startsWith("-") && part !== "git" && part !== parts[1]);
510
803
  return { branch: branch ?? null };
511
804
  }),
512
- makeRule(/^git\s+(merge|rebase)\b/, "code_merge", (_m, cmd) => {
805
+ makeRule(/^git\s+(merge|rebase)\b/, "shipping", (_m, cmd) => {
513
806
  const parts = cmd.split(/\s+/);
514
807
  return { subcommand: parts[1], target: parts[2] ?? null };
515
808
  }),
516
- makeRule(/^git\s+stash\b/, "code_stash", (_m, cmd) => {
809
+ makeRule(/^git\s+stash\b/, "versioning", (_m, cmd) => {
517
810
  const parts = cmd.split(/\s+/);
518
811
  return { subcommand: parts[2] ?? "push" };
519
812
  }),
520
- makeRule(/^git\s+/, "git_other", (_m, cmd) => {
813
+ makeRule(/^git\s+/, "versioning", (_m, cmd) => {
521
814
  const parts = cmd.split(/\s+/);
522
815
  return { subcommand: parts[1] ?? null };
523
816
  }),
524
- // ─── Package managers — install ───────────────────────────────────────────
525
- makeRule(/^npm\s+(install|i|add)\b/, "dependency_install", (_m, cmd) => ({
817
+ // ─── Releasing ────────────────────────────────────────────────────────────
818
+ makeRule(/^npm\s+publish\b/, "releasing", () => ({ tool: "npm" })),
819
+ makeRule(/^cargo\s+publish\b/, "releasing", () => ({ tool: "cargo" })),
820
+ makeRule(/^gem\s+push\b/, "releasing", () => ({ tool: "gem" })),
821
+ makeRule(/^docker\s+push\b/, "releasing", (_m, cmd) => ({ tool: "docker", image: cmd.split(/\s+/)[2] ?? null })),
822
+ makeRule(/^gh\s+release\b/, "releasing", (_m, cmd) => ({ tool: "gh", subcommand: cmd.split(/\s+/)[2] ?? null })),
823
+ makeRule(/^fly\s+(deploy|launch)\b/, "releasing", (match) => ({ tool: "fly", subcommand: match[1] })),
824
+ makeRule(/^vercel\s+(deploy|build|--prod)\b/, "releasing", () => ({ tool: "vercel" })),
825
+ makeRule(/^netlify\s+deploy\b/, "releasing", () => ({ tool: "netlify" })),
826
+ makeRule(/^railway\s+up\b/, "releasing", () => ({ tool: "railway" })),
827
+ makeRule(/^wrangler\s+deploy\b/, "releasing", () => ({ tool: "wrangler" })),
828
+ // ─── Dependencies ─────────────────────────────────────────────────────────
829
+ makeRule(/^npm\s+(install|i|add)\b/, "dependencies", (_m, cmd) => ({
526
830
  tool: "npm",
527
831
  packages: extractPackages(cmd, cmd.includes("install") ? "install" : cmd.includes(" i ") ? " i " : "add")
528
832
  })),
529
- makeRule(/^(yarn|pnpm)\s+(install|add)\b/, "dependency_install", (m, cmd) => ({
833
+ makeRule(/^(yarn|pnpm)\s+(install|add)\b/, "dependencies", (m, cmd) => ({
530
834
  tool: m[1],
531
835
  packages: extractPackages(cmd, "add")
532
836
  })),
533
- makeRule(/^bun\s+(install|add)\b/, "dependency_install", (_m, cmd) => ({
837
+ makeRule(/^bun\s+(install|add)\b/, "dependencies", (_m, cmd) => ({
534
838
  tool: "bun",
535
839
  packages: extractPackages(cmd, "add")
536
840
  })),
537
- makeRule(/^pip\s+(install)\b/, "dependency_install", (_m, cmd) => ({
841
+ makeRule(/^pip\s+(install)\b/, "dependencies", (_m, cmd) => ({
538
842
  tool: "pip",
539
843
  packages: extractPackages(cmd, "install")
540
844
  })),
541
- makeRule(/^cargo\s+add\b/, "dependency_install", (_m, cmd) => ({
845
+ makeRule(/^cargo\s+add\b/, "dependencies", (_m, cmd) => ({
542
846
  tool: "cargo",
543
847
  packages: extractPackages(cmd, "add")
544
848
  })),
545
- makeRule(/^(brew)\s+install\b/, "dependency_install", (_m, cmd) => ({
849
+ makeRule(/^(brew)\s+install\b/, "dependencies", (_m, cmd) => ({
546
850
  tool: "brew",
547
851
  packages: extractPackages(cmd, "install")
548
852
  })),
549
- makeRule(/^go\s+get\b/, "dependency_install", (_m, cmd) => ({
853
+ makeRule(/^go\s+get\b/, "dependencies", (_m, cmd) => ({
550
854
  tool: "go",
551
855
  packages: extractPackages(cmd, "get")
552
856
  })),
553
- // ─── Package managers — remove ────────────────────────────────────────────
554
- makeRule(/^npm\s+(uninstall|remove|rm)\b/, "dependency_remove", (_m, cmd) => ({
857
+ makeRule(/^npm\s+(uninstall|remove|rm)\b/, "dependencies", (_m, cmd) => ({
555
858
  tool: "npm",
556
859
  packages: extractPackages(cmd, cmd.split(/\s+/)[1])
557
860
  })),
558
- makeRule(/^(yarn|pnpm)\s+remove\b/, "dependency_remove", (m, cmd) => ({
861
+ makeRule(/^(yarn|pnpm)\s+remove\b/, "dependencies", (m, cmd) => ({
559
862
  tool: m[1],
560
863
  packages: extractPackages(cmd, "remove")
561
864
  })),
562
- makeRule(/^bun\s+remove\b/, "dependency_remove", (_m, cmd) => ({
865
+ makeRule(/^bun\s+remove\b/, "dependencies", (_m, cmd) => ({
563
866
  tool: "bun",
564
867
  packages: extractPackages(cmd, "remove")
565
868
  })),
566
- makeRule(/^pip\s+uninstall\b/, "dependency_remove", (_m, cmd) => ({
869
+ makeRule(/^pip\s+uninstall\b/, "dependencies", (_m, cmd) => ({
567
870
  tool: "pip",
568
871
  packages: extractPackages(cmd, "uninstall")
569
872
  })),
873
+ // Runtime version managers
874
+ makeRule(/^nvm\b/, "dependencies", (_m, cmd) => ({ tool: "nvm", subcommand: cmd.split(/\s+/)[1] ?? null })),
875
+ makeRule(/^(fnm|volta)\b/, "dependencies", (m, cmd) => ({ tool: m[1], subcommand: cmd.split(/\s+/)[1] ?? null })),
876
+ makeRule(/^(rbenv|pyenv|asdf)\b/, "dependencies", (m, cmd) => ({
877
+ tool: m[1],
878
+ subcommand: cmd.split(/\s+/)[1] ?? null
879
+ })),
570
880
  // ─── Tests ────────────────────────────────────────────────────────────────
571
- makeRule(/^npm\s+(test|run\s+test)\b/, "test_run", () => ({ tool: "npm" })),
572
- makeRule(/^yarn\s+test\b/, "test_run", () => ({ tool: "yarn" })),
573
- makeRule(/^pnpm\s+test\b/, "test_run", () => ({ tool: "pnpm" })),
574
- makeRule(/^bun\s+test\b/, "test_run", () => ({ tool: "bun" })),
575
- makeRule(/^(jest|vitest)\b/, "test_run", (match) => ({ tool: match[1] })),
576
- makeRule(/^pytest\b/, "test_run", () => ({ tool: "pytest" })),
577
- makeRule(/^go\s+test\b/, "test_run", () => ({ tool: "go" })),
578
- makeRule(/^cargo\s+test\b/, "test_run", () => ({ tool: "cargo" })),
579
- makeRule(/^rspec\b/, "test_run", () => ({ tool: "rspec" })),
580
- makeRule(/^(php\s+)?phpunit\b/, "test_run", () => ({ tool: "phpunit" })),
881
+ makeRule(/^npm\s+(test|run\s+test)\b/, "testing", () => ({ tool: "npm" })),
882
+ makeRule(/^yarn\s+test\b/, "testing", () => ({ tool: "yarn" })),
883
+ makeRule(/^pnpm\s+test\b/, "testing", () => ({ tool: "pnpm" })),
884
+ makeRule(/^bun\s+test\b/, "testing", () => ({ tool: "bun" })),
885
+ makeRule(/^(jest|vitest)\b/, "testing", (match) => ({ tool: match[1] })),
886
+ makeRule(/^pytest\b/, "testing", () => ({ tool: "pytest" })),
887
+ makeRule(/^go\s+test\b/, "testing", () => ({ tool: "go" })),
888
+ makeRule(/^cargo\s+test\b/, "testing", () => ({ tool: "cargo" })),
889
+ makeRule(/^rspec\b/, "testing", () => ({ tool: "rspec" })),
890
+ makeRule(/^(php\s+)?phpunit\b/, "testing", () => ({ tool: "phpunit" })),
581
891
  // ─── Build ────────────────────────────────────────────────────────────────
582
- makeRule(/^npm\s+run\s+build\b/, "build", () => ({ tool: "npm" })),
583
- makeRule(/^(yarn|pnpm)\s+build\b/, "build", (match) => ({ tool: match[1] })),
584
- makeRule(/^bun\s+run\s+build\b/, "build", () => ({ tool: "bun" })),
585
- makeRule(/^(make|gmake)\b/, "build", (match) => ({ tool: match[1] })),
586
- makeRule(/^cargo\s+build\b/, "build", () => ({ tool: "cargo" })),
587
- makeRule(/^go\s+build\b/, "build", () => ({ tool: "go" })),
588
- makeRule(/^docker\s+build\b/, "build", () => ({ tool: "docker" })),
589
- makeRule(/^(mvn|gradle)\b/, "build", (match) => ({ tool: match[1] })),
590
- // ─── Dev server ───────────────────────────────────────────────────────────
591
- makeRule(/^npm\s+run\s+(dev|start|serve)\b/, "server_run", (_m, cmd) => ({
892
+ makeRule(/^npm\s+run\s+build\b/, "building", () => ({ tool: "npm" })),
893
+ makeRule(/^(yarn|pnpm)\s+build\b/, "building", (match) => ({ tool: match[1] })),
894
+ makeRule(/^bun\s+run\s+build\b/, "building", () => ({ tool: "bun" })),
895
+ makeRule(/^(make|gmake)\b/, "building", (match) => ({ tool: match[1] })),
896
+ makeRule(/^cargo\s+build\b/, "building", () => ({ tool: "cargo" })),
897
+ makeRule(/^go\s+build\b/, "building", () => ({ tool: "go" })),
898
+ makeRule(/^docker\s+build\b/, "building", () => ({ tool: "docker" })),
899
+ makeRule(/^(mvn|gradle)\b/, "building", (match) => ({ tool: match[1] })),
900
+ // ─── Dev server / running ─────────────────────────────────────────────────
901
+ makeRule(/^npm\s+run\s+(dev|start|serve)\b/, "running", (_m, cmd) => ({
592
902
  tool: "npm",
593
903
  script: cmd.split(/\s+/)[2]
594
904
  })),
595
- makeRule(/^(yarn|pnpm)\s+(dev|start)\b/, "server_run", (match) => ({ tool: match[1], script: match[2] })),
596
- makeRule(/^bun\s+run\s+(dev|start|serve)\b/, "server_run", (_m, cmd) => ({
905
+ makeRule(/^(yarn|pnpm)\s+(dev|start)\b/, "running", (match) => ({ tool: match[1], script: match[2] })),
906
+ makeRule(/^bun\s+run\s+(dev|start|serve)\b/, "running", (_m, cmd) => ({
597
907
  tool: "bun",
598
908
  script: cmd.split(/\s+/)[2]
599
909
  })),
600
- makeRule(/^python\s+.*manage\.py\s+runserver\b/, "server_run", () => ({ tool: "django" })),
910
+ makeRule(/^python\s+.*manage\.py\s+runserver\b/, "running", () => ({ tool: "django" })),
601
911
  // ─── Infrastructure ───────────────────────────────────────────────────────
602
912
  makeRule(/^ssh\b/, "infrastructure", (_m, cmd) => {
603
913
  const parts = cmd.split(/\s+/);
@@ -611,7 +921,7 @@ var RULES = [
611
921
  const parts = cmd.split(/\s+/);
612
922
  return { tool: m[1], subcommand: parts[1] ?? null };
613
923
  }),
614
- makeRule(/^docker\s+(run|compose|ps|stop|start|rm|exec)\b/, "infrastructure", (m, cmd) => {
924
+ makeRule(/^docker\s+(run|compose|ps|stop|start|rm|exec)\b/, "infrastructure", (_m, cmd) => {
615
925
  const parts = cmd.split(/\s+/);
616
926
  return { tool: "docker", subcommand: parts[1] };
617
927
  }),
@@ -619,62 +929,76 @@ var RULES = [
619
929
  const parts = cmd.split(/\s+/);
620
930
  return { tool: m[1], subcommand: parts[1] ?? null };
621
931
  }),
932
+ // ─── Database ─────────────────────────────────────────────────────────────
933
+ makeRule(/^(psql|pgcli)\b/, "database", (m) => ({ tool: m[1] })),
934
+ makeRule(/^(mysql|mycli)\b/, "database", (m) => ({ tool: m[1] })),
935
+ makeRule(/^sqlite3\b/, "database", () => ({ tool: "sqlite3" })),
936
+ makeRule(/^(mongosh|mongo)\b/, "database", (m) => ({ tool: m[1] })),
937
+ makeRule(/^redis-cli\b/, "database", () => ({ tool: "redis-cli" })),
622
938
  // ─── npx ─────────────────────────────────────────────────────────────────
623
- makeRule(/^npx\s+(jest|vitest|mocha|ava|tap)\b/, "test_run", (_m, cmd) => ({
939
+ makeRule(/^npx\s+(jest|vitest|mocha|ava|tap)\b/, "testing", (_m, cmd) => ({
624
940
  tool: cmd.split(/\s+/)[1]
625
941
  })),
626
- makeRule(/^npx\s+tsc\b/, "build", () => ({ tool: "tsc" })),
627
- makeRule(/^npx\s+(eslint|tslint|biome)\b/, "lint_run", (_m, cmd) => ({
942
+ makeRule(/^npx\s+tsc\b/, "building", () => ({ tool: "tsc" })),
943
+ makeRule(/^npx\s+(eslint|tslint|biome)\b/, "linting", (_m, cmd) => ({
628
944
  tool: cmd.split(/\s+/)[1]
629
945
  })),
630
- makeRule(/^npx\s+(prettier|dprint)\b/, "lint_run", (_m, cmd) => ({
946
+ makeRule(/^npx\s+(prettier|dprint)\b/, "linting", (_m, cmd) => ({
631
947
  tool: cmd.split(/\s+/)[1]
632
948
  })),
633
- makeRule(/^npx\s+\S+/, "script_run", (_m, cmd) => ({
949
+ makeRule(/^npx\s+\S+/, "running", (_m, cmd) => ({
634
950
  tool: cmd.split(/\s+/)[1]
635
951
  })),
636
952
  // ─── Scripts ──────────────────────────────────────────────────────────────
637
- makeRule(/^(bash|sh|zsh|fish)\s+\S+/, "script_run", (match) => ({ shell: match[1] })),
638
- makeRule(/^node\s+\S+\.(?:js|mjs|cjs)\b/, "script_run", () => ({ runtime: "node" })),
639
- makeRule(/^python3?\s+\S+\.py\b/, "script_run", () => ({ runtime: "python" })),
640
- // ─── Environment ──────────────────────────────────────────────────────────
641
- makeRule(/^export\s+\w+=/, "environment", (_m, cmd) => {
642
- const varName = cmd.match(/^export\s+(\w+)=/)?.[1] ?? null;
643
- return { variable: varName };
644
- }),
645
- makeRule(/^source\s+/, "environment", () => ({})),
953
+ makeRule(/^(bash|sh|zsh|fish)\s+\S+/, "running", (match) => ({ shell: match[1] })),
954
+ makeRule(/^node\s+\S+\.(?:js|mjs|cjs)\b/, "running", () => ({ runtime: "node" })),
955
+ makeRule(/^python3?\s+\S+\.py\b/, "running", () => ({ runtime: "python" })),
956
+ // ─── Linting / code quality ───────────────────────────────────────────────
957
+ makeRule(/^(eslint|tslint|biome)\b/, "linting", (match) => ({ tool: match[1] })),
958
+ makeRule(/^(prettier|dprint)\b/, "linting", (match) => ({ tool: match[1] })),
646
959
  // ─── generic run scripts ──────────────────────────────────────────────────
647
- makeRule(/^npm\s+run\s+\S+/, "script_run", (_m, cmd) => ({
960
+ makeRule(/^npm\s+run\s+\S+/, "running", (_m, cmd) => ({
648
961
  tool: "npm",
649
962
  script: cmd.split(/\s+/)[2]
650
963
  })),
651
- makeRule(/^bun\s+run\s+\S+/, "script_run", (_m, cmd) => ({
964
+ makeRule(/^bun\s+run\s+\S+/, "running", (_m, cmd) => ({
652
965
  tool: "bun",
653
966
  script: cmd.split(/\s+/)[2]
654
967
  })),
655
968
  // ─── Code search ─────────────────────────────────────────────────────────
656
- makeRule(/^(grep|egrep|fgrep)\b/, "code_search", (match) => ({ tool: match[1] })),
657
- makeRule(/^(rg|ag|ack|ripgrep)\b/, "code_search", (match) => ({ tool: match[1] })),
658
- makeRule(/^find\s/, "code_search", () => ({ tool: "find" })),
969
+ makeRule(/^(grep|egrep|fgrep)\b/, "exploring", (match) => ({ tool: match[1] })),
970
+ makeRule(/^(rg|ag|ack|ripgrep)\b/, "exploring", (match) => ({ tool: match[1] })),
971
+ makeRule(/^find\s/, "exploring", () => ({ tool: "find" })),
659
972
  // ─── Code read ────────────────────────────────────────────────────────────
660
- makeRule(/^(cat|bat|less|more)\s+\S+/, "code_read", (match) => ({ tool: match[1] })),
661
- makeRule(/^(head|tail)\b/, "code_read", (match) => ({ tool: match[1] })),
973
+ makeRule(/^(cat|bat|less|more)\s+\S+/, "exploring", (match) => ({ tool: match[1] })),
974
+ makeRule(/^(head|tail)\b/, "exploring", (match) => ({ tool: match[1] })),
662
975
  // ─── File operations ──────────────────────────────────────────────────────
663
- makeRule(/^(cp|rsync)\b/, "file_ops", (match) => ({ tool: match[1] })),
664
- makeRule(/^(mv)\b/, "file_ops", () => ({ tool: "mv" })),
665
- makeRule(/^(rm|rmdir)\b/, "file_ops", (match) => ({ tool: match[1] })),
666
- makeRule(/^(mkdir|touch|ln)\b/, "file_ops", (match) => ({ tool: match[1] })),
667
- makeRule(/^(chmod|chown|chgrp)\b/, "file_ops", (match) => ({ tool: match[1] })),
668
- // ─── AI terminal tools (registry-driven) ─────────────────────────────────
669
- ...loadAIToolRegistry().flatMap((entry) => {
976
+ makeRule(/^(cp|rsync)\b/, "files", (match) => ({ tool: match[1] })),
977
+ makeRule(/^(mv)\b/, "files", () => ({ tool: "mv" })),
978
+ makeRule(/^(rm|rmdir)\b/, "files", (match) => ({ tool: match[1] })),
979
+ makeRule(/^(mkdir|touch|ln)\b/, "files", (match) => ({ tool: match[1] })),
980
+ makeRule(/^(chmod|chown|chgrp)\b/, "files", (match) => ({ tool: match[1] })),
981
+ // ─── Environment ──────────────────────────────────────────────────────────
982
+ makeRule(/^export\s+\w+=/, "files", (_m, cmd) => {
983
+ const varName = cmd.match(/^export\s+(\w+)=/)?.[1] ?? null;
984
+ return { variable: varName };
985
+ }),
986
+ makeRule(/^source\s+/, "files", () => ({}))
987
+ ];
988
+ function aiToolRules() {
989
+ return loadAIToolRegistry().flatMap((entry) => {
670
990
  try {
671
991
  return [makeRule(new RegExp(entry.pattern), "ai_session", () => ({ tool: entry.tool }))];
672
992
  } catch {
673
993
  debugLog("registry", `Skipping tool "${entry.tool}" \u2014 invalid pattern: ${entry.pattern}`);
674
994
  return [];
675
995
  }
676
- })
677
- ];
996
+ });
997
+ }
998
+ function buildRules(profileRules = []) {
999
+ return [...BASE_RULES, ...profileRules, ...aiToolRules()];
1000
+ }
1001
+ var RULES = buildRules(getActiveProfileRules());
678
1002
  function normalizeCommand(command) {
679
1003
  let trimmed = command.trim().replace(/\s+/g, " ");
680
1004
  trimmed = trimmed.replace(/^(git)\s+-C\s+\S+\s+/, "$1 ");
@@ -685,87 +1009,6 @@ function normalizeCommand(command) {
685
1009
  return { category: "other", metadata: {} };
686
1010
  }
687
1011
 
688
- // src/config/index.ts
689
- var import_fs6 = __toESM(require("fs"));
690
- var DEFAULTS = {
691
- ai: {
692
- provider: "",
693
- apiKey: "",
694
- model: "",
695
- baseUrl: ""
696
- },
697
- session: {
698
- idleTimeout: 30
699
- },
700
- capture: {
701
- ignore: ["cd", "ls", "ll", "la", "ls -la", "ls -l", "ls -a", "pwd", "clear", "exit", "history", "man"],
702
- aiTools: true,
703
- includeAiderPrompt: true
704
- },
705
- redact: {
706
- extra: []
707
- },
708
- summary: {
709
- format: "markdown"
710
- }
711
- };
712
- function readConfig() {
713
- if (!import_fs6.default.existsSync(CONFIG_PATH)) return { ...DEFAULTS };
714
- try {
715
- const raw = import_fs6.default.readFileSync(CONFIG_PATH, "utf-8");
716
- const parsed = JSON.parse(raw);
717
- return deepMerge(DEFAULTS, parsed);
718
- } catch {
719
- return { ...DEFAULTS };
720
- }
721
- }
722
- function writeConfig(config) {
723
- import_fs6.default.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 384 });
724
- try {
725
- import_fs6.default.chmodSync(CONFIG_PATH, 384);
726
- } catch {
727
- }
728
- }
729
- function getConfigValue(config, key) {
730
- const parts = key.split(".");
731
- let cur = config;
732
- for (const part of parts) {
733
- if (cur == null || typeof cur !== "object") return void 0;
734
- cur = cur[part];
735
- }
736
- return cur;
737
- }
738
- function setConfigValue(config, key, value) {
739
- const parts = key.split(".");
740
- const result = JSON.parse(JSON.stringify(config));
741
- let cur = result;
742
- for (let i = 0; i < parts.length - 1; i++) {
743
- const part = parts[i];
744
- if (cur[part] == null || typeof cur[part] !== "object") {
745
- cur[part] = {};
746
- }
747
- cur = cur[part];
748
- }
749
- const lastKey = parts[parts.length - 1];
750
- if (value === "true") cur[lastKey] = true;
751
- else if (value === "false") cur[lastKey] = false;
752
- else if (!isNaN(Number(value))) cur[lastKey] = Number(value);
753
- else cur[lastKey] = value;
754
- return result;
755
- }
756
- function deepMerge(defaults, overrides) {
757
- const result = { ...defaults };
758
- for (const key of Object.keys(overrides)) {
759
- const val = overrides[key];
760
- if (val !== null && typeof val === "object" && !Array.isArray(val) && typeof defaults[key] === "object") {
761
- result[key] = deepMerge(defaults[key], val);
762
- } else if (val !== void 0) {
763
- result[key] = val;
764
- }
765
- }
766
- return result;
767
- }
768
-
769
1012
  // src/capture/filter.ts
770
1013
  function normalizeForFilter(cmd) {
771
1014
  return cmd.trim().replace(/\s+/g, " ");
@@ -789,7 +1032,7 @@ function isDuplicate(command, cwd, lastEvent, dedupWindowMs = 5e3, now = Date.no
789
1032
 
790
1033
  // src/capture/index.ts
791
1034
  function captureCommand(opts) {
792
- if (import_fs7.default.existsSync(PAUSE_FLAG_PATH)) return;
1035
+ if (import_fs9.default.existsSync(PAUSE_FLAG_PATH)) return;
793
1036
  const config = readConfig();
794
1037
  const { command, cwd, exitCode, durationMs } = opts;
795
1038
  if (shouldFilter(command, config.capture.ignore)) return;
@@ -833,8 +1076,11 @@ function captureCommand(opts) {
833
1076
  }
834
1077
 
835
1078
  // src/cli/capture.ts
1079
+ var CAPTURE_HANG_TIMEOUT_MS = 5e3;
836
1080
  function registerCapture(program2) {
837
1081
  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) => {
1082
+ const hangGuard = setTimeout(() => process.exit(0), CAPTURE_HANG_TIMEOUT_MS);
1083
+ hangGuard.unref();
838
1084
  try {
839
1085
  if (!opts.cmd || !opts.cwd) return;
840
1086
  captureCommand({
@@ -887,25 +1133,423 @@ function registerHook(program2) {
887
1133
  }
888
1134
 
889
1135
  // src/cli/init.ts
890
- var import_fs10 = __toESM(require("fs"));
891
- var import_path7 = __toESM(require("path"));
1136
+ var import_fs12 = __toESM(require("fs"));
1137
+ var import_path9 = __toESM(require("path"));
892
1138
  var import_os4 = __toESM(require("os"));
893
1139
  var import_prompts = __toESM(require("prompts"));
894
1140
  var import_chalk2 = __toESM(require("chalk"));
895
1141
 
896
- // src/capture/shell-hook.ts
897
- var import_fs8 = __toESM(require("fs"));
898
- var import_path5 = __toESM(require("path"));
899
- var HOOK_START = "# workscribe:hook:start";
900
- var HOOK_END = "# workscribe:hook:end";
901
- var HOOK_PREAMBLE = "# Workscribe \u2014 developer work journal";
902
- function getInstalledHookBlock(rcFile) {
903
- if (!import_fs8.default.existsSync(rcFile)) return null;
904
- const content = import_fs8.default.readFileSync(rcFile, "utf-8");
905
- const startIdx = content.indexOf(HOOK_START);
906
- const endIdx = content.indexOf(HOOK_END);
907
- if (startIdx === -1 || endIdx === -1) return null;
908
- return content.slice(startIdx, endIdx + HOOK_END.length);
1142
+ // src/utils/format.ts
1143
+ function formatDuration(minutes) {
1144
+ if (minutes < 60) return `${Math.round(minutes)}m`;
1145
+ const h = Math.floor(minutes / 60);
1146
+ const m = Math.round(minutes % 60);
1147
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
1148
+ }
1149
+ function formatDate(isoString) {
1150
+ return new Date(isoString).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1151
+ }
1152
+ function todayDate() {
1153
+ return localDateStr(/* @__PURE__ */ new Date());
1154
+ }
1155
+ function parseDate(input) {
1156
+ const d = /* @__PURE__ */ new Date(input + "T00:00:00");
1157
+ if (isNaN(d.getTime())) throw new Error(`Invalid date: ${input}`);
1158
+ return input;
1159
+ }
1160
+ function localDateStr(d) {
1161
+ const y = d.getFullYear();
1162
+ const m = String(d.getMonth() + 1).padStart(2, "0");
1163
+ const day = String(d.getDate()).padStart(2, "0");
1164
+ return `${y}-${m}-${day}`;
1165
+ }
1166
+ function startOfWeek(dateStr) {
1167
+ const d = /* @__PURE__ */ new Date(dateStr + "T00:00:00");
1168
+ const day = d.getDay();
1169
+ const diff = day === 0 ? -6 : 1 - day;
1170
+ d.setDate(d.getDate() + diff);
1171
+ return localDateStr(d);
1172
+ }
1173
+ function eachDayOfRange(start, end) {
1174
+ const days = [];
1175
+ const cur = /* @__PURE__ */ new Date(start + "T00:00:00");
1176
+ const last = /* @__PURE__ */ new Date(end + "T00:00:00");
1177
+ while (cur <= last) {
1178
+ days.push(localDateStr(cur));
1179
+ cur.setDate(cur.getDate() + 1);
1180
+ }
1181
+ return days;
1182
+ }
1183
+ function isoWeekKey(dateStr) {
1184
+ const d = /* @__PURE__ */ new Date(dateStr + "T00:00:00");
1185
+ const jan4 = new Date(d.getFullYear(), 0, 4);
1186
+ const weekNum = Math.ceil(((d.getTime() - jan4.getTime()) / 864e5 + jan4.getDay() + 1) / 7);
1187
+ return `${d.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
1188
+ }
1189
+
1190
+ // src/ai/prompt.ts
1191
+ 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.
1192
+
1193
+ Here is an example:
1194
+
1195
+ INPUT:
1196
+ Project: api-service
1197
+ Branch: feature/auth
1198
+ Duration: 2h 00m
1199
+ Events:
1200
+ - versioning {"branch":"feature/auth"}
1201
+ - 2x dependencies {"tool":"npm","packages":["bcrypt","jsonwebtoken"]}
1202
+ - 8x testing [failed]
1203
+ - testing
1204
+ - 2x files {"tool":"mkdir"}
1205
+ - shipping {"message":"fix: auth token validation"}
1206
+ - shipping {"remote":"origin","branch":"feature/auth"}
1207
+
1208
+ OUTPUT:
1209
+ - Added authentication dependencies (bcrypt, JWT)
1210
+ - Debugged and resolved failing auth tests
1211
+ - Committed and pushed the token validation fix
1212
+
1213
+ RULES \u2014 follow these exactly:
1214
+
1215
+ 1. Interpret repeated events as patterns, not individual steps.
1216
+ - Many testing [failed] followed by a passing test = debugging a failure. Say "resolved failing X tests", not "ran tests multiple times".
1217
+ - Many versioning events = branch housekeeping. Fold into context, do not list each git command.
1218
+
1219
+ 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.
1220
+
1221
+ 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.
1222
+
1223
+ 4. For other events with a hint field: describe the developer's intent, not the raw command.
1224
+
1225
+ 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.
1226
+
1227
+ 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).
1228
+
1229
+ 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.
1230
+
1231
+ Maximum 5 bullets. Output ONLY the bullet points, nothing else.`;
1232
+ function getSystemPrompt() {
1233
+ const contexts = getActiveProfileAiContexts();
1234
+ if (contexts.length === 0) return SYSTEM_PROMPT;
1235
+ return SYSTEM_PROMPT + "\n\n" + contexts.join("\n");
1236
+ }
1237
+ var DEFAULT_EXCLUDED_CATEGORIES = /* @__PURE__ */ new Set(["exploring", "note", "linting"]);
1238
+ function buildExcludedSet(includeCategories = []) {
1239
+ const excluded = new Set(DEFAULT_EXCLUDED_CATEGORIES);
1240
+ for (const cat of includeCategories) excluded.delete(cat);
1241
+ return excluded;
1242
+ }
1243
+ function countExcludedEvents(sessions, excluded) {
1244
+ const counts = /* @__PURE__ */ new Map();
1245
+ for (const session of sessions) {
1246
+ for (const event of session.events) {
1247
+ if (excluded.has(event.category)) {
1248
+ counts.set(event.category, (counts.get(event.category) ?? 0) + 1);
1249
+ }
1250
+ }
1251
+ }
1252
+ return counts;
1253
+ }
1254
+ function buildUserMessage(payload, includeCategories = []) {
1255
+ const excluded = buildExcludedSet(includeCategories);
1256
+ const lines = [`Date: ${payload.date}`, ""];
1257
+ for (const session of payload.sessions) {
1258
+ lines.push(`Project: ${session.project ?? "unknown"}`);
1259
+ if (session.branch) lines.push(`Branch: ${session.branch}`);
1260
+ lines.push(`Duration: ${formatDuration(session.durationMinutes)}`);
1261
+ for (const event of session.events) {
1262
+ if (event.category === "note") {
1263
+ lines.push(`NOTE: ${event.metadata.text}`);
1264
+ }
1265
+ }
1266
+ lines.push("Events:");
1267
+ const counts = /* @__PURE__ */ new Map();
1268
+ for (const event of session.events) {
1269
+ if (excluded.has(event.category)) continue;
1270
+ const failed = event.exitCode !== null && event.exitCode !== 0 ? " [failed]" : "";
1271
+ let key;
1272
+ if (event.hint) {
1273
+ key = `other [${event.hint}]${failed}`;
1274
+ } else {
1275
+ const meta = Object.keys(event.metadata).length > 0 ? ` ${JSON.stringify(event.metadata)}` : "";
1276
+ key = `${event.category}${meta}${failed}`;
1277
+ }
1278
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1279
+ }
1280
+ for (const [key, count] of counts) {
1281
+ lines.push(` - ${count > 1 ? `${count}x ` : ""}${key}`);
1282
+ }
1283
+ lines.push("");
1284
+ }
1285
+ return lines.join("\n");
1286
+ }
1287
+ var WEEKLY_SYSTEM_PROMPT = `You write short weekly developer summaries. Here is an example:
1288
+
1289
+ INPUT:
1290
+ Week: 2026-05-18 to 2026-05-22
1291
+
1292
+ Monday 2026-05-18:
1293
+ Project: api-service (feature/auth) \u2014 2h 10m
1294
+ Events: 3x code_commit, 2x test_run, dependency_install
1295
+
1296
+ Tuesday 2026-05-19:
1297
+ Project: api-service (feature/auth) \u2014 1h 30m
1298
+ Events: 2x code_commit, test_run, build
1299
+
1300
+ Wednesday 2026-05-20:
1301
+ Project: dashboard (main) \u2014 3h 00m
1302
+ Events: 4x code_commit, 2x build, test_run
1303
+
1304
+ Thursday 2026-05-21:
1305
+ Project: api-service (feature/auth) \u2014 1h 00m
1306
+ Events: code_commit {"message":"feat: merge auth"}, branch_switch
1307
+
1308
+ Friday 2026-05-22:
1309
+ Project: dashboard (main) \u2014 2h 00m
1310
+ Events: 3x code_commit, build
1311
+
1312
+ OUTPUT:
1313
+ ## Week of 18 May \u2014 9h 40m across 2 projects
1314
+
1315
+ **api-service** (Mon\u2013Thu): Built and shipped the auth feature \u2014 JWT setup, token validation fixes, and final merge.
1316
+ **dashboard** (Wed, Fri): Active development across 7 commits with builds passing.
1317
+
1318
+ Now summarise the following week. Output ONLY the summary \u2014 a one-line header then one bullet per active project. Maximum 4 bullets.`;
1319
+ var DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
1320
+ function buildWeeklyMessage(payload) {
1321
+ const lines = [`Week: ${payload.weekStart} to ${payload.weekEnd}`, ""];
1322
+ for (const { date, sessions } of payload.days) {
1323
+ const dayName = DAY_NAMES[(/* @__PURE__ */ new Date(date + "T00:00:00")).getDay()];
1324
+ lines.push(`${dayName} ${date}:`);
1325
+ for (const session of sessions) {
1326
+ const project = session.project ?? "unknown";
1327
+ const branch = session.branch ? ` (${session.branch})` : "";
1328
+ lines.push(` Project: ${project}${branch} \u2014 ${formatDuration(session.durationMinutes)}`);
1329
+ for (const event of session.events) {
1330
+ if (event.category === "note") {
1331
+ lines.push(` NOTE: ${event.metadata.text}`);
1332
+ }
1333
+ }
1334
+ const excluded = buildExcludedSet();
1335
+ const counts = /* @__PURE__ */ new Map();
1336
+ for (const event of session.events) {
1337
+ if (excluded.has(event.category)) continue;
1338
+ const failed = event.exitCode !== null && event.exitCode !== 0 ? " [failed]" : "";
1339
+ let key;
1340
+ if (event.hint) {
1341
+ key = `other [${event.hint}]${failed}`;
1342
+ } else {
1343
+ const meta = Object.keys(event.metadata).length > 0 ? ` ${JSON.stringify(event.metadata)}` : "";
1344
+ key = `${event.category}${meta}${failed}`;
1345
+ }
1346
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1347
+ }
1348
+ const eventLine = [...counts.entries()].map(([key, count]) => `${count > 1 ? `${count}x ` : ""}${key}`).join(", ");
1349
+ lines.push(` Events: ${eventLine}`);
1350
+ }
1351
+ lines.push("");
1352
+ }
1353
+ return lines.join("\n");
1354
+ }
1355
+
1356
+ // src/ai/fetch.ts
1357
+ var DEFAULT_TIMEOUT_MS = 3e4;
1358
+ async function fetchWithTimeout(url, init, timeoutMs = DEFAULT_TIMEOUT_MS) {
1359
+ const controller = new AbortController();
1360
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1361
+ try {
1362
+ return await fetch(url, { ...init, signal: controller.signal });
1363
+ } catch (err) {
1364
+ if (err.name === "AbortError") {
1365
+ throw new Error(`Request timed out after ${timeoutMs / 1e3}s`);
1366
+ }
1367
+ throw err;
1368
+ } finally {
1369
+ clearTimeout(timer);
1370
+ }
1371
+ }
1372
+
1373
+ // src/ai/providers/index.ts
1374
+ var GenericProvider = class {
1375
+ constructor(config, model, apiKey, baseUrl, includeCategories = []) {
1376
+ this.config = config;
1377
+ this.model = model;
1378
+ this.apiKey = apiKey;
1379
+ this.baseUrl = baseUrl;
1380
+ this.includeCategories = includeCategories;
1381
+ }
1382
+ config;
1383
+ model;
1384
+ apiKey;
1385
+ baseUrl;
1386
+ includeCategories;
1387
+ async call(system, userMsg, maxTokens) {
1388
+ const response = await fetchWithTimeout(this.config.chatUrl(this.baseUrl), {
1389
+ method: "POST",
1390
+ headers: this.config.headers(this.apiKey),
1391
+ body: JSON.stringify(this.config.buildChatBody(this.model, system, userMsg, maxTokens))
1392
+ });
1393
+ if (!response.ok) {
1394
+ throw new Error(this.config.chatError(response.status, await response.text()));
1395
+ }
1396
+ return this.config.parseText(await response.json());
1397
+ }
1398
+ async summarize(payload) {
1399
+ return this.call(getSystemPrompt(), buildUserMessage(payload, this.includeCategories), this.config.dailyTokens);
1400
+ }
1401
+ async summarizeWeek(payload) {
1402
+ return this.call(WEEKLY_SYSTEM_PROMPT, buildWeeklyMessage(payload), this.config.weeklyTokens);
1403
+ }
1404
+ async validate() {
1405
+ const isPost = this.config.validateMethod === "POST";
1406
+ const response = await fetchWithTimeout(this.config.validateUrl(this.baseUrl), {
1407
+ method: this.config.validateMethod,
1408
+ headers: isPost ? this.config.headers(this.apiKey) : void 0,
1409
+ body: isPost && this.config.buildValidateBody ? JSON.stringify(this.config.buildValidateBody(this.model)) : void 0
1410
+ });
1411
+ if (!response.ok) {
1412
+ throw new Error(this.config.validateError(response.status, await response.text(), this.baseUrl));
1413
+ }
1414
+ }
1415
+ };
1416
+
1417
+ // src/ai/providers/config.ts
1418
+ var PROVIDER_CONFIGS = {
1419
+ anthropic: {
1420
+ defaultModel: "claude-haiku-4-5-20251001",
1421
+ defaultBaseUrl: "https://api.anthropic.com/v1",
1422
+ dailyTokens: 1024,
1423
+ weeklyTokens: 1024,
1424
+ chatUrl: (baseUrl) => `${baseUrl}/messages`,
1425
+ validateUrl: (baseUrl) => `${baseUrl}/messages`,
1426
+ validateMethod: "POST",
1427
+ headers: (apiKey) => ({
1428
+ "Content-Type": "application/json",
1429
+ "x-api-key": apiKey,
1430
+ "anthropic-version": "2023-06-01"
1431
+ }),
1432
+ buildChatBody: (model, system, userMsg, maxTokens) => ({
1433
+ model,
1434
+ max_tokens: maxTokens,
1435
+ system,
1436
+ messages: [{ role: "user", content: userMsg }]
1437
+ }),
1438
+ buildValidateBody: (model) => ({
1439
+ model,
1440
+ max_tokens: 1,
1441
+ messages: [{ role: "user", content: "." }]
1442
+ }),
1443
+ parseText: (json) => {
1444
+ const data = json;
1445
+ return data.content.find((b) => b.type === "text")?.text ?? "";
1446
+ },
1447
+ chatError: (status, body) => `Anthropic API error ${status}: ${body}`,
1448
+ validateError: (status, body) => `Anthropic API error ${status}: ${body}`
1449
+ },
1450
+ openai: {
1451
+ defaultModel: "gpt-4o-mini",
1452
+ defaultBaseUrl: "https://api.openai.com/v1",
1453
+ dailyTokens: 1024,
1454
+ weeklyTokens: 1024,
1455
+ chatUrl: (baseUrl) => `${baseUrl}/chat/completions`,
1456
+ validateUrl: (baseUrl) => `${baseUrl}/chat/completions`,
1457
+ validateMethod: "POST",
1458
+ headers: (apiKey) => ({
1459
+ "Content-Type": "application/json",
1460
+ Authorization: `Bearer ${apiKey}`
1461
+ }),
1462
+ buildChatBody: (model, system, userMsg, maxTokens) => ({
1463
+ model,
1464
+ messages: [
1465
+ { role: "system", content: system },
1466
+ { role: "user", content: userMsg }
1467
+ ],
1468
+ max_tokens: maxTokens
1469
+ }),
1470
+ buildValidateBody: (model) => ({
1471
+ model,
1472
+ messages: [{ role: "user", content: "." }],
1473
+ max_tokens: 1
1474
+ }),
1475
+ parseText: (json) => {
1476
+ const data = json;
1477
+ return data.choices[0]?.message?.content ?? "";
1478
+ },
1479
+ chatError: (status, body) => `OpenAI API error ${status}: ${body}`,
1480
+ validateError: (status, body) => `OpenAI API error ${status}: ${body}`
1481
+ },
1482
+ ollama: {
1483
+ defaultModel: "llama3",
1484
+ defaultBaseUrl: "http://localhost:11434",
1485
+ dailyTokens: 300,
1486
+ weeklyTokens: 400,
1487
+ chatUrl: (baseUrl) => `${baseUrl}/api/chat`,
1488
+ validateUrl: (baseUrl) => `${baseUrl}/api/tags`,
1489
+ validateMethod: "GET",
1490
+ headers: () => ({ "Content-Type": "application/json" }),
1491
+ buildChatBody: (model, system, userMsg, maxTokens) => ({
1492
+ model,
1493
+ stream: false,
1494
+ messages: [
1495
+ { role: "system", content: system },
1496
+ { role: "user", content: userMsg }
1497
+ ],
1498
+ options: { num_predict: maxTokens, temperature: 0.3 }
1499
+ }),
1500
+ parseText: (json) => {
1501
+ return json.message?.content ?? "";
1502
+ },
1503
+ chatError: (status, body) => `Ollama error ${status}: ${body}. Is Ollama running? Try: ollama serve`,
1504
+ validateError: (_status, _body, baseUrl) => `Ollama is not reachable at ${baseUrl}. Is it running? Try: ollama serve`
1505
+ }
1506
+ };
1507
+
1508
+ // src/ai/router.ts
1509
+ function getProvider(config) {
1510
+ const { provider, apiKey = "", model, baseUrl } = config.ai;
1511
+ if (!provider) {
1512
+ throw new Error(
1513
+ "No AI provider configured.\nRun: workscribe config set ai.provider <anthropic|openai|ollama|openai-compatible>"
1514
+ );
1515
+ }
1516
+ const configKey = provider === "openai-compatible" ? "openai" : provider;
1517
+ const providerConfig = PROVIDER_CONFIGS[configKey];
1518
+ if (!providerConfig) {
1519
+ throw new Error(
1520
+ `Unknown provider: "${provider}"
1521
+ Supported providers: anthropic, openai, ollama, openai-compatible`
1522
+ );
1523
+ }
1524
+ if (provider !== "ollama" && !apiKey) {
1525
+ throw new Error(`${provider} requires an API key.
1526
+ Run: workscribe config set ai.apiKey <your-key>`);
1527
+ }
1528
+ if (provider === "openai-compatible" && !baseUrl) {
1529
+ throw new Error("openai-compatible requires a base URL.\nRun: workscribe config set ai.baseUrl <url>");
1530
+ }
1531
+ return new GenericProvider(
1532
+ providerConfig,
1533
+ model || providerConfig.defaultModel,
1534
+ apiKey,
1535
+ baseUrl || providerConfig.defaultBaseUrl,
1536
+ config.summary?.includeCategories ?? []
1537
+ );
1538
+ }
1539
+
1540
+ // src/capture/shell-hook.ts
1541
+ var import_fs10 = __toESM(require("fs"));
1542
+ var import_path7 = __toESM(require("path"));
1543
+ var HOOK_START = "# workscribe:hook:start";
1544
+ var HOOK_END = "# workscribe:hook:end";
1545
+ var HOOK_PREAMBLE = "# Workscribe \u2014 developer work journal";
1546
+ function getInstalledHookBlock(rcFile) {
1547
+ if (!import_fs10.default.existsSync(rcFile)) return null;
1548
+ const content = import_fs10.default.readFileSync(rcFile, "utf-8");
1549
+ const startIdx = content.indexOf(HOOK_START);
1550
+ const endIdx = content.indexOf(HOOK_END);
1551
+ if (startIdx === -1 || endIdx === -1) return null;
1552
+ return content.slice(startIdx, endIdx + HOOK_END.length);
909
1553
  }
910
1554
  function isHookInstalled(rcFile) {
911
1555
  return getInstalledHookBlock(rcFile) !== null;
@@ -917,38 +1561,38 @@ function isHookOutdated(rcFile, hookContent) {
917
1561
  }
918
1562
  function installHook(rcFile, hookContent) {
919
1563
  const backup = `${rcFile}.workscribe-backup-${Date.now()}`;
920
- import_fs8.default.copyFileSync(rcFile, backup);
921
- log.info(`Backed up ${rcFile} \u2192 ${import_path5.default.basename(backup)}`);
922
- import_fs8.default.appendFileSync(rcFile, `
1564
+ import_fs10.default.copyFileSync(rcFile, backup);
1565
+ log.info(`Backed up ${rcFile} \u2192 ${import_path7.default.basename(backup)}`);
1566
+ import_fs10.default.appendFileSync(rcFile, `
923
1567
  ${hookContent}
924
1568
  `);
925
1569
  }
926
1570
  function updateHook(rcFile, hookContent) {
927
1571
  const backup = `${rcFile}.workscribe-backup-${Date.now()}`;
928
- import_fs8.default.copyFileSync(rcFile, backup);
929
- log.info(`Backed up ${rcFile} \u2192 ${import_path5.default.basename(backup)}`);
930
- const content = import_fs8.default.readFileSync(rcFile, "utf-8");
1572
+ import_fs10.default.copyFileSync(rcFile, backup);
1573
+ log.info(`Backed up ${rcFile} \u2192 ${import_path7.default.basename(backup)}`);
1574
+ const content = import_fs10.default.readFileSync(rcFile, "utf-8");
931
1575
  const startIdx = content.indexOf(HOOK_START);
932
1576
  const endIdx = content.indexOf(HOOK_END) + HOOK_END.length;
933
- import_fs8.default.writeFileSync(rcFile, content.slice(0, startIdx) + hookContent + content.slice(endIdx));
1577
+ import_fs10.default.writeFileSync(rcFile, content.slice(0, startIdx) + hookContent + content.slice(endIdx));
934
1578
  }
935
1579
  function removeHook(rcFile) {
936
1580
  const backup = `${rcFile}.workscribe-backup-${Date.now()}`;
937
- import_fs8.default.copyFileSync(rcFile, backup);
938
- log.info(`Backed up ${rcFile} \u2192 ${import_path5.default.basename(backup)}`);
939
- const content = import_fs8.default.readFileSync(rcFile, "utf-8");
1581
+ import_fs10.default.copyFileSync(rcFile, backup);
1582
+ log.info(`Backed up ${rcFile} \u2192 ${import_path7.default.basename(backup)}`);
1583
+ const content = import_fs10.default.readFileSync(rcFile, "utf-8");
940
1584
  const startIdx = content.indexOf(HOOK_START);
941
1585
  const endIdx = content.indexOf(HOOK_END) + HOOK_END.length;
942
1586
  const preambleIdx = content.lastIndexOf(HOOK_PREAMBLE, startIdx);
943
1587
  const blockStart = preambleIdx !== -1 ? preambleIdx : startIdx;
944
1588
  const before = content.slice(0, blockStart).replace(/\n+$/, "\n");
945
1589
  const after = content.slice(endIdx).replace(/^\n/, "");
946
- import_fs8.default.writeFileSync(rcFile, before + after);
1590
+ import_fs10.default.writeFileSync(rcFile, before + after);
947
1591
  }
948
1592
 
949
1593
  // src/capture/hooks/claude-code.ts
950
- var import_fs9 = __toESM(require("fs"));
951
- var import_path6 = __toESM(require("path"));
1594
+ var import_fs11 = __toESM(require("fs"));
1595
+ var import_path8 = __toESM(require("path"));
952
1596
  var import_os3 = __toESM(require("os"));
953
1597
 
954
1598
  // src/capture/hooks/utils.ts
@@ -963,11 +1607,11 @@ function isCommandInstalled(cmd) {
963
1607
  }
964
1608
 
965
1609
  // src/capture/hooks/claude-code.ts
966
- var CLAUDE_SETTINGS_PATH = import_path6.default.join(import_os3.default.homedir(), ".claude", "settings.json");
1610
+ var CLAUDE_SETTINGS_PATH = import_path8.default.join(import_os3.default.homedir(), ".claude", "settings.json");
967
1611
  function readSettings() {
968
- if (!import_fs9.default.existsSync(CLAUDE_SETTINGS_PATH)) return {};
1612
+ if (!import_fs11.default.existsSync(CLAUDE_SETTINGS_PATH)) return {};
969
1613
  try {
970
- return JSON.parse(import_fs9.default.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
1614
+ return JSON.parse(import_fs11.default.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
971
1615
  } catch {
972
1616
  return {};
973
1617
  }
@@ -1002,8 +1646,8 @@ var claudeCodeIntegration = {
1002
1646
  }
1003
1647
  ]
1004
1648
  };
1005
- import_fs9.default.mkdirSync(import_path6.default.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
1006
- import_fs9.default.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
1649
+ import_fs11.default.mkdirSync(import_path8.default.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
1650
+ import_fs11.default.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
1007
1651
  }
1008
1652
  };
1009
1653
 
@@ -1015,36 +1659,54 @@ var AI_HOOK_INTEGRATIONS = {
1015
1659
  // src/cli/init.ts
1016
1660
  function detectShell() {
1017
1661
  const shell = process.env.SHELL ?? "";
1018
- if (shell.includes("zsh")) return { name: "zsh", rcFile: import_path7.default.join(import_os4.default.homedir(), ".zshrc") };
1019
- if (shell.includes("bash")) return { name: "bash", rcFile: import_path7.default.join(import_os4.default.homedir(), ".bashrc") };
1662
+ if (shell.includes("zsh")) return { name: "zsh", rcFile: import_path9.default.join(import_os4.default.homedir(), ".zshrc") };
1663
+ if (shell.includes("bash")) return { name: "bash", rcFile: import_path9.default.join(import_os4.default.homedir(), ".bashrc") };
1020
1664
  return null;
1021
1665
  }
1666
+ function step(current, total, label) {
1667
+ log.plain(import_chalk2.default.bold(`
1668
+ [${current}/${total}] ${label}`));
1669
+ }
1022
1670
  function registerInit(program2) {
1023
1671
  program2.command("init").description("Set up Workscribe and install or update shell hooks").action(async () => {
1672
+ const TOTAL_STEPS = 5;
1024
1673
  log.plain(import_chalk2.default.bold("\n Workscribe \u2014 Developer Work Journal\n"));
1025
- if (!import_fs10.default.existsSync(WORKSCRIBE_DIR)) {
1026
- import_fs10.default.mkdirSync(WORKSCRIBE_DIR, { recursive: true, mode: 448 });
1674
+ step(1, TOTAL_STEPS, "Creating data directory");
1675
+ if (!import_fs12.default.existsSync(WORKSCRIBE_DIR)) {
1676
+ import_fs12.default.mkdirSync(WORKSCRIBE_DIR, { recursive: true, mode: 448 });
1027
1677
  log.success(`Created ${WORKSCRIBE_DIR}`);
1028
1678
  } else {
1029
1679
  try {
1030
- import_fs10.default.chmodSync(WORKSCRIBE_DIR, 448);
1680
+ import_fs12.default.chmodSync(WORKSCRIBE_DIR, 448);
1031
1681
  } catch {
1032
1682
  }
1683
+ log.info(`${WORKSCRIBE_DIR} already exists`);
1033
1684
  }
1685
+ if (!import_fs12.default.existsSync(PROFILES_DIR)) {
1686
+ import_fs12.default.mkdirSync(PROFILES_DIR, { recursive: true, mode: 448 });
1687
+ }
1688
+ step(2, TOTAL_STEPS, "Initialising database");
1034
1689
  getDb();
1035
- log.success("Database initialized");
1690
+ log.success("Database ready");
1691
+ step(3, TOTAL_STEPS, "Installing shell hook");
1036
1692
  const shell = detectShell();
1037
1693
  if (!shell) {
1038
1694
  log.warn("Could not detect shell. Supported: zsh, bash");
1039
1695
  log.plain(`Manually add the hook from: ${import_chalk2.default.cyan("shell/hook.zsh")} or ${import_chalk2.default.cyan("shell/hook.bash")}`);
1040
1696
  } else {
1041
- const hookFile = import_path7.default.join(__dirname, "..", "shell", `hook.${shell.name}`);
1042
- if (!import_fs10.default.existsSync(hookFile)) {
1697
+ const hookFile = import_path9.default.join(__dirname, "..", "shell", `hook.${shell.name}`);
1698
+ if (!import_fs12.default.existsSync(hookFile)) {
1043
1699
  log.warn(`Hook template not found at ${hookFile}`);
1044
1700
  } else {
1045
- const hookContent = import_fs10.default.readFileSync(hookFile, "utf-8");
1046
- if (!import_fs10.default.existsSync(shell.rcFile)) import_fs10.default.writeFileSync(shell.rcFile, "");
1701
+ const hookContent = import_fs12.default.readFileSync(hookFile, "utf-8");
1702
+ if (!import_fs12.default.existsSync(shell.rcFile)) import_fs12.default.writeFileSync(shell.rcFile, "");
1047
1703
  if (!isHookInstalled(shell.rcFile)) {
1704
+ log.plain("");
1705
+ log.plain(import_chalk2.default.dim(" Workscribe adds a lightweight hook to your shell that captures each"));
1706
+ log.plain(import_chalk2.default.dim(" command you run \u2014 the text, exit code, duration, and working directory."));
1707
+ log.plain(import_chalk2.default.dim(" It runs in the background and adds no latency to your terminal."));
1708
+ log.plain(import_chalk2.default.dim(" Credentials and secrets are redacted before anything is stored."));
1709
+ log.plain("");
1048
1710
  installHook(shell.rcFile, hookContent);
1049
1711
  log.success(`Shell hook installed in ${shell.rcFile}`);
1050
1712
  } else if (isHookOutdated(shell.rcFile, hookContent)) {
@@ -1065,6 +1727,7 @@ function registerInit(program2) {
1065
1727
  }
1066
1728
  }
1067
1729
  }
1730
+ step(4, TOTAL_STEPS, "Detecting AI coding tools");
1068
1731
  const installedTools = loadAIToolRegistry().filter(
1069
1732
  (entry) => entry.hook !== null && AI_HOOK_INTEGRATIONS[entry.hook]?.isInstalled()
1070
1733
  );
@@ -1092,6 +1755,35 @@ function registerInit(program2) {
1092
1755
  }
1093
1756
  }
1094
1757
  const config = readConfig();
1758
+ try {
1759
+ await cacheManifest();
1760
+ const detected = detectProfilesForDirectory(process.cwd());
1761
+ const alreadyActive = new Set(config.capture.profiles);
1762
+ const toPrompt = detected.filter((entry) => !alreadyActive.has(entry.name));
1763
+ if (toPrompt.length > 0) {
1764
+ log.plain("");
1765
+ for (const entry of toPrompt) {
1766
+ const { install } = await (0, import_prompts.default)({
1767
+ type: "confirm",
1768
+ name: "install",
1769
+ message: `${entry.displayName} project detected. Enable the ${entry.displayName} profile?`,
1770
+ initial: true
1771
+ });
1772
+ if (install) {
1773
+ await installProfile(entry.name);
1774
+ config.capture.profiles = [.../* @__PURE__ */ new Set([...config.capture.profiles, entry.name])];
1775
+ log.success(`${entry.displayName} profile enabled`);
1776
+ }
1777
+ }
1778
+ writeConfig(config);
1779
+ } else if (detected.length === 0) {
1780
+ log.info("No project profiles detected \u2014 run workscribe profile detect from your project directory");
1781
+ }
1782
+ } catch {
1783
+ log.info("Could not reach profile registry \u2014 run workscribe profile detect after connecting");
1784
+ }
1785
+ step(5, TOTAL_STEPS, "Configuring AI provider");
1786
+ let providerConfigured = !!config.ai.provider;
1095
1787
  if (config.ai.provider) {
1096
1788
  log.info(`AI provider already configured: ${import_chalk2.default.cyan(config.ai.provider)}`);
1097
1789
  } else {
@@ -1108,7 +1800,10 @@ function registerInit(program2) {
1108
1800
  { title: "Skip \u2014 configure later", value: "skip" }
1109
1801
  ]
1110
1802
  });
1111
- if (provider && provider !== "skip") {
1803
+ if (!provider || provider === "skip") {
1804
+ providerConfigured = false;
1805
+ log.info("Skipped \u2014 run later: workscribe config set ai.provider <provider>");
1806
+ } else {
1112
1807
  config.ai.provider = provider;
1113
1808
  if (provider === "ollama") {
1114
1809
  log.plain(`
@@ -1133,90 +1828,73 @@ function registerInit(program2) {
1133
1828
  if (baseUrl) config.ai.baseUrl = baseUrl;
1134
1829
  }
1135
1830
  }
1136
- writeConfig(config);
1137
- log.success(`AI provider set to ${import_chalk2.default.cyan(provider)}`);
1138
- } else if (provider === "skip" || !provider) {
1139
- log.info("Skipped \u2014 run later: workscribe config set ai.provider <provider>");
1831
+ log.plain("");
1832
+ log.info(`Validating ${import_chalk2.default.cyan(provider)} connection\u2026`);
1833
+ try {
1834
+ const testProvider = getProvider(config);
1835
+ await testProvider.validate();
1836
+ log.success("API key verified");
1837
+ writeConfig(config);
1838
+ providerConfigured = true;
1839
+ } catch (error) {
1840
+ log.warn(`Validation failed: ${error.message}`);
1841
+ const { retry } = await (0, import_prompts.default)({
1842
+ type: "confirm",
1843
+ name: "retry",
1844
+ message: "Save anyway and configure later?",
1845
+ initial: false
1846
+ });
1847
+ if (retry) {
1848
+ writeConfig(config);
1849
+ providerConfigured = false;
1850
+ log.info("Config saved \u2014 run workscribe config set ai.apiKey <key> to update");
1851
+ } else {
1852
+ log.info("API key not saved \u2014 run workscribe init again to configure");
1853
+ providerConfigured = false;
1854
+ }
1855
+ }
1140
1856
  }
1141
1857
  }
1142
- if (!import_fs10.default.existsSync(CONFIG_PATH)) {
1858
+ if (!import_fs12.default.existsSync(CONFIG_PATH)) {
1143
1859
  writeConfig(config);
1144
- log.success("Config file created");
1145
1860
  }
1146
1861
  log.plain("");
1147
1862
  log.plain(import_chalk2.default.bold(" Setup complete!"));
1863
+ log.plain("");
1148
1864
  if (shell) {
1149
- log.plain("");
1150
- log.plain(` Activate capture now: ${import_chalk2.default.cyan(`source ${shell.rcFile}`)}`);
1865
+ log.plain(` Activate capture now:`);
1866
+ log.plain(` ${import_chalk2.default.cyan(`source ${shell.rcFile}`)}`);
1151
1867
  log.plain(` Or restart your terminal.`);
1868
+ log.plain("");
1869
+ }
1870
+ if (providerConfigured) {
1871
+ log.plain(` Then run a few commands and generate your first summary:`);
1872
+ log.plain(` ${import_chalk2.default.cyan("workscribe summary")}`);
1873
+ log.plain("");
1874
+ log.plain(` Check capture is working:`);
1875
+ log.plain(` ${import_chalk2.default.cyan("workscribe status")}`);
1876
+ } else {
1877
+ log.plain(` Before generating summaries, configure an AI provider:`);
1878
+ log.plain(` ${import_chalk2.default.cyan("workscribe config set ai.provider anthropic")}`);
1879
+ log.plain(` ${import_chalk2.default.cyan("workscribe config set ai.apiKey <your-key>")}`);
1880
+ log.plain("");
1881
+ log.plain(` Then run:`);
1882
+ log.plain(` ${import_chalk2.default.cyan("workscribe summary")}`);
1152
1883
  }
1153
- log.plain("");
1154
- log.plain(` ${import_chalk2.default.dim("workscribe summary")} \u2014 generate today's work summary`);
1155
- log.plain(` ${import_chalk2.default.dim("workscribe status")} \u2014 check capture is working`);
1156
1884
  log.plain("");
1157
1885
  });
1158
1886
  }
1159
1887
 
1160
1888
  // src/cli/status.ts
1161
- var import_fs11 = __toESM(require("fs"));
1889
+ var import_fs13 = __toESM(require("fs"));
1162
1890
  var import_chalk3 = __toESM(require("chalk"));
1163
-
1164
- // src/utils/format.ts
1165
- function formatDuration(minutes) {
1166
- if (minutes < 60) return `${Math.round(minutes)}m`;
1167
- const h = Math.floor(minutes / 60);
1168
- const m = Math.round(minutes % 60);
1169
- return m > 0 ? `${h}h ${m}m` : `${h}h`;
1170
- }
1171
- function formatDate(isoString) {
1172
- return new Date(isoString).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1173
- }
1174
- function todayDate() {
1175
- return localDateStr(/* @__PURE__ */ new Date());
1176
- }
1177
- function parseDate(input) {
1178
- const d = /* @__PURE__ */ new Date(input + "T00:00:00");
1179
- if (isNaN(d.getTime())) throw new Error(`Invalid date: ${input}`);
1180
- return input;
1181
- }
1182
- function localDateStr(d) {
1183
- const y = d.getFullYear();
1184
- const m = String(d.getMonth() + 1).padStart(2, "0");
1185
- const day = String(d.getDate()).padStart(2, "0");
1186
- return `${y}-${m}-${day}`;
1187
- }
1188
- function startOfWeek(dateStr) {
1189
- const d = /* @__PURE__ */ new Date(dateStr + "T00:00:00");
1190
- const day = d.getDay();
1191
- const diff = day === 0 ? -6 : 1 - day;
1192
- d.setDate(d.getDate() + diff);
1193
- return localDateStr(d);
1194
- }
1195
- function eachDayOfRange(start, end) {
1196
- const days = [];
1197
- const cur = /* @__PURE__ */ new Date(start + "T00:00:00");
1198
- const last = /* @__PURE__ */ new Date(end + "T00:00:00");
1199
- while (cur <= last) {
1200
- days.push(localDateStr(cur));
1201
- cur.setDate(cur.getDate() + 1);
1202
- }
1203
- return days;
1204
- }
1205
- function isoWeekKey(dateStr) {
1206
- const d = /* @__PURE__ */ new Date(dateStr + "T00:00:00");
1207
- const jan4 = new Date(d.getFullYear(), 0, 4);
1208
- const weekNum = Math.ceil(((d.getTime() - jan4.getTime()) / 864e5 + jan4.getDay() + 1) / 7);
1209
- return `${d.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
1210
- }
1211
-
1212
- // src/cli/status.ts
1213
1891
  function registerStatus(program2) {
1214
1892
  program2.command("status").description("Show capture status and event counts").action(() => {
1215
1893
  const config = readConfig();
1216
1894
  const total = getEventCount();
1217
1895
  const todayEvents = getEventsForDate(todayDate());
1218
1896
  const last = getLastEvent();
1219
- const isPaused = import_fs11.default.existsSync(PAUSE_FLAG_PATH);
1897
+ const isPaused = import_fs13.default.existsSync(PAUSE_FLAG_PATH);
1220
1898
  log.plain("");
1221
1899
  log.plain(import_chalk3.default.bold(" Workscribe Status"));
1222
1900
  log.plain("");
@@ -1235,6 +1913,25 @@ function registerStatus(program2) {
1235
1913
  ` AI provider ${config.ai.provider ? import_chalk3.default.cyan(config.ai.provider) : import_chalk3.default.yellow("not configured")}`
1236
1914
  );
1237
1915
  if (config.ai.model) log.plain(` Model ${import_chalk3.default.dim(config.ai.model)}`);
1916
+ const activeProfiles = config.capture.profiles;
1917
+ log.plain(
1918
+ ` Profiles ${activeProfiles.length > 0 ? import_chalk3.default.cyan(activeProfiles.join(", ")) : import_chalk3.default.dim("none")}`
1919
+ );
1920
+ const { total: weekTotal, otherCount } = getRecentOtherRate(7);
1921
+ if (weekTotal > 0) {
1922
+ const otherPct = Math.round(otherCount / weekTotal * 100);
1923
+ if (otherPct >= 25) {
1924
+ log.plain("");
1925
+ log.plain(
1926
+ ` ${import_chalk3.default.yellow("\u26A0")} ${otherCount} of ${weekTotal} events (${otherPct}%) are uncategorised this week.`
1927
+ );
1928
+ log.plain(
1929
+ import_chalk3.default.dim(
1930
+ " Run workscribe profile detect \u2014 scan current directory for matching profiles\n Run workscribe profile suggest \u2014 analyse your command history"
1931
+ )
1932
+ );
1933
+ }
1934
+ }
1238
1935
  log.plain("");
1239
1936
  });
1240
1937
  }
@@ -1242,26 +1939,19 @@ function registerStatus(program2) {
1242
1939
  // src/cli/events.ts
1243
1940
  var import_chalk4 = __toESM(require("chalk"));
1244
1941
  var CATEGORY_COLORS = {
1245
- code_commit: import_chalk4.default.green,
1246
- code_push: import_chalk4.default.green,
1247
- code_pull: import_chalk4.default.blue,
1248
- branch_switch: import_chalk4.default.blue,
1249
- dependency_install: import_chalk4.default.yellow,
1250
- dependency_remove: import_chalk4.default.yellow,
1251
- test_run: import_chalk4.default.magenta,
1252
- build: import_chalk4.default.cyan,
1253
- server_run: import_chalk4.default.cyan,
1942
+ shipping: import_chalk4.default.green,
1943
+ versioning: import_chalk4.default.blue,
1944
+ building: import_chalk4.default.cyan,
1945
+ running: import_chalk4.default.white,
1946
+ testing: import_chalk4.default.magenta,
1947
+ linting: import_chalk4.default.yellow,
1948
+ dependencies: import_chalk4.default.yellow,
1254
1949
  infrastructure: import_chalk4.default.red,
1255
- script_run: import_chalk4.default.white,
1256
- environment: import_chalk4.default.gray,
1257
- git_other: import_chalk4.default.blue,
1258
- code_merge: import_chalk4.default.green,
1259
- code_stash: import_chalk4.default.blue,
1950
+ releasing: import_chalk4.default.bold.green,
1951
+ database: import_chalk4.default.bold.blue,
1952
+ files: import_chalk4.default.dim.yellow,
1953
+ exploring: import_chalk4.default.dim.cyan,
1260
1954
  ai_session: import_chalk4.default.bold.magenta,
1261
- lint_run: import_chalk4.default.yellow,
1262
- code_search: import_chalk4.default.dim.cyan,
1263
- code_read: import_chalk4.default.dim.white,
1264
- file_ops: import_chalk4.default.yellow,
1265
1955
  other: import_chalk4.default.gray
1266
1956
  };
1267
1957
  var AI_TOOL_DISPLAY_NAMES = Object.fromEntries(
@@ -1346,461 +2036,128 @@ function splitByRepo(events) {
1346
2036
  groups[groups.length - 1].push(events[i]);
1347
2037
  }
1348
2038
  }
1349
- return groups;
1350
- }
1351
- function minutesBetween(start, end) {
1352
- return (new Date(end).getTime() - new Date(start).getTime()) / 6e4;
1353
- }
1354
-
1355
- // src/sessions/detector.ts
1356
- function getOrBuildSessions(date) {
1357
- const existing = getSessionsForDate(date);
1358
- const events = getEventsForDate(date);
1359
- if (events.length === 0) return [];
1360
- const cachedEventCount = existing.reduce((sum, sessionRow) => sum + sessionRow.event_count, 0);
1361
- if (existing.length > 0 && cachedEventCount === events.length) {
1362
- return existing.map((sessionRow) => ({
1363
- id: sessionRow.id,
1364
- date: sessionRow.date,
1365
- startedAt: sessionRow.started_at,
1366
- endedAt: sessionRow.ended_at,
1367
- repoName: sessionRow.repo_name,
1368
- durationMinutes: minutesBetween(sessionRow.started_at, sessionRow.ended_at),
1369
- eventCount: sessionRow.event_count,
1370
- summary: sessionRow.summary,
1371
- events: getEventsBySessionId(sessionRow.id)
1372
- }));
1373
- }
1374
- const savedSummaries = /* @__PURE__ */ new Map();
1375
- for (const s of existing) {
1376
- if (s.summary) savedSummaries.set(`${s.repo_name}|${s.started_at}`, s.summary);
1377
- }
1378
- clearSessionsForDate(date);
1379
- return buildSessions(date, events, savedSummaries);
1380
- }
1381
- function buildSessions(date, events, savedSummaries = /* @__PURE__ */ new Map()) {
1382
- const config = readConfig();
1383
- const idleMs = config.session.idleTimeout * 60 * 1e3;
1384
- const sessions = [];
1385
- const timeGroups = splitByIdleGap(events, idleMs);
1386
- for (const group of timeGroups) {
1387
- if (group.length === 0) continue;
1388
- const repoGroups = splitByRepo(group);
1389
- for (const repoGroup of repoGroups) {
1390
- if (repoGroup.length === 0) continue;
1391
- const first = repoGroup[0];
1392
- const last = repoGroup[repoGroup.length - 1];
1393
- const repoName = last.repo_name ?? first.repo_name ?? null;
1394
- const summary = savedSummaries.get(`${repoName}|${first.captured_at}`) ?? null;
1395
- const sessionId = insertSession({
1396
- date,
1397
- started_at: first.captured_at,
1398
- ended_at: last.captured_at,
1399
- repo_name: repoName,
1400
- event_count: repoGroup.length,
1401
- summary
1402
- });
1403
- for (const event of repoGroup) {
1404
- insertSessionEvent(sessionId, event.id);
1405
- }
1406
- sessions.push({
1407
- id: sessionId,
1408
- date,
1409
- startedAt: first.captured_at,
1410
- endedAt: last.captured_at,
1411
- repoName,
1412
- durationMinutes: minutesBetween(first.captured_at, last.captured_at),
1413
- eventCount: repoGroup.length,
1414
- summary,
1415
- events: repoGroup
1416
- });
1417
- }
1418
- }
1419
- return sessions;
1420
- }
1421
-
1422
- // src/cli/sessions.ts
1423
- function registerSessions(program2) {
1424
- program2.command("sessions").description("List detected work sessions").option("--date <YYYY-MM-DD>", "date to show sessions for (default: today)").action((opts) => {
1425
- let date = todayDate();
1426
- if (opts.date) {
1427
- try {
1428
- date = parseDate(opts.date);
1429
- } catch (e) {
1430
- log.error(e.message);
1431
- process.exit(1);
1432
- }
1433
- }
1434
- const sessions = getOrBuildSessions(date);
1435
- if (sessions.length === 0) {
1436
- log.plain(`No sessions detected for ${date}`);
1437
- return;
1438
- }
1439
- log.plain("");
1440
- log.plain(import_chalk5.default.bold(` Sessions for ${date} (${sessions.length} session${sessions.length > 1 ? "s" : ""})`));
1441
- log.plain("");
1442
- for (const session of sessions) {
1443
- const start = formatDate(session.startedAt);
1444
- const end = formatDate(session.endedAt);
1445
- const duration = formatDuration(session.durationMinutes);
1446
- const repo = session.repoName ? import_chalk5.default.cyan(session.repoName) : import_chalk5.default.gray("no repo");
1447
- log.plain(
1448
- ` ${import_chalk5.default.dim(start)} \u2013 ${import_chalk5.default.dim(end)} ${repo} ${import_chalk5.default.dim(duration)} ${import_chalk5.default.dim(session.eventCount + " events")}`
1449
- );
1450
- }
1451
- log.plain("");
1452
- });
1453
- }
1454
-
1455
- // src/cli/summary.ts
1456
- var import_chalk6 = __toESM(require("chalk"));
1457
- var import_fs12 = __toESM(require("fs"));
1458
- var import_os5 = __toESM(require("os"));
1459
- var import_path8 = __toESM(require("path"));
1460
-
1461
- // src/ai/prompt.ts
1462
- var SYSTEM_PROMPT = `You write short developer work summaries. Here is an example:
1463
-
1464
- INPUT:
1465
- Project: api-service
1466
- Branch: feature/auth
1467
- Duration: 2h 00m
1468
- Events:
1469
- - branch_switch {"branch":"feature/auth"}
1470
- - 2x dependency_install {"tool":"npm","packages":["bcrypt","jsonwebtoken"]}
1471
- - 2x test_run {"tool":"jest"} [failed]
1472
- - test_run {"tool":"jest"}
1473
- - 2x file_ops {"tool":"mkdir"}
1474
- - code_commit {"message":"fix: auth token validation"}
1475
- - code_push {"remote":"origin","branch":"feature/auth"}
1476
-
1477
- OUTPUT:
1478
- - Switched to the authentication feature branch and added authentication dependencies (bcrypt, JWT)
1479
- - Resolved failing auth tests
1480
- - Committed and pushed authentication fixes
1481
-
1482
- For dependency_install events: 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.
1483
-
1484
- Events like code_push, build, file_ops, and code_stash 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, or a significant restructure with no accompanying commits).
1485
-
1486
- 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.
1487
-
1488
- Maximum 5 bullets. Output ONLY the bullet points, nothing else.`;
1489
- var EXCLUDED = /* @__PURE__ */ new Set(["environment", "code_search", "code_read", "server_run", "git_other"]);
1490
- function buildUserMessage(payload) {
1491
- const lines = [`Date: ${payload.date}`, ""];
1492
- for (const session of payload.sessions) {
1493
- lines.push(`Project: ${session.project ?? "unknown"}`);
1494
- if (session.branch) lines.push(`Branch: ${session.branch}`);
1495
- lines.push(`Duration: ${formatDuration(session.durationMinutes)}`);
1496
- lines.push("Events:");
1497
- const counts = /* @__PURE__ */ new Map();
1498
- for (const event of session.events) {
1499
- if (EXCLUDED.has(event.category)) continue;
1500
- const failed = event.exitCode !== null && event.exitCode !== 0 ? " [failed]" : "";
1501
- let key;
1502
- if (event.hint) {
1503
- key = `other [${event.hint}]${failed}`;
1504
- } else {
1505
- const meta = Object.keys(event.metadata).length > 0 ? ` ${JSON.stringify(event.metadata)}` : "";
1506
- key = `${event.category}${meta}${failed}`;
1507
- }
1508
- counts.set(key, (counts.get(key) ?? 0) + 1);
1509
- }
1510
- for (const [key, count] of counts) {
1511
- lines.push(` - ${count > 1 ? `${count}x ` : ""}${key}`);
1512
- }
1513
- lines.push("");
1514
- }
1515
- return lines.join("\n");
1516
- }
1517
- var WEEKLY_SYSTEM_PROMPT = `You write short weekly developer summaries. Here is an example:
1518
-
1519
- INPUT:
1520
- Week: 2026-05-18 to 2026-05-22
1521
-
1522
- Monday 2026-05-18:
1523
- Project: api-service (feature/auth) \u2014 2h 10m
1524
- Events: 3x code_commit, 2x test_run, dependency_install
1525
-
1526
- Tuesday 2026-05-19:
1527
- Project: api-service (feature/auth) \u2014 1h 30m
1528
- Events: 2x code_commit, test_run, build
1529
-
1530
- Wednesday 2026-05-20:
1531
- Project: dashboard (main) \u2014 3h 00m
1532
- Events: 4x code_commit, 2x build, test_run
1533
-
1534
- Thursday 2026-05-21:
1535
- Project: api-service (feature/auth) \u2014 1h 00m
1536
- Events: code_commit {"message":"feat: merge auth"}, branch_switch
1537
-
1538
- Friday 2026-05-22:
1539
- Project: dashboard (main) \u2014 2h 00m
1540
- Events: 3x code_commit, build
1541
-
1542
- OUTPUT:
1543
- ## Week of 18 May \u2014 9h 40m across 2 projects
1544
-
1545
- **api-service** (Mon\u2013Thu): Built and shipped the auth feature \u2014 JWT setup, token validation fixes, and final merge.
1546
- **dashboard** (Wed, Fri): Active development across 7 commits with builds passing.
1547
-
1548
- Now summarise the following week. Output ONLY the summary \u2014 a one-line header then one bullet per active project. Maximum 4 bullets.`;
1549
- var DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
1550
- function buildWeeklyMessage(payload) {
1551
- const lines = [`Week: ${payload.weekStart} to ${payload.weekEnd}`, ""];
1552
- for (const { date, sessions } of payload.days) {
1553
- const dayName = DAY_NAMES[(/* @__PURE__ */ new Date(date + "T00:00:00")).getDay()];
1554
- lines.push(`${dayName} ${date}:`);
1555
- for (const s of sessions) {
1556
- const project = s.project ?? "unknown";
1557
- const branch = s.branch ? ` (${s.branch})` : "";
1558
- lines.push(` Project: ${project}${branch} \u2014 ${formatDuration(s.durationMinutes)}`);
1559
- const counts = /* @__PURE__ */ new Map();
1560
- for (const e of s.events) {
1561
- if (EXCLUDED.has(e.category)) continue;
1562
- const failed = e.exitCode !== null && e.exitCode !== 0 ? " [failed]" : "";
1563
- let key;
1564
- if (e.hint) {
1565
- key = `other [${e.hint}]${failed}`;
1566
- } else {
1567
- const meta = Object.keys(e.metadata).length > 0 ? ` ${JSON.stringify(e.metadata)}` : "";
1568
- key = `${e.category}${meta}${failed}`;
1569
- }
1570
- counts.set(key, (counts.get(key) ?? 0) + 1);
1571
- }
1572
- const eventLine = [...counts.entries()].map(([k, n]) => `${n > 1 ? `${n}x ` : ""}${k}`).join(", ");
1573
- lines.push(` Events: ${eventLine}`);
1574
- }
1575
- lines.push("");
1576
- }
1577
- return lines.join("\n");
1578
- }
1579
-
1580
- // src/ai/fetch.ts
1581
- var DEFAULT_TIMEOUT_MS = 3e4;
1582
- async function fetchWithTimeout(url, init, timeoutMs = DEFAULT_TIMEOUT_MS) {
1583
- const controller = new AbortController();
1584
- const timer = setTimeout(() => controller.abort(), timeoutMs);
1585
- try {
1586
- return await fetch(url, { ...init, signal: controller.signal });
1587
- } catch (err) {
1588
- if (err.name === "AbortError") {
1589
- throw new Error(`Request timed out after ${timeoutMs / 1e3}s`);
1590
- }
1591
- throw err;
1592
- } finally {
1593
- clearTimeout(timer);
1594
- }
1595
- }
1596
-
1597
- // src/ai/providers/anthropic.ts
1598
- var AnthropicProvider = class {
1599
- apiKey;
1600
- model;
1601
- constructor(apiKey, model) {
1602
- this.apiKey = apiKey;
1603
- this.model = model || "claude-haiku-4-5-20251001";
1604
- }
1605
- async summarize(payload) {
1606
- const response = await fetchWithTimeout("https://api.anthropic.com/v1/messages", {
1607
- method: "POST",
1608
- headers: {
1609
- "Content-Type": "application/json",
1610
- "x-api-key": this.apiKey,
1611
- "anthropic-version": "2023-06-01"
1612
- },
1613
- body: JSON.stringify({
1614
- model: this.model,
1615
- max_tokens: 1024,
1616
- system: SYSTEM_PROMPT,
1617
- messages: [{ role: "user", content: buildUserMessage(payload) }]
1618
- })
1619
- });
1620
- if (!response.ok) {
1621
- const body = await response.text();
1622
- throw new Error(`Anthropic API error ${response.status}: ${body}`);
1623
- }
1624
- const data = await response.json();
1625
- return data.content.find((block) => block.type === "text")?.text ?? "";
1626
- }
1627
- async summarizeWeek(payload) {
1628
- const response = await fetchWithTimeout("https://api.anthropic.com/v1/messages", {
1629
- method: "POST",
1630
- headers: {
1631
- "Content-Type": "application/json",
1632
- "x-api-key": this.apiKey,
1633
- "anthropic-version": "2023-06-01"
1634
- },
1635
- body: JSON.stringify({
1636
- model: this.model,
1637
- max_tokens: 1024,
1638
- system: WEEKLY_SYSTEM_PROMPT,
1639
- messages: [{ role: "user", content: buildWeeklyMessage(payload) }]
1640
- })
1641
- });
1642
- if (!response.ok) {
1643
- const body = await response.text();
1644
- throw new Error(`Anthropic API error ${response.status}: ${body}`);
1645
- }
1646
- const data = await response.json();
1647
- return data.content.find((block) => block.type === "text")?.text ?? "";
1648
- }
1649
- };
1650
-
1651
- // src/ai/providers/openai.ts
1652
- var OpenAIProvider = class {
1653
- apiKey;
1654
- model;
1655
- baseUrl;
1656
- constructor(apiKey, model, baseUrl) {
1657
- this.apiKey = apiKey;
1658
- this.model = model || "gpt-4o-mini";
1659
- this.baseUrl = baseUrl || "https://api.openai.com/v1";
1660
- }
1661
- async summarize(payload) {
1662
- const response = await fetchWithTimeout(`${this.baseUrl}/chat/completions`, {
1663
- method: "POST",
1664
- headers: {
1665
- "Content-Type": "application/json",
1666
- Authorization: `Bearer ${this.apiKey}`
1667
- },
1668
- body: JSON.stringify({
1669
- model: this.model,
1670
- messages: [
1671
- { role: "system", content: SYSTEM_PROMPT },
1672
- { role: "user", content: buildUserMessage(payload) }
1673
- ],
1674
- max_tokens: 1024
1675
- })
1676
- });
1677
- if (!response.ok) {
1678
- const body = await response.text();
1679
- throw new Error(`OpenAI API error ${response.status}: ${body}`);
1680
- }
1681
- const data = await response.json();
1682
- return data.choices[0]?.message?.content ?? "";
1683
- }
1684
- async summarizeWeek(payload) {
1685
- const response = await fetchWithTimeout(`${this.baseUrl}/chat/completions`, {
1686
- method: "POST",
1687
- headers: {
1688
- "Content-Type": "application/json",
1689
- Authorization: `Bearer ${this.apiKey}`
1690
- },
1691
- body: JSON.stringify({
1692
- model: this.model,
1693
- messages: [
1694
- { role: "system", content: WEEKLY_SYSTEM_PROMPT },
1695
- { role: "user", content: buildWeeklyMessage(payload) }
1696
- ],
1697
- max_tokens: 1024
1698
- })
1699
- });
1700
- if (!response.ok) {
1701
- const body = await response.text();
1702
- throw new Error(`OpenAI API error ${response.status}: ${body}`);
1703
- }
1704
- const data = await response.json();
1705
- return data.choices[0]?.message?.content ?? "";
1706
- }
1707
- };
1708
-
1709
- // src/ai/providers/ollama.ts
1710
- var OllamaProvider = class {
1711
- model;
1712
- baseUrl;
1713
- constructor(model, baseUrl) {
1714
- this.model = model || "llama3";
1715
- this.baseUrl = baseUrl || "http://localhost:11434";
1716
- }
1717
- async summarize(payload) {
1718
- const response = await fetchWithTimeout(`${this.baseUrl}/api/chat`, {
1719
- method: "POST",
1720
- headers: { "Content-Type": "application/json" },
1721
- body: JSON.stringify({
1722
- model: this.model,
1723
- stream: false,
1724
- messages: [
1725
- { role: "system", content: SYSTEM_PROMPT },
1726
- { role: "user", content: buildUserMessage(payload) }
1727
- ],
1728
- options: {
1729
- num_predict: 300,
1730
- // hard cap on output tokens — forces conciseness
1731
- temperature: 0.3
1732
- // lower temperature = more focused, less wandering
1733
- }
1734
- })
1735
- });
1736
- if (!response.ok) {
1737
- const body = await response.text();
1738
- throw new Error(`Ollama error ${response.status}: ${body}. Is Ollama running? Try: ollama serve`);
1739
- }
1740
- const data = await response.json();
1741
- return data.message?.content ?? "";
1742
- }
1743
- async summarizeWeek(payload) {
1744
- const response = await fetchWithTimeout(`${this.baseUrl}/api/chat`, {
1745
- method: "POST",
1746
- headers: { "Content-Type": "application/json" },
1747
- body: JSON.stringify({
1748
- model: this.model,
1749
- stream: false,
1750
- messages: [
1751
- { role: "system", content: WEEKLY_SYSTEM_PROMPT },
1752
- { role: "user", content: buildWeeklyMessage(payload) }
1753
- ],
1754
- options: {
1755
- num_predict: 400,
1756
- temperature: 0.3
1757
- }
1758
- })
1759
- });
1760
- if (!response.ok) {
1761
- const body = await response.text();
1762
- throw new Error(`Ollama error ${response.status}: ${body}. Is Ollama running? Try: ollama serve`);
1763
- }
1764
- const data = await response.json();
1765
- return data.message?.content ?? "";
1766
- }
1767
- };
2039
+ return groups;
2040
+ }
2041
+ function minutesBetween(start, end) {
2042
+ return (new Date(end).getTime() - new Date(start).getTime()) / 6e4;
2043
+ }
1768
2044
 
1769
- // src/ai/router.ts
1770
- function getProvider(config) {
1771
- const { provider, apiKey, model, baseUrl } = config.ai;
1772
- if (!provider) {
1773
- throw new Error(
1774
- "No AI provider configured.\nRun: workscribe config set ai.provider <anthropic|openai|ollama|openai-compatible>"
1775
- );
2045
+ // src/sessions/detector.ts
2046
+ function getOrBuildSessions(date) {
2047
+ const existing = getSessionsForDate(date);
2048
+ const events = getEventsForDate(date);
2049
+ if (events.length === 0) return [];
2050
+ const cachedEventCount = existing.reduce((sum, sessionRow) => sum + sessionRow.event_count, 0);
2051
+ if (existing.length > 0 && cachedEventCount === events.length) {
2052
+ return existing.map((sessionRow) => ({
2053
+ id: sessionRow.id,
2054
+ date: sessionRow.date,
2055
+ startedAt: sessionRow.started_at,
2056
+ endedAt: sessionRow.ended_at,
2057
+ repoName: sessionRow.repo_name,
2058
+ durationMinutes: minutesBetween(sessionRow.started_at, sessionRow.ended_at),
2059
+ eventCount: sessionRow.event_count,
2060
+ summary: sessionRow.summary,
2061
+ events: getEventsBySessionId(sessionRow.id)
2062
+ }));
1776
2063
  }
1777
- switch (provider) {
1778
- case "anthropic": {
1779
- if (!apiKey) throw new Error("Anthropic requires an API key.\nRun: workscribe config set ai.apiKey <your-key>");
1780
- return new AnthropicProvider(apiKey, model || void 0);
1781
- }
1782
- case "openai": {
1783
- if (!apiKey) throw new Error("OpenAI requires an API key.\nRun: workscribe config set ai.apiKey <your-key>");
1784
- return new OpenAIProvider(apiKey, model || void 0);
2064
+ const savedSummaries = /* @__PURE__ */ new Map();
2065
+ for (const s of existing) {
2066
+ if (s.summary) savedSummaries.set(`${s.repo_name}|${s.started_at}`, s.summary);
2067
+ }
2068
+ clearSessionsForDate(date);
2069
+ return buildSessions(date, events, savedSummaries);
2070
+ }
2071
+ function buildSessions(date, events, savedSummaries = /* @__PURE__ */ new Map()) {
2072
+ const config = readConfig();
2073
+ const idleMs = config.session.idleTimeout * 60 * 1e3;
2074
+ const sessions = [];
2075
+ const timeGroups = splitByIdleGap(events, idleMs);
2076
+ for (const group of timeGroups) {
2077
+ if (group.length === 0) continue;
2078
+ const repoGroups = splitByRepo(group);
2079
+ for (const repoGroup of repoGroups) {
2080
+ if (repoGroup.length === 0) continue;
2081
+ const first = repoGroup[0];
2082
+ const last = repoGroup[repoGroup.length - 1];
2083
+ const repoName = last.repo_name ?? first.repo_name ?? null;
2084
+ const summary = savedSummaries.get(`${repoName}|${first.captured_at}`) ?? null;
2085
+ const sessionId = insertSession({
2086
+ date,
2087
+ started_at: first.captured_at,
2088
+ ended_at: last.captured_at,
2089
+ repo_name: repoName,
2090
+ event_count: repoGroup.length,
2091
+ summary
2092
+ });
2093
+ for (const event of repoGroup) {
2094
+ insertSessionEvent(sessionId, event.id);
2095
+ }
2096
+ sessions.push({
2097
+ id: sessionId,
2098
+ date,
2099
+ startedAt: first.captured_at,
2100
+ endedAt: last.captured_at,
2101
+ repoName,
2102
+ durationMinutes: minutesBetween(first.captured_at, last.captured_at),
2103
+ eventCount: repoGroup.length,
2104
+ summary,
2105
+ events: repoGroup
2106
+ });
1785
2107
  }
1786
- case "openai-compatible": {
1787
- if (!apiKey)
1788
- throw new Error("This provider requires an API key.\nRun: workscribe config set ai.apiKey <your-key>");
1789
- if (!baseUrl)
1790
- throw new Error("openai-compatible requires a base URL.\nRun: workscribe config set ai.baseUrl <url>");
1791
- return new OpenAIProvider(apiKey, model || void 0, baseUrl);
2108
+ }
2109
+ return sessions;
2110
+ }
2111
+
2112
+ // src/cli/sessions.ts
2113
+ function registerSessions(program2) {
2114
+ program2.command("sessions").description("List detected work sessions").option("--date <YYYY-MM-DD>", "date to show sessions for (default: today)").action((opts) => {
2115
+ let date = todayDate();
2116
+ if (opts.date) {
2117
+ try {
2118
+ date = parseDate(opts.date);
2119
+ } catch (error) {
2120
+ log.error(error.message);
2121
+ process.exit(1);
2122
+ }
1792
2123
  }
1793
- case "ollama": {
1794
- return new OllamaProvider(model || void 0, baseUrl || void 0);
2124
+ const sessions = getOrBuildSessions(date);
2125
+ if (sessions.length === 0) {
2126
+ log.plain(`No sessions detected for ${date}`);
2127
+ return;
1795
2128
  }
1796
- default:
1797
- throw new Error(
1798
- `Unknown provider: "${provider}"
1799
- Supported providers: anthropic, openai, ollama, openai-compatible`
2129
+ log.plain("");
2130
+ log.plain(import_chalk5.default.bold(` Sessions for ${date} (${sessions.length} session${sessions.length > 1 ? "s" : ""})`));
2131
+ log.plain("");
2132
+ for (const session of sessions) {
2133
+ const start = formatDate(session.startedAt);
2134
+ const end = formatDate(session.endedAt);
2135
+ const duration = formatDuration(session.durationMinutes);
2136
+ const repo = session.repoName ? import_chalk5.default.cyan(session.repoName) : import_chalk5.default.gray("no repo");
2137
+ log.plain(
2138
+ ` ${import_chalk5.default.dim(start)} \u2013 ${import_chalk5.default.dim(end)} ${repo} ${import_chalk5.default.dim(duration)} ${import_chalk5.default.dim(session.eventCount + " events")}`
1800
2139
  );
1801
- }
2140
+ for (const event of session.events) {
2141
+ if (event.category === "note") {
2142
+ let text = "";
2143
+ try {
2144
+ text = JSON.parse(event.metadata ?? "{}").text ?? "";
2145
+ } catch {
2146
+ }
2147
+ if (text) log.plain(` ${import_chalk5.default.yellow("note")} ${import_chalk5.default.dim(text)}`);
2148
+ }
2149
+ }
2150
+ }
2151
+ log.plain("");
2152
+ });
1802
2153
  }
1803
2154
 
2155
+ // src/cli/summary.ts
2156
+ var import_chalk6 = __toESM(require("chalk"));
2157
+ var import_fs14 = __toESM(require("fs"));
2158
+ var import_os5 = __toESM(require("os"));
2159
+ var import_path10 = __toESM(require("path"));
2160
+
1804
2161
  // src/integrations/webhook.ts
1805
2162
  async function postToWebhook(url, text) {
1806
2163
  const response = await fetchWithTimeout(
@@ -1846,7 +2203,7 @@ function eventRowToPayload(row) {
1846
2203
  };
1847
2204
  if (row.category === "other" && typeof metadata.hint === "string") {
1848
2205
  payload.hint = metadata.hint;
1849
- const { hint: _h, tool: _t, ...rest } = metadata;
2206
+ const { hint: _hint, tool: _tool, ...rest } = metadata;
1850
2207
  payload.metadata = rest;
1851
2208
  }
1852
2209
  return payload;
@@ -1871,21 +2228,21 @@ async function handleWebhook(opts, config, summary) {
1871
2228
  const body = isSlackWebhookUrl(webhookUrl) ? formatForSlack(summary, username) : summary;
1872
2229
  await postToWebhook(webhookUrl, body);
1873
2230
  log.success("Posted.");
1874
- } catch (e) {
1875
- log.error(`Webhook failed: ${e.message}`);
2231
+ } catch (error) {
2232
+ log.error(`Webhook failed: ${error.message}`);
1876
2233
  }
1877
2234
  } else if (opts.slack && !config.integrations?.slackWebhook) {
1878
2235
  log.warn("--slack requires integrations.slackWebhook to be set in config");
1879
2236
  }
1880
2237
  }
1881
2238
  function registerSummary(program2) {
1882
- 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) => {
2239
+ 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) => {
1883
2240
  const config = readConfig();
1884
2241
  let provider;
1885
2242
  try {
1886
2243
  provider = getProvider(config);
1887
- } catch (e) {
1888
- log.error(e.message);
2244
+ } catch (error) {
2245
+ log.error(error.message);
1889
2246
  process.exit(1);
1890
2247
  }
1891
2248
  if (opts.week) {
@@ -1904,9 +2261,9 @@ function registerSummary(program2) {
1904
2261
  return;
1905
2262
  }
1906
2263
  const cacheKey = isoWeekKey(today);
1907
- const cacheFile = import_path8.default.join(WEEKLY_CACHE_DIR, `${cacheKey}.md`);
1908
- if (!opts.fresh && import_fs12.default.existsSync(cacheFile)) {
1909
- const cached = import_fs12.default.readFileSync(cacheFile, "utf-8");
2264
+ const cacheFile = import_path10.default.join(WEEKLY_CACHE_DIR, `${cacheKey}.md`);
2265
+ if (!opts.fresh && import_fs14.default.existsSync(cacheFile)) {
2266
+ const cached = import_fs14.default.readFileSync(cacheFile, "utf-8");
1910
2267
  log.plain("");
1911
2268
  log.plain(cached);
1912
2269
  log.plain("");
@@ -1922,11 +2279,11 @@ function registerSummary(program2) {
1922
2279
  log.plain("");
1923
2280
  log.plain(summary);
1924
2281
  log.plain("");
1925
- import_fs12.default.mkdirSync(WEEKLY_CACHE_DIR, { recursive: true });
1926
- import_fs12.default.writeFileSync(cacheFile, summary, { encoding: "utf-8", mode: 384 });
2282
+ import_fs14.default.mkdirSync(WEEKLY_CACHE_DIR, { recursive: true });
2283
+ import_fs14.default.writeFileSync(cacheFile, summary, { encoding: "utf-8", mode: 384 });
1927
2284
  await handleWebhook(opts, config, summary);
1928
- } catch (e) {
1929
- log.error(`Weekly summary failed [${config.ai.provider}]: ${e.message}`);
2285
+ } catch (error) {
2286
+ log.error(`Weekly summary failed [${config.ai.provider}]: ${error.message}`);
1930
2287
  process.exit(1);
1931
2288
  }
1932
2289
  return;
@@ -1935,8 +2292,8 @@ function registerSummary(program2) {
1935
2292
  if (opts.date) {
1936
2293
  try {
1937
2294
  date = parseDate(opts.date);
1938
- } catch (e) {
1939
- log.error(e.message);
2295
+ } catch (error) {
2296
+ log.error(error.message);
1940
2297
  process.exit(1);
1941
2298
  }
1942
2299
  }
@@ -1968,12 +2325,35 @@ function registerSummary(program2) {
1968
2325
  return;
1969
2326
  }
1970
2327
  }
2328
+ const excluded = buildExcludedSet(config.summary?.includeCategories ?? []);
2329
+ const allSessionPayloads = sessions.map((session) => sessionToPayload(session, date));
2330
+ if (opts.verbose) {
2331
+ log.plain("");
2332
+ log.plain(import_chalk6.default.bold(" Captured events (--verbose):"));
2333
+ for (const sessionPayload of allSessionPayloads) {
2334
+ const branch = sessionPayload.branch ? ` (${sessionPayload.branch})` : "";
2335
+ log.plain(
2336
+ import_chalk6.default.dim(
2337
+ `
2338
+ ${sessionPayload.project ?? "unknown"}${branch} \u2014 ${formatDuration(sessionPayload.durationMinutes)}`
2339
+ )
2340
+ );
2341
+ for (const event of sessionPayload.events) {
2342
+ const tag = excluded.has(event.category) ? import_chalk6.default.dim(` [excluded] ${event.category}`) : ` ${event.category}`;
2343
+ const hint = event.hint ? import_chalk6.default.dim(` [${event.hint}]`) : "";
2344
+ const meta = Object.keys(event.metadata).length > 0 ? import_chalk6.default.dim(` ${JSON.stringify(event.metadata)}`) : "";
2345
+ log.plain(` ${tag}${hint}${meta}`);
2346
+ }
2347
+ }
2348
+ log.plain("");
2349
+ }
1971
2350
  log.plain("");
1972
2351
  log.info(`Generating summary via ${import_chalk6.default.cyan(config.ai.provider)}\u2026`);
1973
2352
  const allSummaries = [];
1974
2353
  const jsonSessions = [];
1975
- for (const session of sessions) {
1976
- const sessionPayload = { date, sessions: [sessionToPayload(session, date)] };
2354
+ for (let index = 0; index < sessions.length; index++) {
2355
+ const session = sessions[index];
2356
+ const sessionPayload = { date, sessions: [allSessionPayloads[index]] };
1977
2357
  try {
1978
2358
  const bullets = await provider.summarize(sessionPayload);
1979
2359
  const project = session.repoName ?? "unknown";
@@ -1992,8 +2372,8 @@ ${bullets.trim()}`;
1992
2372
  bullets: parseBullets(bullets)
1993
2373
  });
1994
2374
  }
1995
- } catch (e) {
1996
- log.error(`Summary failed [${config.ai.provider}]: ${e.message}`);
2375
+ } catch (error) {
2376
+ log.error(`Summary failed [${config.ai.provider}]: ${error.message}`);
1997
2377
  process.exit(1);
1998
2378
  }
1999
2379
  }
@@ -2005,13 +2385,21 @@ ${bullets.trim()}`;
2005
2385
  log.plain(fullSummary);
2006
2386
  }
2007
2387
  log.plain("");
2388
+ const excludedCounts = countExcludedEvents(allSessionPayloads, excluded);
2389
+ if (excludedCounts.size > 0) {
2390
+ const total = [...excludedCounts.values()].reduce((sum, count) => sum + count, 0);
2391
+ const breakdown = [...excludedCounts.entries()].map(([category, count]) => `${category} \xD7${count}`).join(", ");
2392
+ log.plain(import_chalk6.default.dim(` \u2139 ${total} event${total === 1 ? "" : "s"} not included in summary (${breakdown})`));
2393
+ log.plain(import_chalk6.default.dim(" Use --verbose to inspect \xB7 workscribe config set summary.includeCategories to opt in"));
2394
+ log.plain("");
2395
+ }
2008
2396
  await handleWebhook(opts, config, fullSummary);
2009
2397
  });
2010
2398
  }
2011
2399
 
2012
2400
  // src/cli/export.ts
2013
- var import_fs13 = __toESM(require("fs"));
2014
- var import_path9 = __toESM(require("path"));
2401
+ var import_fs15 = __toESM(require("fs"));
2402
+ var import_path11 = __toESM(require("path"));
2015
2403
  function eventRowToPayload2(row) {
2016
2404
  let metadata = {};
2017
2405
  try {
@@ -2067,9 +2455,9 @@ function registerExport(program2) {
2067
2455
  const payload = { date, sessions: sessionPayloads };
2068
2456
  log.info(`Generating summary via ${config.ai.provider}\u2026`);
2069
2457
  const summary = await provider.summarize(payload);
2070
- const outputPath = opts.output ?? import_path9.default.join(EXPORTS_DIR, `workscribe-${date}.md`);
2071
- if (!import_fs13.default.existsSync(EXPORTS_DIR)) import_fs13.default.mkdirSync(EXPORTS_DIR, { recursive: true, mode: 448 });
2072
- import_fs13.default.writeFileSync(outputPath, `# Work Summary \u2014 ${date}
2458
+ const outputPath = opts.output ?? import_path11.default.join(EXPORTS_DIR, `workscribe-${date}.md`);
2459
+ if (!import_fs15.default.existsSync(EXPORTS_DIR)) import_fs15.default.mkdirSync(EXPORTS_DIR, { recursive: true, mode: 448 });
2460
+ import_fs15.default.writeFileSync(outputPath, `# Work Summary \u2014 ${date}
2073
2461
 
2074
2462
  ${summary}
2075
2463
  `, { encoding: "utf-8", mode: 384 });
@@ -2141,22 +2529,22 @@ function printConfig(obj, indent) {
2141
2529
  }
2142
2530
 
2143
2531
  // src/cli/pause.ts
2144
- var import_fs14 = __toESM(require("fs"));
2532
+ var import_fs16 = __toESM(require("fs"));
2145
2533
  function registerPause(program2) {
2146
2534
  program2.command("pause").description("Pause capture temporarily").action(() => {
2147
- if (import_fs14.default.existsSync(PAUSE_FLAG_PATH)) {
2535
+ if (import_fs16.default.existsSync(PAUSE_FLAG_PATH)) {
2148
2536
  log.info("Capture is already paused. Run workscribe resume to restart.");
2149
2537
  return;
2150
2538
  }
2151
- import_fs14.default.writeFileSync(PAUSE_FLAG_PATH, "", { mode: 384 });
2539
+ import_fs16.default.writeFileSync(PAUSE_FLAG_PATH, "", { mode: 384 });
2152
2540
  log.success("Capture paused. Run workscribe resume to restart.");
2153
2541
  });
2154
2542
  program2.command("resume").description("Resume capture after a pause").action(() => {
2155
- if (!import_fs14.default.existsSync(PAUSE_FLAG_PATH)) {
2543
+ if (!import_fs16.default.existsSync(PAUSE_FLAG_PATH)) {
2156
2544
  log.info("Capture is not paused.");
2157
2545
  return;
2158
2546
  }
2159
- import_fs14.default.rmSync(PAUSE_FLAG_PATH);
2547
+ import_fs16.default.rmSync(PAUSE_FLAG_PATH);
2160
2548
  log.success("Capture resumed.");
2161
2549
  });
2162
2550
  }
@@ -2241,18 +2629,18 @@ function registerClear(program2) {
2241
2629
  }
2242
2630
 
2243
2631
  // src/cli/uninstall.ts
2244
- var import_fs15 = __toESM(require("fs"));
2245
- var import_path10 = __toESM(require("path"));
2632
+ var import_fs17 = __toESM(require("fs"));
2633
+ var import_path12 = __toESM(require("path"));
2246
2634
  var import_os6 = __toESM(require("os"));
2247
2635
  var import_prompts3 = __toESM(require("prompts"));
2248
2636
  var import_chalk9 = __toESM(require("chalk"));
2249
2637
  function detectShellRcFiles() {
2250
2638
  const candidates = [
2251
- { name: "zsh", rcFile: import_path10.default.join(import_os6.default.homedir(), ".zshrc") },
2252
- { name: "bash", rcFile: import_path10.default.join(import_os6.default.homedir(), ".bashrc") },
2253
- { name: "bash", rcFile: import_path10.default.join(import_os6.default.homedir(), ".bash_profile") }
2639
+ { name: "zsh", rcFile: import_path12.default.join(import_os6.default.homedir(), ".zshrc") },
2640
+ { name: "bash", rcFile: import_path12.default.join(import_os6.default.homedir(), ".bashrc") },
2641
+ { name: "bash", rcFile: import_path12.default.join(import_os6.default.homedir(), ".bash_profile") }
2254
2642
  ];
2255
- return candidates.filter((candidate) => import_fs15.default.existsSync(candidate.rcFile) && isHookInstalled(candidate.rcFile));
2643
+ return candidates.filter((candidate) => import_fs17.default.existsSync(candidate.rcFile) && isHookInstalled(candidate.rcFile));
2256
2644
  }
2257
2645
  function registerUninstall(program2) {
2258
2646
  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) => {
@@ -2271,7 +2659,7 @@ function registerUninstall(program2) {
2271
2659
  }
2272
2660
  }
2273
2661
  if (opts.hooksOnly) return;
2274
- if (import_fs15.default.existsSync(WORKSCRIBE_DIR)) {
2662
+ if (import_fs17.default.existsSync(WORKSCRIBE_DIR)) {
2275
2663
  log.plain("");
2276
2664
  const { deleteData } = await (0, import_prompts3.default)({
2277
2665
  type: "confirm",
@@ -2280,7 +2668,7 @@ function registerUninstall(program2) {
2280
2668
  initial: false
2281
2669
  });
2282
2670
  if (deleteData) {
2283
- import_fs15.default.rmSync(WORKSCRIBE_DIR, { recursive: true, force: true });
2671
+ import_fs17.default.rmSync(WORKSCRIBE_DIR, { recursive: true, force: true });
2284
2672
  log.success(`Deleted ${WORKSCRIBE_DIR}`);
2285
2673
  } else {
2286
2674
  log.info(`Data kept at ${WORKSCRIBE_DIR}`);
@@ -2292,9 +2680,306 @@ function registerUninstall(program2) {
2292
2680
  });
2293
2681
  }
2294
2682
 
2683
+ // src/cli/profile.ts
2684
+ var import_fs18 = __toESM(require("fs"));
2685
+ var import_path13 = __toESM(require("path"));
2686
+ var import_chalk10 = __toESM(require("chalk"));
2687
+ var MIN_SUGGEST_COUNT = 5;
2688
+ function registerProfile(program2) {
2689
+ const profile = program2.command("profile").description("Manage stack profiles for better command tracking");
2690
+ profile.command("list").description("List available profiles from the registry").action(async () => {
2691
+ const config = readConfig();
2692
+ const activeNames = new Set(config.capture.profiles);
2693
+ let manifest = readCachedManifest();
2694
+ let offline = false;
2695
+ if (!manifest) {
2696
+ try {
2697
+ manifest = await cacheManifest();
2698
+ } catch {
2699
+ offline = true;
2700
+ manifest = getCachedProfiles().map((p) => ({
2701
+ name: p.name,
2702
+ displayName: p.displayName,
2703
+ tags: [],
2704
+ description: "",
2705
+ keywords: [],
2706
+ detect: p.detect
2707
+ }));
2708
+ }
2709
+ } else {
2710
+ cacheManifest().catch(() => void 0);
2711
+ }
2712
+ if (offline) {
2713
+ log.warn("Registry unreachable \u2014 showing installed profiles only");
2714
+ }
2715
+ if (manifest.length === 0) {
2716
+ log.plain("No profiles available. Check your connection and try again.");
2717
+ return;
2718
+ }
2719
+ const grouped = /* @__PURE__ */ new Map();
2720
+ for (const entry of manifest) {
2721
+ const tag = entry.tags[0] ?? "other";
2722
+ if (!grouped.has(tag)) grouped.set(tag, []);
2723
+ grouped.get(tag).push(entry);
2724
+ }
2725
+ log.plain("");
2726
+ for (const [tag, entries] of grouped) {
2727
+ log.plain(import_chalk10.default.bold(` ${tag.charAt(0).toUpperCase() + tag.slice(1)} profiles`));
2728
+ log.plain(import_chalk10.default.dim(" " + "\u2500".repeat(60)));
2729
+ for (const entry of entries) {
2730
+ const badge = activeNames.has(entry.name) ? import_chalk10.default.green(" [active]") : "";
2731
+ const installed = import_fs18.default.existsSync(import_path13.default.join(PROFILES_DIR, `${entry.name}.json`)) ? import_chalk10.default.dim(" installed") : "";
2732
+ log.plain(` ${import_chalk10.default.cyan(entry.name.padEnd(18))} ${entry.displayName.padEnd(28)}${badge}${installed}`);
2733
+ if (entry.description) log.plain(` ${import_chalk10.default.dim(" ".repeat(18) + entry.description)}`);
2734
+ }
2735
+ log.plain("");
2736
+ }
2737
+ });
2738
+ profile.command("add <names...>").description("Install one or more profiles from the registry").action(async (names) => {
2739
+ const config = readConfig();
2740
+ const activeNames = new Set(config.capture.profiles);
2741
+ for (const name of names) {
2742
+ if (activeNames.has(name)) {
2743
+ log.info(`Profile "${name}" is already active`);
2744
+ continue;
2745
+ }
2746
+ try {
2747
+ log.plain(`Fetching ${import_chalk10.default.cyan(name)}\u2026`);
2748
+ await installProfile(name);
2749
+ activeNames.add(name);
2750
+ log.success(`Profile "${name}" installed`);
2751
+ } catch (err) {
2752
+ log.error(`Failed to install "${name}": ${err.message}`);
2753
+ }
2754
+ }
2755
+ config.capture.profiles = [...activeNames];
2756
+ writeConfig(config);
2757
+ });
2758
+ profile.command("remove <names...>").description("Uninstall one or more profiles").action((names) => {
2759
+ const config = readConfig();
2760
+ const activeNames = new Set(config.capture.profiles);
2761
+ for (const name of names) {
2762
+ if (!activeNames.has(name)) {
2763
+ log.warn(`Profile "${name}" is not active`);
2764
+ continue;
2765
+ }
2766
+ activeNames.delete(name);
2767
+ const cachePath = import_path13.default.join(PROFILES_DIR, `${name}.json`);
2768
+ if (import_fs18.default.existsSync(cachePath)) import_fs18.default.unlinkSync(cachePath);
2769
+ log.success(`Profile "${name}" removed`);
2770
+ }
2771
+ config.capture.profiles = [...activeNames];
2772
+ writeConfig(config);
2773
+ });
2774
+ profile.command("update [name]").description("Re-fetch installed profiles from the registry").action(async (name) => {
2775
+ const config = readConfig();
2776
+ const toUpdate = name ? [name] : config.capture.profiles;
2777
+ if (toUpdate.length === 0) {
2778
+ log.plain("No profiles installed. Run workscribe profile add <name> first.");
2779
+ return;
2780
+ }
2781
+ for (const profileName of toUpdate) {
2782
+ try {
2783
+ log.plain(`Updating ${import_chalk10.default.cyan(profileName)}\u2026`);
2784
+ await installProfile(profileName);
2785
+ log.success(`${profileName} updated`);
2786
+ } catch (err) {
2787
+ log.error(`Failed to update "${profileName}": ${err.message}`);
2788
+ }
2789
+ }
2790
+ });
2791
+ profile.command("detect").description("Scan the current directory and suggest matching profiles").action(() => {
2792
+ const cwd = process.cwd();
2793
+ const matches = detectProfilesForDirectory(cwd);
2794
+ if (matches.length === 0) {
2795
+ const cached = readCachedManifest();
2796
+ if (!cached) {
2797
+ log.plain("No manifest cached. Run workscribe profile list to fetch it first.");
2798
+ } else {
2799
+ log.plain(`No profiles detected for ${import_chalk10.default.dim(cwd)}`);
2800
+ log.plain(import_chalk10.default.dim("Run workscribe profile list to browse available profiles."));
2801
+ }
2802
+ return;
2803
+ }
2804
+ log.plain("");
2805
+ log.plain(import_chalk10.default.bold(" Profiles detected for this directory:"));
2806
+ log.plain("");
2807
+ for (const entry of matches) {
2808
+ const files = entry.detect?.files?.join(", ") ?? "";
2809
+ log.plain(` ${import_chalk10.default.cyan(entry.name.padEnd(18))} ${entry.displayName} ${import_chalk10.default.dim("(" + files + ")")}`);
2810
+ }
2811
+ log.plain("");
2812
+ log.plain(` Run: ${import_chalk10.default.cyan("workscribe profile add " + matches.map((e) => e.name).join(" "))}`);
2813
+ log.plain("");
2814
+ });
2815
+ profile.command("suggest").description("Analyse recent unclassified commands and suggest profiles").option("--days <n>", "number of days to analyse", "30").action((opts) => {
2816
+ const days = Math.max(1, parseInt(opts.days, 10) || 30);
2817
+ const toolCounts = getOtherEventToolCounts(days);
2818
+ if (toolCounts.length === 0) {
2819
+ log.plain("No unclassified commands found in the last " + days + " days.");
2820
+ return;
2821
+ }
2822
+ const manifest = readCachedManifest();
2823
+ if (!manifest) {
2824
+ log.plain("No manifest cached. Run workscribe profile list first.");
2825
+ return;
2826
+ }
2827
+ const config = readConfig();
2828
+ const activeNames = new Set(config.capture.profiles);
2829
+ const suggestions = [];
2830
+ for (const entry of manifest) {
2831
+ if (activeNames.has(entry.name)) continue;
2832
+ const matchedCount = toolCounts.filter((tc) => entry.keywords.some((kw) => tc.tool.toLowerCase().includes(kw.toLowerCase()))).reduce((sum, tc) => sum + tc.count, 0);
2833
+ if (matchedCount >= MIN_SUGGEST_COUNT) {
2834
+ suggestions.push({ entry, count: matchedCount });
2835
+ }
2836
+ }
2837
+ suggestions.sort((a, b) => b.count - a.count);
2838
+ if (suggestions.length === 0) {
2839
+ log.plain("No profile matches found. Your commands are well covered.");
2840
+ return;
2841
+ }
2842
+ log.plain("");
2843
+ log.plain(import_chalk10.default.bold(` Analysed last ${days} days of unclassified commands:`));
2844
+ log.plain("");
2845
+ for (const { entry, count } of suggestions) {
2846
+ log.plain(
2847
+ ` ${import_chalk10.default.cyan(entry.name.padEnd(18))} ${String(count).padStart(4)} commands \u2192 ${entry.displayName}`
2848
+ );
2849
+ }
2850
+ log.plain("");
2851
+ log.plain(` Run: ${import_chalk10.default.cyan("workscribe profile add " + suggestions.map((s) => s.entry.name).join(" "))}`);
2852
+ log.plain("");
2853
+ });
2854
+ }
2855
+
2856
+ // src/cli/note.ts
2857
+ function registerNote(program2) {
2858
+ program2.command("note <text>").description("Attach a free-text annotation to the current session").action((text) => {
2859
+ const { repoName, branch } = getGitContext(process.cwd());
2860
+ insertEvent({
2861
+ captured_at: (/* @__PURE__ */ new Date()).toISOString(),
2862
+ command: "workscribe note",
2863
+ cwd: process.cwd(),
2864
+ exit_code: null,
2865
+ duration_ms: null,
2866
+ repo_name: repoName,
2867
+ branch,
2868
+ category: "note",
2869
+ metadata: JSON.stringify({ text })
2870
+ });
2871
+ log.success(`Note saved${repoName ? ` to ${repoName}` : ""}`);
2872
+ });
2873
+ }
2874
+
2875
+ // src/cli/week.ts
2876
+ var import_chalk11 = __toESM(require("chalk"));
2877
+ var import_fs19 = __toESM(require("fs"));
2878
+ var import_path14 = __toESM(require("path"));
2879
+ var import_os7 = __toESM(require("os"));
2880
+ function eventRowToPayload3(row) {
2881
+ let metadata = {};
2882
+ try {
2883
+ if (row.metadata) metadata = JSON.parse(row.metadata);
2884
+ } catch {
2885
+ }
2886
+ const payload = {
2887
+ category: row.category,
2888
+ metadata,
2889
+ exitCode: row.exit_code,
2890
+ durationMs: row.duration_ms
2891
+ };
2892
+ if (row.category === "other" && typeof metadata.hint === "string") {
2893
+ payload.hint = metadata.hint;
2894
+ const { hint: _hint, tool: _tool, ...rest } = metadata;
2895
+ payload.metadata = rest;
2896
+ }
2897
+ return payload;
2898
+ }
2899
+ function sessionToPayload2(session, date) {
2900
+ return {
2901
+ date,
2902
+ project: session.repoName,
2903
+ branch: session.events[0]?.branch ?? null,
2904
+ startTime: formatDate(session.startedAt),
2905
+ endTime: formatDate(session.endedAt),
2906
+ durationMinutes: session.durationMinutes,
2907
+ events: session.events.map(eventRowToPayload3)
2908
+ };
2909
+ }
2910
+ async function postSummary(opts, config, summary) {
2911
+ const webhookUrl = opts.webhook ?? (opts.slack ? config.integrations?.slackWebhook : void 0);
2912
+ if (!webhookUrl) {
2913
+ if (opts.slack) log.warn("--slack requires integrations.slackWebhook to be set in config");
2914
+ return;
2915
+ }
2916
+ try {
2917
+ log.info("Posting to webhook\u2026");
2918
+ const username = import_os7.default.userInfo().username;
2919
+ const body = isSlackWebhookUrl(webhookUrl) ? formatForSlack(summary, username) : summary;
2920
+ await postToWebhook(webhookUrl, body);
2921
+ log.success("Posted.");
2922
+ } catch (error) {
2923
+ log.error(`Webhook failed: ${error.message}`);
2924
+ }
2925
+ }
2926
+ function registerWeek(program2) {
2927
+ 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) => {
2928
+ const config = readConfig();
2929
+ let provider;
2930
+ try {
2931
+ provider = getProvider(config);
2932
+ } catch (error) {
2933
+ log.error(error.message);
2934
+ process.exit(1);
2935
+ }
2936
+ const today = todayDate();
2937
+ const weekStart = startOfWeek(today);
2938
+ const days = eachDayOfRange(weekStart, today);
2939
+ const weeklyDays = [];
2940
+ for (const day of days) {
2941
+ const sessions = getOrBuildSessions(day);
2942
+ if (sessions.length > 0) {
2943
+ weeklyDays.push({ date: day, sessions: sessions.map((session) => sessionToPayload2(session, day)) });
2944
+ }
2945
+ }
2946
+ if (weeklyDays.length === 0) {
2947
+ log.plain("No activity captured this week.");
2948
+ return;
2949
+ }
2950
+ const cacheKey = isoWeekKey(today);
2951
+ const cacheFile = import_path14.default.join(WEEKLY_CACHE_DIR, `${cacheKey}.md`);
2952
+ if (!opts.fresh && import_fs19.default.existsSync(cacheFile)) {
2953
+ const cached = import_fs19.default.readFileSync(cacheFile, "utf-8");
2954
+ log.plain("");
2955
+ log.plain(cached);
2956
+ log.plain("");
2957
+ log.plain(import_chalk11.default.dim(" (cached \u2014 use --fresh to regenerate)"));
2958
+ await postSummary(opts, config, cached);
2959
+ return;
2960
+ }
2961
+ const payload = { weekStart, weekEnd: today, days: weeklyDays };
2962
+ log.plain("");
2963
+ log.info(`Generating weekly digest via ${import_chalk11.default.cyan(config.ai.provider)}\u2026`);
2964
+ try {
2965
+ const summary = provider.summarizeWeek ? await provider.summarizeWeek(payload) : await provider.summarize({ date: today, sessions: weeklyDays.flatMap((day) => day.sessions) });
2966
+ log.plain("");
2967
+ log.plain(summary);
2968
+ log.plain("");
2969
+ import_fs19.default.mkdirSync(WEEKLY_CACHE_DIR, { recursive: true });
2970
+ import_fs19.default.writeFileSync(cacheFile, summary, { encoding: "utf-8", mode: 384 });
2971
+ await postSummary(opts, config, summary);
2972
+ } catch (error) {
2973
+ log.error(`Weekly digest failed [${config.ai.provider}]: ${error.message}`);
2974
+ process.exit(1);
2975
+ }
2976
+ });
2977
+ }
2978
+
2295
2979
  // src/index.ts
2980
+ assertNodeVersion(process.versions.node);
2296
2981
  var program = new import_commander.Command();
2297
- program.name("workscribe").description("Automatic developer work journal").version("1.9.0");
2982
+ program.name("workscribe").description("Automatic developer work journal").version("1.11.0");
2298
2983
  registerCapture(program);
2299
2984
  registerHook(program);
2300
2985
  registerInit(program);
@@ -2308,5 +2993,8 @@ registerPause(program);
2308
2993
  registerUndo(program);
2309
2994
  registerClear(program);
2310
2995
  registerUninstall(program);
2996
+ registerProfile(program);
2997
+ registerNote(program);
2998
+ registerWeek(program);
2311
2999
  program.parse();
2312
3000
  //# sourceMappingURL=index.js.map