noctrace 1.0.0 → 1.2.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.
@@ -1,2 +1,2 @@
1
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}.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 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}
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}.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-C0dmCjnv.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-DYum71Dc.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
@@ -5,6 +5,8 @@
5
5
  * watcher injection, and cleanup. All Docker commands go through the
6
6
  * DockerRunner interface so callers (and tests) can swap in a stub.
7
7
  */
8
+ import path from 'node:path';
9
+ import os from 'node:os';
8
10
  // ---------------------------------------------------------------------------
9
11
  // Default runner (real child_process)
10
12
  // ---------------------------------------------------------------------------
@@ -150,3 +152,66 @@ export function cleanupWatcher(containerArg, runner) {
150
152
  }
151
153
  catch { /* container may be gone */ }
152
154
  }
155
+ // ---------------------------------------------------------------------------
156
+ // Devcontainer support
157
+ // ---------------------------------------------------------------------------
158
+ /**
159
+ * Look up a running container by an exact Docker label match.
160
+ * Returns the container ID (short form), or null when nothing matches.
161
+ *
162
+ * Uses `docker ps --filter "label=<label>=<value>" --format "{{.ID}}"`.
163
+ * The label and value are passed as a single `label=key=value` filter argument
164
+ * so no shell interpolation occurs.
165
+ */
166
+ export function findContainerByLabel(label, value, runner) {
167
+ let output;
168
+ try {
169
+ output = runner.execSync('docker', ['ps', '--filter', `label=${label}=${value}`, '--format', '{{.ID}}'], { stdio: 'pipe' });
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ const id = output.trim().split('\n')[0]?.trim() ?? '';
175
+ return id.length > 0 ? id : null;
176
+ }
177
+ /**
178
+ * Resolve a devcontainer argument to a concrete container ID.
179
+ *
180
+ * If `input` looks like a path (starts with `/`, `.`, `./`, or `~/`) it is
181
+ * resolved to an absolute path and looked up via the canonical
182
+ * `devcontainer.local_folder` label, falling back to the older
183
+ * `vsch.local.folder` label. When neither label matches an error is thrown
184
+ * with a clear hint pointing the user at `docker ps --filter "label=devcontainer.*"`.
185
+ *
186
+ * If `input` is not a path it is treated as a container name/ID.
187
+ * `isValidContainerName` is checked and the value is returned directly.
188
+ *
189
+ * @param cwd - Working directory used to resolve relative paths. Defaults to `process.cwd()`.
190
+ */
191
+ export function resolveDevcontainerContainer(input, runner, cwd) {
192
+ const isPath = input.startsWith('/') || input.startsWith('.') || input.startsWith('~/');
193
+ if (!isPath) {
194
+ if (!isValidContainerName(input)) {
195
+ throw new Error(`Invalid container name: "${input}"`);
196
+ }
197
+ return input;
198
+ }
199
+ // Resolve to an absolute path — devcontainer labels always store absolute paths.
200
+ // path.resolve does not expand ~ so handle that explicitly.
201
+ let absPath;
202
+ if (input.startsWith('~/')) {
203
+ absPath = path.join(os.homedir(), input.slice(2));
204
+ }
205
+ else {
206
+ absPath = path.resolve(cwd ?? process.cwd(), input);
207
+ }
208
+ // Try canonical label first, then the older VS Code label.
209
+ const id = findContainerByLabel('devcontainer.local_folder', absPath, runner) ??
210
+ findContainerByLabel('vsch.local.folder', absPath, runner);
211
+ if (id === null) {
212
+ throw new Error(`No devcontainer found for path: ${absPath}\n` +
213
+ `Hint: make sure the devcontainer is running, then check:\n` +
214
+ ` docker ps --filter "label=devcontainer.local_folder"`);
215
+ }
216
+ return id;
217
+ }
@@ -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,336 @@
1
+ /**
2
+ * Cross-session Patterns rollup orchestrator.
3
+ * Lists session JSONL files, applies mtime pre-filter, parses + caches each,
4
+ * and folds results into a PatternsResponse.
5
+ */
6
+ import fs from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import { parseJsonlContent } from '../shared/parser.js';
10
+ import { buildSessionSummaryFromContent, } from '../shared/session-summary.js';
11
+ // ---------------------------------------------------------------------------
12
+ // Window math
13
+ // ---------------------------------------------------------------------------
14
+ /** Start of the local calendar day containing `nowMs`, in local time. */
15
+ function startOfDay(nowMs) {
16
+ const d = new Date(nowMs);
17
+ d.setHours(0, 0, 0, 0);
18
+ return d.getTime();
19
+ }
20
+ /** Compute window bounds using calendar semantics in server-local time. */
21
+ function computeWindow(kind, now) {
22
+ const todayStart = startOfDay(now);
23
+ const MS_PER_DAY = 86_400_000;
24
+ let startMs;
25
+ let spanDays;
26
+ if (kind === 'today') {
27
+ startMs = todayStart;
28
+ spanDays = 1;
29
+ }
30
+ else if (kind === '7d') {
31
+ startMs = todayStart - 6 * MS_PER_DAY;
32
+ spanDays = 7;
33
+ }
34
+ else {
35
+ startMs = todayStart - 29 * MS_PER_DAY;
36
+ spanDays = 30;
37
+ }
38
+ const endMs = now;
39
+ const prevEndMs = startMs;
40
+ const prevStartMs = startMs - spanDays * MS_PER_DAY;
41
+ const label = formatDateRange(startMs, endMs);
42
+ return { startMs, endMs, prevStartMs, prevEndMs, label };
43
+ }
44
+ /** Format a date range as "Apr 7 – Apr 14, 2026". */
45
+ function formatDateRange(startMs, endMs) {
46
+ const fmt = (ms, withYear) => {
47
+ const d = new Date(ms);
48
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
49
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
50
+ const base = `${months[d.getMonth()]} ${d.getDate()}`;
51
+ return withYear ? `${base}, ${d.getFullYear()}` : base;
52
+ };
53
+ const start = new Date(startMs);
54
+ const end = new Date(endMs);
55
+ const sameYear = start.getFullYear() === end.getFullYear();
56
+ return `${fmt(startMs, false)} \u2013 ${fmt(endMs, sameYear)}`;
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Percentile helper
60
+ // ---------------------------------------------------------------------------
61
+ /** Compute the p-th percentile (0..100) of a numeric array via linear interpolation. */
62
+ function percentile(arr, p) {
63
+ if (arr.length === 0)
64
+ return 0;
65
+ const sorted = [...arr].sort((a, b) => a - b);
66
+ if (p <= 0)
67
+ return sorted[0];
68
+ if (p >= 100)
69
+ return sorted[sorted.length - 1];
70
+ const idx = (p / 100) * (sorted.length - 1);
71
+ const lo = Math.floor(idx);
72
+ const hi = Math.ceil(idx);
73
+ if (lo === hi)
74
+ return sorted[lo];
75
+ return sorted[lo] + (idx - lo) * (sorted[hi] - sorted[lo]);
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // Bounded-concurrency parallel execution
79
+ // ---------------------------------------------------------------------------
80
+ /** Run `tasks` in parallel chunks of `concurrency`. */
81
+ async function runChunked(tasks, concurrency) {
82
+ const results = [];
83
+ for (let i = 0; i < tasks.length; i += concurrency) {
84
+ const chunk = tasks.slice(i, i + concurrency).map((t) => t());
85
+ const batch = await Promise.all(chunk);
86
+ results.push(...batch);
87
+ }
88
+ return results;
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // Project de-slugification
92
+ // ---------------------------------------------------------------------------
93
+ /**
94
+ * Convert a Claude project slug to a human-readable path.
95
+ * Algorithm: split on `-`, drop empty leading segment.
96
+ * If first segment is `Users` and second matches the current user's username,
97
+ * replace with `~`; otherwise join with `/`.
98
+ */
99
+ function deslugify(slug, username) {
100
+ // Slugs use `-` as separator but project paths can contain hyphens too.
101
+ // The slug is produced by replacing `/` with `-` in the absolute path,
102
+ // e.g. /Users/lam/dev/noctrace → -Users-lam-dev-noctrace
103
+ const parts = slug.split('-');
104
+ // Drop the leading empty string before the first `-`
105
+ if (parts[0] === '')
106
+ parts.shift();
107
+ if (parts.length === 0)
108
+ return slug;
109
+ if (parts[0] === 'Users' && parts[1] === username) {
110
+ // Replace /Users/<user> with ~
111
+ return '~/' + parts.slice(2).join('/');
112
+ }
113
+ return '/' + parts.join('/');
114
+ }
115
+ /** List all *.jsonl session files under projectsDir with their mtime. */
116
+ async function listSessionFiles(projectsDir) {
117
+ const result = [];
118
+ let slugs;
119
+ try {
120
+ slugs = await fs.readdir(projectsDir);
121
+ }
122
+ catch {
123
+ // Directory doesn't exist — graceful degradation
124
+ return result;
125
+ }
126
+ for (const slug of slugs) {
127
+ const slugPath = path.join(projectsDir, slug);
128
+ let slugStat;
129
+ try {
130
+ slugStat = await fs.stat(slugPath);
131
+ }
132
+ catch {
133
+ continue;
134
+ }
135
+ if (!slugStat.isDirectory())
136
+ continue;
137
+ let files;
138
+ try {
139
+ files = await fs.readdir(slugPath);
140
+ }
141
+ catch {
142
+ continue;
143
+ }
144
+ for (const file of files) {
145
+ if (!file.endsWith('.jsonl'))
146
+ continue;
147
+ const filePath = path.join(slugPath, file);
148
+ let fstat;
149
+ try {
150
+ fstat = await fs.stat(filePath);
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ result.push({
156
+ filePath,
157
+ slug,
158
+ sessionId: file.replace(/\.jsonl$/, ''),
159
+ mtimeMs: fstat.mtimeMs,
160
+ });
161
+ }
162
+ }
163
+ return result;
164
+ }
165
+ // ---------------------------------------------------------------------------
166
+ // Main export
167
+ // ---------------------------------------------------------------------------
168
+ /**
169
+ * Compute the cross-session Patterns rollup for the given time window.
170
+ *
171
+ * @param window - 'today' | '7d' | '30d'
172
+ * @param cache - mtime-invalidated summary cache
173
+ * @param claudeHome - defaults to CLAUDE_HOME env or ~/.claude
174
+ * @param now - injectable for tests; defaults to Date.now()
175
+ */
176
+ export async function computeRollup(window, cache, claudeHome, now) {
177
+ const nowMs = now ?? Date.now();
178
+ const home = claudeHome ?? (process.env['CLAUDE_HOME'] ?? path.join(os.homedir(), '.claude'));
179
+ const projectsDir = path.join(home, 'projects');
180
+ const username = os.userInfo().username;
181
+ const bounds = computeWindow(window, nowMs);
182
+ const { startMs, endMs, prevStartMs, prevEndMs } = bounds;
183
+ const MS_PER_DAY = 86_400_000;
184
+ // List all session files
185
+ const allFiles = await listSessionFiles(projectsDir);
186
+ // Pre-filter by mtime: include files whose mtime is within window + 1-day slack
187
+ // This is a fast pre-filter; we still check session startMs after parsing.
188
+ const preFilterStart = prevStartMs - MS_PER_DAY;
189
+ const currentCandidates = allFiles.filter((f) => f.mtimeMs >= preFilterStart && f.mtimeMs <= endMs + MS_PER_DAY);
190
+ // Parse + cache each file
191
+ const errors = [];
192
+ const parsed = await runChunked(currentCandidates.map((sf) => async () => {
193
+ const { summary, error } = await cache.getOrBuild(sf.filePath, async () => {
194
+ let content;
195
+ try {
196
+ content = await fs.readFile(sf.filePath, 'utf8');
197
+ }
198
+ catch (err) {
199
+ throw new Error(err instanceof Error ? err.message : String(err));
200
+ }
201
+ const rows = parseJsonlContent(content);
202
+ return buildSessionSummaryFromContent(rows, sf.sessionId, sf.slug, content);
203
+ });
204
+ return { sf, summary, error };
205
+ }), 20);
206
+ // Collect errors and filter out failed parses
207
+ const summaries = [];
208
+ for (const item of parsed) {
209
+ if (item.error && item.summary === null) {
210
+ errors.push({ path: item.sf.filePath, reason: item.error });
211
+ }
212
+ else if (item.summary !== null) {
213
+ summaries.push({ sf: item.sf, summary: item.summary });
214
+ }
215
+ }
216
+ // Separate current vs previous window by session start time
217
+ const currentSummaries = summaries.filter(({ summary }) => summary.startMs >= startMs && summary.startMs < endMs);
218
+ const prevSummaries = summaries.filter(({ summary }) => summary.startMs >= prevStartMs && summary.startMs < prevEndMs);
219
+ // --- Health distribution ---
220
+ const emptyDist = () => ({ A: 0, B: 0, C: 0, D: 0, F: 0 });
221
+ const currentDist = emptyDist();
222
+ for (const { summary } of currentSummaries) {
223
+ if (summary.healthGrade)
224
+ currentDist[summary.healthGrade]++;
225
+ }
226
+ const prevDist = emptyDist();
227
+ for (const { summary } of prevSummaries) {
228
+ if (summary.healthGrade)
229
+ prevDist[summary.healthGrade]++;
230
+ }
231
+ const projectBuckets = new Map();
232
+ for (const { summary } of currentSummaries) {
233
+ const bucket = projectBuckets.get(summary.projectSlug);
234
+ if (bucket) {
235
+ bucket.sessions.push(summary);
236
+ }
237
+ else {
238
+ projectBuckets.set(summary.projectSlug, { sessions: [summary] });
239
+ }
240
+ }
241
+ const BAD_GRADES = new Set(['D', 'F']);
242
+ const rotLeaderboard = [];
243
+ for (const [slug, bucket] of projectBuckets) {
244
+ const sessions = bucket.sessions;
245
+ const badSessions = sessions.filter((s) => s.healthGrade && BAD_GRADES.has(s.healthGrade));
246
+ const bad = badSessions.length;
247
+ const badPct = sessions.length > 0 ? bad / sessions.length : 0;
248
+ const totalCompactions = sessions.reduce((sum, s) => sum + s.compactionCount, 0);
249
+ const avgCompactions = sessions.length > 0 ? totalCompactions / sessions.length : 0;
250
+ // Worst session = lowest healthScore among sessions with a score
251
+ let worstSessionId = null;
252
+ let lowestScore = Infinity;
253
+ for (const s of sessions) {
254
+ if (s.healthScore !== null && s.healthScore < lowestScore) {
255
+ lowestScore = s.healthScore;
256
+ worstSessionId = s.sessionId;
257
+ }
258
+ }
259
+ rotLeaderboard.push({
260
+ project: deslugify(slug, username),
261
+ rawSlug: slug,
262
+ sessions: sessions.length,
263
+ bad,
264
+ badPct,
265
+ avgCompactions,
266
+ worstSessionId,
267
+ });
268
+ }
269
+ // Sort by badPct descending, then calls descending as tiebreaker
270
+ rotLeaderboard.sort((a, b) => {
271
+ if (b.badPct !== a.badPct)
272
+ return b.badPct - a.badPct;
273
+ return b.sessions - a.sessions;
274
+ });
275
+ // --- Tool health ---
276
+ // Aggregate tool stats from current window
277
+ const toolAgg = new Map();
278
+ for (const { summary } of currentSummaries) {
279
+ for (const [tool, count] of Object.entries(summary.toolCounts)) {
280
+ const agg = toolAgg.get(tool) ?? { calls: 0, failures: 0, latencies: [] };
281
+ agg.calls += count;
282
+ agg.failures += summary.toolFailures[tool] ?? 0;
283
+ agg.latencies.push(...(summary.toolLatencies[tool] ?? []));
284
+ toolAgg.set(tool, agg);
285
+ }
286
+ }
287
+ // Aggregate call counts from previous window for delta
288
+ const prevToolCalls = new Map();
289
+ for (const { summary } of prevSummaries) {
290
+ for (const [tool, count] of Object.entries(summary.toolCounts)) {
291
+ prevToolCalls.set(tool, (prevToolCalls.get(tool) ?? 0) + count);
292
+ }
293
+ }
294
+ // Only include tools with >= 10 calls in current window
295
+ const toolHealth = [];
296
+ for (const [tool, agg] of toolAgg) {
297
+ if (agg.calls < 10)
298
+ continue;
299
+ toolHealth.push({
300
+ tool,
301
+ calls: agg.calls,
302
+ failures: agg.failures,
303
+ failPct: agg.calls > 0 ? agg.failures / agg.calls : 0,
304
+ p50ms: Math.round(percentile(agg.latencies, 50)),
305
+ p95ms: Math.round(percentile(agg.latencies, 95)),
306
+ callsPrev: prevToolCalls.get(tool) ?? 0,
307
+ });
308
+ }
309
+ // Sort by failPct descending, then calls descending
310
+ toolHealth.sort((a, b) => {
311
+ if (b.failPct !== a.failPct)
312
+ return b.failPct - a.failPct;
313
+ return b.calls - a.calls;
314
+ });
315
+ return {
316
+ window: {
317
+ kind: window,
318
+ startMs,
319
+ endMs,
320
+ prevStartMs,
321
+ prevEndMs,
322
+ label: bounds.label,
323
+ },
324
+ sessionCounts: {
325
+ current: currentSummaries.length,
326
+ previous: prevSummaries.length,
327
+ },
328
+ healthDist: {
329
+ current: currentDist,
330
+ previous: prevDist,
331
+ },
332
+ rotLeaderboard,
333
+ toolHealth,
334
+ errors,
335
+ };
336
+ }
@@ -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
+ }