noctrace 1.1.0 → 1.3.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.
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--radius-sm:.25rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-8{top:calc(var(--spacing) * 8)}.right-0{right:calc(var(--spacing) * 0)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-2{right:calc(var(--spacing) * 2)}.left-0{left:calc(var(--spacing) * 0)}.left-2{left:calc(var(--spacing) * 2)}.z-10{z-index:10}.z-50{z-index:50}.row-1{grid-row:1}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-full{height:100%}.w-full{width:100%}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-sm{border-radius:var(--radius-sm)}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-7{padding-left:calc(var(--spacing) * 7)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.uppercase{text-transform:uppercase}.italic{font-style:italic}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.\[file\:line\]{file:line}@media (width>=48rem){.md\:hidden{display:none}}}:root{--ctp-base:#1e1e2e;--ctp-mantle:#181825;--ctp-crust:#11111b;--ctp-surface0:#313244;--ctp-surface1:#45475a;--ctp-surface2:#585b70;--ctp-overlay0:#6c7086;--ctp-overlay1:#7f849c;--ctp-text:#cdd6f4;--ctp-subtext0:#a6adc8;--ctp-subtext1:#bac2de;--ctp-blue:#89b4fa;--ctp-green:#a6e3a1;--ctp-yellow:#f9e2af;--ctp-peach:#fab387;--ctp-mauve:#cba6f7;--ctp-teal:#94e2d5;--ctp-red:#f38ba8;--ctp-pink:#f5c2e7;--ctp-lavender:#b4befe;--ctp-sky:#89dceb;--color-read:var(--ctp-blue);--color-write:var(--ctp-green);--color-edit:var(--ctp-yellow);--color-bash:var(--ctp-peach);--color-agent:var(--ctp-mauve);--color-grep:var(--ctp-teal);--color-error:var(--ctp-red);--color-running:var(--ctp-pink);--color-monitor:var(--ctp-sky)}body{background-color:var(--ctp-base);color:var(--ctp-text);font-family:SF Mono,Cascadia Code,JetBrains Mono,Fira Code,ui-monospace,monospace}@keyframes skeleton-shimmer{0%,to{opacity:.6}50%{opacity:.3}}@keyframes pulse-edge{0%,to{opacity:.6}50%{opacity:.1}}.running-pulse{animation:1.2s ease-in-out infinite pulse-edge}@keyframes noc-pulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.85)}}@keyframes noc-blink{0%,to{opacity:1}50%{opacity:0}}@media (width<=768px){.hidden-mobile{display:none!important}}.sidebar-panel{background-color:var(--ctp-mantle);flex-direction:column;flex-shrink:0;width:240px;display:flex;overflow:hidden}@media (width<=767px){.sidebar-panel{width:240px;transition:transform .2s;position:fixed;top:0;bottom:0;left:0;transform:translate(-100%)}.sidebar-panel[data-open=true]{transform:translate(0)}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
@@ -7,8 +7,8 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-D3XepZ5e.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-DwPuae45.css">
10
+ <script type="module" crossorigin src="/assets/index-BGW0xA7n.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-DlKrxvV-.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
@@ -7,6 +7,7 @@ import { createServer } from 'node:http';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { buildApiRouter } from './routes/api.js';
10
+ import { buildPatternsRouter } from './routes/patterns.js';
10
11
  import { setupWebSocket } from './ws.js';
11
12
  import { getClaudeHome } from './config.js';
12
13
  const BASE_PORT = parseInt(process.env['PORT'] ?? '4117', 10);
@@ -22,6 +23,7 @@ export function startServer() {
22
23
  app.use(express.json());
23
24
  const wss = setupWebSocket(server, claudeHome);
24
25
  app.use('/api', buildApiRouter(claudeHome, wss));
26
+ app.use('/api/patterns', buildPatternsRouter(claudeHome));
25
27
  app.get('/api/health', (_req, res) => {
26
28
  res.json({ status: 'ok' });
27
29
  });
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Cross-session Patterns rollup orchestrator.
3
+ * Iterates registered providers, calls listSessions + readSession for each,
4
+ * and folds results into a PatternsResponse.
5
+ *
6
+ * Phase B: uses the Provider registry instead of walking ~/.claude/projects/ directly.
7
+ */
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import os from 'node:os';
11
+ import { listProviders } from '../shared/providers/index.js';
12
+ import { createClaudeCodeProvider } from '../shared/providers/claude-code.js';
13
+ import { buildSessionSummaryFromContent, } from '../shared/session-summary.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Window math
16
+ // ---------------------------------------------------------------------------
17
+ /** Start of the local calendar day containing `nowMs`, in local time. */
18
+ function startOfDay(nowMs) {
19
+ const d = new Date(nowMs);
20
+ d.setHours(0, 0, 0, 0);
21
+ return d.getTime();
22
+ }
23
+ /** Compute window bounds using calendar semantics in server-local time. */
24
+ function computeWindow(kind, now) {
25
+ const todayStart = startOfDay(now);
26
+ const MS_PER_DAY = 86_400_000;
27
+ let startMs;
28
+ let spanDays;
29
+ if (kind === 'today') {
30
+ startMs = todayStart;
31
+ spanDays = 1;
32
+ }
33
+ else if (kind === '7d') {
34
+ startMs = todayStart - 6 * MS_PER_DAY;
35
+ spanDays = 7;
36
+ }
37
+ else {
38
+ startMs = todayStart - 29 * MS_PER_DAY;
39
+ spanDays = 30;
40
+ }
41
+ const endMs = now;
42
+ const prevEndMs = startMs;
43
+ const prevStartMs = startMs - spanDays * MS_PER_DAY;
44
+ const label = formatDateRange(startMs, endMs);
45
+ return { startMs, endMs, prevStartMs, prevEndMs, label };
46
+ }
47
+ /** Format a date range as "Apr 7 – Apr 14, 2026". */
48
+ function formatDateRange(startMs, endMs) {
49
+ const fmt = (ms, withYear) => {
50
+ const d = new Date(ms);
51
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
52
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
53
+ const base = `${months[d.getMonth()]} ${d.getDate()}`;
54
+ return withYear ? `${base}, ${d.getFullYear()}` : base;
55
+ };
56
+ const start = new Date(startMs);
57
+ const end = new Date(endMs);
58
+ const sameYear = start.getFullYear() === end.getFullYear();
59
+ return `${fmt(startMs, false)} \u2013 ${fmt(endMs, sameYear)}`;
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // Percentile helper
63
+ // ---------------------------------------------------------------------------
64
+ /** Compute the p-th percentile (0..100) of a numeric array via linear interpolation. */
65
+ function percentile(arr, p) {
66
+ if (arr.length === 0)
67
+ return 0;
68
+ const sorted = [...arr].sort((a, b) => a - b);
69
+ if (p <= 0)
70
+ return sorted[0];
71
+ if (p >= 100)
72
+ return sorted[sorted.length - 1];
73
+ const idx = (p / 100) * (sorted.length - 1);
74
+ const lo = Math.floor(idx);
75
+ const hi = Math.ceil(idx);
76
+ if (lo === hi)
77
+ return sorted[lo];
78
+ return sorted[lo] + (idx - lo) * (sorted[hi] - sorted[lo]);
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Bounded-concurrency parallel execution
82
+ // ---------------------------------------------------------------------------
83
+ /** Run `tasks` in parallel chunks of `concurrency`. */
84
+ async function runChunked(tasks, concurrency) {
85
+ const results = [];
86
+ for (let i = 0; i < tasks.length; i += concurrency) {
87
+ const chunk = tasks.slice(i, i + concurrency).map((t) => t());
88
+ const batch = await Promise.all(chunk);
89
+ results.push(...batch);
90
+ }
91
+ return results;
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // Project de-slugification
95
+ // ---------------------------------------------------------------------------
96
+ /**
97
+ * Convert a Claude project slug to a human-readable path.
98
+ * Algorithm: split on `-`, drop empty leading segment.
99
+ * If first segment is `Users` and second matches the current user's username,
100
+ * replace with `~`; otherwise join with `/`.
101
+ */
102
+ function deslugify(slug, username) {
103
+ // Slugs use `-` as separator but project paths can contain hyphens too.
104
+ // The slug is produced by replacing `/` with `-` in the absolute path,
105
+ // e.g. /Users/lam/dev/noctrace → -Users-lam-dev-noctrace
106
+ const parts = slug.split('-');
107
+ // Drop the leading empty string before the first `-`
108
+ if (parts[0] === '')
109
+ parts.shift();
110
+ if (parts.length === 0)
111
+ return slug;
112
+ if (parts[0] === 'Users' && parts[1] === username) {
113
+ // Replace /Users/<user> with ~
114
+ return '~/' + parts.slice(2).join('/');
115
+ }
116
+ return '/' + parts.join('/');
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // Main export
120
+ // ---------------------------------------------------------------------------
121
+ /**
122
+ * Compute the cross-session Patterns rollup for the given time window.
123
+ *
124
+ * @param window - 'today' | '7d' | '30d'
125
+ * @param cache - mtime-invalidated summary cache
126
+ * @param claudeHome - optional override; when provided a temporary claude-code
127
+ * provider is created for that home directory (used in tests)
128
+ * @param now - injectable for tests; defaults to Date.now()
129
+ */
130
+ export async function computeRollup(window, cache, claudeHome, now) {
131
+ const nowMs = now ?? Date.now();
132
+ const home = claudeHome ?? (process.env['CLAUDE_HOME'] ?? path.join(os.homedir(), '.claude'));
133
+ const username = os.userInfo().username;
134
+ const bounds = computeWindow(window, nowMs);
135
+ const { startMs, endMs, prevStartMs, prevEndMs } = bounds;
136
+ const MS_PER_DAY = 86_400_000;
137
+ // Use a provider scoped to the requested claudeHome when an override is provided
138
+ // (tests pass a tmpHome); otherwise use the global registry.
139
+ const providers = claudeHome
140
+ ? [createClaudeCodeProvider(claudeHome)]
141
+ : listProviders();
142
+ // Collect session records from all providers
143
+ // We use a wider window for listing so the pre-filter + previous window both work.
144
+ const listWindow = {
145
+ startMs: prevStartMs - MS_PER_DAY,
146
+ endMs: endMs + MS_PER_DAY,
147
+ };
148
+ const allRecords = [];
149
+ for (const provider of providers) {
150
+ let sessions;
151
+ try {
152
+ sessions = await provider.listSessions(listWindow);
153
+ }
154
+ catch {
155
+ // If a provider fails to list, skip it gracefully
156
+ continue;
157
+ }
158
+ for (const meta of sessions) {
159
+ // Derive the file path from rawSlug for Claude Code cache-key purposes.
160
+ // rawSlug format: '<projectSlug>/<sessionId>'
161
+ const slashIdx = meta.rawSlug.indexOf('/');
162
+ if (slashIdx === -1)
163
+ continue;
164
+ const slug = meta.rawSlug.slice(0, slashIdx);
165
+ const sessionId = meta.rawSlug.slice(slashIdx + 1);
166
+ const filePath = path.join(home, 'projects', slug, `${sessionId}.jsonl`);
167
+ const mtimeMs = meta.endMs ?? meta.startMs;
168
+ allRecords.push({
169
+ cacheKey: `${provider.id}:${meta.rawSlug}`,
170
+ filePath,
171
+ rawSlug: meta.rawSlug,
172
+ slug,
173
+ sessionId,
174
+ mtimeMs,
175
+ provider,
176
+ });
177
+ }
178
+ }
179
+ // Parse + cache each session
180
+ const errors = [];
181
+ const parsed = await runChunked(allRecords.map((sr) => async () => {
182
+ // Use the file path as cache key for mtime-based invalidation.
183
+ // The build function reads the file and delegates to the provider for parsing.
184
+ const { summary, error } = await cache.getOrBuild(sr.filePath, async () => {
185
+ // Read raw content for accurate compaction boundary counting.
186
+ let rawContent;
187
+ try {
188
+ rawContent = await fs.readFile(sr.filePath, 'utf8');
189
+ }
190
+ catch (err) {
191
+ throw new Error(err instanceof Error ? err.message : String(err));
192
+ }
193
+ // Use the provider to parse into WaterfallRow[] for consistency.
194
+ const session = await sr.provider.readSession(sr.rawSlug);
195
+ const rows = session.native;
196
+ return buildSessionSummaryFromContent(rows, sr.sessionId, sr.slug, rawContent);
197
+ });
198
+ return { sr, summary, error };
199
+ }), 20);
200
+ // Collect errors and filter out failed parses
201
+ const summaries = [];
202
+ for (const item of parsed) {
203
+ if (item.error && item.summary === null) {
204
+ errors.push({ path: item.sr.filePath, reason: item.error });
205
+ }
206
+ else if (item.summary !== null) {
207
+ summaries.push({ sr: item.sr, summary: item.summary });
208
+ }
209
+ }
210
+ // Separate current vs previous window by session start time
211
+ const currentSummaries = summaries.filter(({ summary }) => summary.startMs >= startMs && summary.startMs < endMs);
212
+ const prevSummaries = summaries.filter(({ summary }) => summary.startMs >= prevStartMs && summary.startMs < prevEndMs);
213
+ // --- Health distribution ---
214
+ const emptyDist = () => ({ A: 0, B: 0, C: 0, D: 0, F: 0 });
215
+ const currentDist = emptyDist();
216
+ for (const { summary } of currentSummaries) {
217
+ if (summary.healthGrade)
218
+ currentDist[summary.healthGrade]++;
219
+ }
220
+ const prevDist = emptyDist();
221
+ for (const { summary } of prevSummaries) {
222
+ if (summary.healthGrade)
223
+ prevDist[summary.healthGrade]++;
224
+ }
225
+ const projectBuckets = new Map();
226
+ for (const { summary } of currentSummaries) {
227
+ const bucket = projectBuckets.get(summary.projectSlug);
228
+ if (bucket) {
229
+ bucket.sessions.push(summary);
230
+ }
231
+ else {
232
+ projectBuckets.set(summary.projectSlug, { sessions: [summary] });
233
+ }
234
+ }
235
+ const BAD_GRADES = new Set(['D', 'F']);
236
+ const rotLeaderboard = [];
237
+ for (const [slug, bucket] of projectBuckets) {
238
+ const sessions = bucket.sessions;
239
+ const badSessions = sessions.filter((s) => s.healthGrade && BAD_GRADES.has(s.healthGrade));
240
+ const bad = badSessions.length;
241
+ const badPct = sessions.length > 0 ? bad / sessions.length : 0;
242
+ const totalCompactions = sessions.reduce((sum, s) => sum + s.compactionCount, 0);
243
+ const avgCompactions = sessions.length > 0 ? totalCompactions / sessions.length : 0;
244
+ // Worst session = lowest healthScore among sessions with a score
245
+ let worstSessionId = null;
246
+ let lowestScore = Infinity;
247
+ for (const s of sessions) {
248
+ if (s.healthScore !== null && s.healthScore < lowestScore) {
249
+ lowestScore = s.healthScore;
250
+ worstSessionId = s.sessionId;
251
+ }
252
+ }
253
+ rotLeaderboard.push({
254
+ project: deslugify(slug, username),
255
+ rawSlug: slug,
256
+ sessions: sessions.length,
257
+ bad,
258
+ badPct,
259
+ avgCompactions,
260
+ worstSessionId,
261
+ });
262
+ }
263
+ // Sort by badPct descending, then calls descending as tiebreaker
264
+ rotLeaderboard.sort((a, b) => {
265
+ if (b.badPct !== a.badPct)
266
+ return b.badPct - a.badPct;
267
+ return b.sessions - a.sessions;
268
+ });
269
+ // --- Tool health ---
270
+ // Aggregate tool stats from current window
271
+ const toolAgg = new Map();
272
+ for (const { summary } of currentSummaries) {
273
+ for (const [tool, count] of Object.entries(summary.toolCounts)) {
274
+ const agg = toolAgg.get(tool) ?? { calls: 0, failures: 0, latencies: [] };
275
+ agg.calls += count;
276
+ agg.failures += summary.toolFailures[tool] ?? 0;
277
+ agg.latencies.push(...(summary.toolLatencies[tool] ?? []));
278
+ toolAgg.set(tool, agg);
279
+ }
280
+ }
281
+ // Aggregate call counts from previous window for delta
282
+ const prevToolCalls = new Map();
283
+ for (const { summary } of prevSummaries) {
284
+ for (const [tool, count] of Object.entries(summary.toolCounts)) {
285
+ prevToolCalls.set(tool, (prevToolCalls.get(tool) ?? 0) + count);
286
+ }
287
+ }
288
+ // Only include tools with >= 10 calls in current window
289
+ const toolHealth = [];
290
+ for (const [tool, agg] of toolAgg) {
291
+ if (agg.calls < 10)
292
+ continue;
293
+ toolHealth.push({
294
+ tool,
295
+ calls: agg.calls,
296
+ failures: agg.failures,
297
+ failPct: agg.calls > 0 ? agg.failures / agg.calls : 0,
298
+ p50ms: Math.round(percentile(agg.latencies, 50)),
299
+ p95ms: Math.round(percentile(agg.latencies, 95)),
300
+ callsPrev: prevToolCalls.get(tool) ?? 0,
301
+ });
302
+ }
303
+ // Sort by failPct descending, then calls descending
304
+ toolHealth.sort((a, b) => {
305
+ if (b.failPct !== a.failPct)
306
+ return b.failPct - a.failPct;
307
+ return b.calls - a.calls;
308
+ });
309
+ return {
310
+ window: {
311
+ kind: window,
312
+ startMs,
313
+ endMs,
314
+ prevStartMs,
315
+ prevEndMs,
316
+ label: bounds.label,
317
+ },
318
+ sessionCounts: {
319
+ current: currentSummaries.length,
320
+ previous: prevSummaries.length,
321
+ },
322
+ healthDist: {
323
+ current: currentDist,
324
+ previous: prevDist,
325
+ },
326
+ rotLeaderboard,
327
+ toolHealth,
328
+ errors,
329
+ };
330
+ }
@@ -1,18 +1,22 @@
1
1
  /**
2
2
  * REST API routes for project and session data.
3
- * All data is read from JSONL files on disk no in-memory caching.
3
+ * All data is read from JSONL files on disk via the Provider registry.
4
+ * Phase B: session reads are routed through getProvider() instead of calling
5
+ * parseJsonlContent directly. Raw file content is still read separately for
6
+ * enrichments not yet in the Provider interface (compaction, drift, tips, etc.).
4
7
  */
5
8
  import express, { Router } from 'express';
6
9
  import fs from 'node:fs/promises';
7
10
  import path from 'node:path';
8
11
  import { WebSocket } from 'ws';
9
- import { parseJsonlContent, parseCompactionBoundaries, extractSessionId, extractSessionTitle, extractAgentIds, parseSubAgentContent, parseInstructionsLoaded, } from '../../shared/parser.js';
12
+ import { parseCompactionBoundaries, extractSessionId, extractSessionTitle, extractAgentIds, parseSubAgentContent, parseInstructionsLoaded, } from '../../shared/parser.js';
10
13
  import { parseSessionResultMetrics, parseInitContext } from '../../shared/session-metadata.js';
11
14
  import { computeContextHealth } from '../../shared/health.js';
12
15
  import { parseAssistantTurns, computeDrift } from '../../shared/drift.js';
13
16
  import { attachEfficiencyTips } from '../../shared/tips.js';
14
17
  import { attachSecurityTips } from '../../shared/security-tips.js';
15
18
  import { sessionToOtlp } from '../../shared/otlp-export.js';
19
+ import { createClaudeCodeProvider } from '../../shared/providers/claude-code.js';
16
20
  /**
17
21
  * Read ~/.claude/sessions/*.json and return a Set of sessionIds
18
22
  * whose PID is still a running claude process.
@@ -63,13 +67,19 @@ function assertWithinBase(resolved, base) {
63
67
  /**
64
68
  * Build the Express router, scoped to a given Claude home directory.
65
69
  * `wss` is the WebSocketServer instance used to broadcast hook events to
66
- * all connected browser clients.
70
+ * all connected browser clients. Optional in tests that only exercise
71
+ * non-WebSocket endpoints.
67
72
  */
68
73
  export function buildApiRouter(claudeHome, wss) {
69
74
  const router = Router();
70
75
  const projectsDir = path.join(claudeHome, 'projects');
71
76
  const teamsDir = path.join(claudeHome, 'teams');
72
77
  const tasksDir = path.join(claudeHome, 'tasks');
78
+ /**
79
+ * Provider scoped to this router's claudeHome.
80
+ * Used by session-read endpoints to route through the Provider abstraction.
81
+ */
82
+ const sessionProvider = createClaudeCodeProvider(claudeHome);
73
83
  /**
74
84
  * In-memory registry of MCP-registered session paths.
75
85
  * Populated by POST /api/sessions/register; cleared on unregister or server restart.
@@ -80,6 +90,8 @@ export function buildApiRouter(claudeHome, wss) {
80
90
  const dockerHeartbeats = new Map();
81
91
  /** Broadcast a message to all connected WebSocket clients. */
82
92
  function broadcast(msg) {
93
+ if (!wss)
94
+ return;
83
95
  const payload = JSON.stringify(msg);
84
96
  for (const client of wss.clients) {
85
97
  if (client.readyState === WebSocket.OPEN) {
@@ -420,15 +432,18 @@ export function buildApiRouter(claudeHome, wss) {
420
432
  return;
421
433
  }
422
434
  try {
435
+ // Use the provider to read session rows; fall back to 404 when not found.
436
+ let rows;
423
437
  let content;
424
438
  try {
425
439
  content = await fs.readFile(filePath, 'utf8');
440
+ const session = await sessionProvider.readSession(`${slug}/${id}`);
441
+ rows = session.native;
426
442
  }
427
443
  catch {
428
444
  res.status(404).json({ error: `Session not found: ${slug}/${id}` });
429
445
  return;
430
446
  }
431
- const rows = parseJsonlContent(content);
432
447
  const sessionId = extractSessionId(content) ?? id;
433
448
  const otlp = sessionToOtlp(rows, sessionId);
434
449
  res.setHeader('Content-Type', 'application/json');
@@ -457,15 +472,19 @@ export function buildApiRouter(claudeHome, wss) {
457
472
  return;
458
473
  }
459
474
  try {
475
+ // Use the provider to read session rows; fall back to 404 when not found.
476
+ let rows;
460
477
  let content;
461
478
  try {
479
+ // Read raw content for compaction/drift/tips analysis (not in Provider interface yet)
462
480
  content = await fs.readFile(filePath, 'utf8');
481
+ const session = await sessionProvider.readSession(`${slug}/${id}`);
482
+ rows = session.native;
463
483
  }
464
484
  catch {
465
485
  res.status(404).json({ error: `Session not found: ${slug}/${id}` });
466
486
  return;
467
487
  }
468
- const rows = parseJsonlContent(content);
469
488
  const boundaries = parseCompactionBoundaries(content);
470
489
  const health = computeContextHealth(rows, boundaries.length);
471
490
  const sessionId = extractSessionId(content) ?? id;
@@ -669,7 +688,7 @@ export function buildApiRouter(claudeHome, wss) {
669
688
  };
670
689
  // For SubagentStart events, broadcast a separate message type
671
690
  // so the client can show an in-progress agent row immediately
672
- if (event.hook_event_name === 'SubagentStart' && event.agent_id) {
691
+ if (event.hook_event_name === 'SubagentStart' && event.agent_id && wss) {
673
692
  const subagentMsg = {
674
693
  type: 'subagent-start',
675
694
  agentId: event.agent_id,
@@ -685,9 +704,11 @@ export function buildApiRouter(claudeHome, wss) {
685
704
  }
686
705
  const message = { type: 'hook-event', event };
687
706
  const payload = JSON.stringify(message);
688
- for (const client of wss.clients) {
689
- if (client.readyState === WebSocket.OPEN) {
690
- client.send(payload);
707
+ if (wss) {
708
+ for (const client of wss.clients) {
709
+ if (client.readyState === WebSocket.OPEN) {
710
+ client.send(payload);
711
+ }
691
712
  }
692
713
  }
693
714
  res.json({ ok: true });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * GET /api/patterns?window=today|7d|30d
3
+ * Returns a PatternsResponse aggregated across all sessions in the current
4
+ * Claude home directory within the requested time window.
5
+ */
6
+ import { Router } from 'express';
7
+ import { computeRollup } from '../rollup.js';
8
+ import { createSummaryCache } from '../summary-cache.js';
9
+ import { getClaudeHome } from '../config.js';
10
+ const VALID_WINDOWS = new Set(['today', '7d', '30d']);
11
+ // Module-level cache shared across requests (lives for the duration of the process)
12
+ const summaryCache = createSummaryCache();
13
+ /**
14
+ * Build the Express router for the patterns endpoint.
15
+ * Accepts an optional claudeHome override (used in tests).
16
+ */
17
+ export function buildPatternsRouter(claudeHomeOverride) {
18
+ const router = Router();
19
+ /**
20
+ * GET /patterns
21
+ * Query params:
22
+ * window — 'today' | '7d' | '30d' (default '7d')
23
+ */
24
+ router.get('/', async (req, res) => {
25
+ const raw = req.query['window'];
26
+ const windowKind = typeof raw === 'string' ? raw : '7d';
27
+ if (!VALID_WINDOWS.has(windowKind)) {
28
+ res.status(400).json({ error: `Invalid window "${windowKind}". Must be one of: today, 7d, 30d` });
29
+ return;
30
+ }
31
+ try {
32
+ const claudeHome = claudeHomeOverride ?? getClaudeHome();
33
+ const result = await computeRollup(windowKind, summaryCache, claudeHome);
34
+ res.json(result);
35
+ }
36
+ catch (err) {
37
+ const message = err instanceof Error ? err.message : String(err);
38
+ res.status(500).json({ error: message });
39
+ }
40
+ });
41
+ return router;
42
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * In-memory mtime-invalidated cache for per-session summaries.
3
+ * Stat-on-read strategy: no background watcher needed.
4
+ */
5
+ import fs from 'node:fs/promises';
6
+ // ---------------------------------------------------------------------------
7
+ // Implementation
8
+ // ---------------------------------------------------------------------------
9
+ /**
10
+ * Create a new, empty {@link SummaryCache} instance.
11
+ */
12
+ export function createSummaryCache() {
13
+ const store = new Map();
14
+ return {
15
+ async getOrBuild(filePath, build) {
16
+ // Stat the file to get current mtime
17
+ let statResult;
18
+ try {
19
+ statResult = await fs.stat(filePath);
20
+ }
21
+ catch {
22
+ // File not accessible — treat as invalidated
23
+ store.delete(filePath);
24
+ return { summary: null, error: 'stat-failed' };
25
+ }
26
+ const { mtimeMs } = statResult;
27
+ const cached = store.get(filePath);
28
+ if (cached !== undefined && cached.mtimeMs === mtimeMs) {
29
+ return { summary: cached.summary };
30
+ }
31
+ // Cache miss or stale — rebuild
32
+ let summary;
33
+ try {
34
+ summary = await build();
35
+ }
36
+ catch (err) {
37
+ const reason = err instanceof Error ? err.message : String(err);
38
+ // Do NOT cache failed builds — let the next call retry
39
+ return { summary: null, error: reason };
40
+ }
41
+ store.set(filePath, { mtimeMs, summary });
42
+ return { summary };
43
+ },
44
+ invalidate(filePath) {
45
+ store.delete(filePath);
46
+ },
47
+ size() {
48
+ return store.size;
49
+ },
50
+ };
51
+ }