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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -0
- package/dist/client/assets/index-BGW0xA7n.js +30 -0
- package/dist/client/assets/index-DlKrxvV-.css +2 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/index.js +2 -0
- package/dist/server/server/rollup.js +330 -0
- package/dist/server/server/routes/api.js +30 -9
- package/dist/server/server/routes/patterns.js +42 -0
- package/dist/server/server/summary-cache.js +51 -0
- package/dist/server/server/ws.js +16 -19
- package/dist/server/shared/providers/claude-code.js +268 -0
- package/dist/server/shared/providers/index.js +37 -0
- package/dist/server/shared/providers/provider.js +5 -0
- package/dist/server/shared/session-summary.js +123 -0
- package/dist/server/shared/session.js +6 -0
- package/package.json +7 -1
- package/dist/client/assets/index-D3XepZ5e.js +0 -30
- package/dist/client/assets/index-DwPuae45.css +0 -2
|
@@ -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}
|
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
client.
|
|
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
|
+
}
|