ctxloom-pro 1.0.21 → 1.0.23

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.
@@ -148,8 +148,8 @@ var init_embedder = __esm({
148
148
  // ../../packages/core/src/db/VectorStore.ts
149
149
  import lancedb from "@lancedb/lancedb";
150
150
  import { makeArrowTable } from "@lancedb/lancedb";
151
- import path14 from "path";
152
- import fs14 from "fs";
151
+ import path15 from "path";
152
+ import fs15 from "fs";
153
153
  var init_VectorStore = __esm({
154
154
  "../../packages/core/src/db/VectorStore.ts"() {
155
155
  "use strict";
@@ -160,12 +160,12 @@ var init_VectorStore = __esm({
160
160
  // server/index.ts
161
161
  import express from "express";
162
162
  import cors from "cors";
163
- import path37 from "path";
164
- import fs29 from "fs";
163
+ import path38 from "path";
164
+ import fs30 from "fs";
165
165
  import { fileURLToPath as fileURLToPath2 } from "url";
166
166
 
167
167
  // server/loader.ts
168
- import path31 from "path";
168
+ import path32 from "path";
169
169
 
170
170
  // ../../packages/core/src/graph/DependencyGraph.ts
171
171
  import fs7 from "fs";
@@ -3124,8 +3124,8 @@ var CoChangeIndex = class _CoChangeIndex {
3124
3124
  if (event.isBulk || event.isMerge) return;
3125
3125
  const paths = event.files.map((f) => f.path);
3126
3126
  if (paths.length === 0) return;
3127
- for (const path38 of paths) {
3128
- this.nodeCounts.set(path38, (this.nodeCounts.get(path38) ?? 0) + 1);
3127
+ for (const path39 of paths) {
3128
+ this.nodeCounts.set(path39, (this.nodeCounts.get(path39) ?? 0) + 1);
3129
3129
  }
3130
3130
  for (let i = 0; i < paths.length; i++) {
3131
3131
  for (let j = i + 1; j < paths.length; j++) {
@@ -3272,8 +3272,8 @@ var ChurnIndex = class _ChurnIndex {
3272
3272
  */
3273
3273
  snapshot() {
3274
3274
  const nodes = {};
3275
- for (const [path38, raw] of this.nodes) {
3276
- nodes[path38] = {
3275
+ for (const [path39, raw] of this.nodes) {
3276
+ nodes[path39] = {
3277
3277
  commits: raw.commits,
3278
3278
  churnLines: raw.churnLines,
3279
3279
  bugCommits: raw.bugCommits,
@@ -3288,8 +3288,8 @@ var ChurnIndex = class _ChurnIndex {
3288
3288
  */
3289
3289
  static load(s) {
3290
3290
  const idx = new _ChurnIndex();
3291
- for (const [path38, raw] of Object.entries(s.nodes)) {
3292
- idx.nodes.set(path38, {
3291
+ for (const [path39, raw] of Object.entries(s.nodes)) {
3292
+ idx.nodes.set(path39, {
3293
3293
  commits: raw.commits,
3294
3294
  churnLines: raw.churnLines,
3295
3295
  bugCommits: raw.bugCommits,
@@ -3302,8 +3302,8 @@ var ChurnIndex = class _ChurnIndex {
3302
3302
  // -------------------------------------------------------------------------
3303
3303
  // Private helpers
3304
3304
  // -------------------------------------------------------------------------
3305
- getOrCreate(path38) {
3306
- const existing = this.nodes.get(path38);
3305
+ getOrCreate(path39) {
3306
+ const existing = this.nodes.get(path39);
3307
3307
  if (existing !== void 0) return existing;
3308
3308
  const fresh = {
3309
3309
  commits: 0,
@@ -3312,7 +3312,7 @@ var ChurnIndex = class _ChurnIndex {
3312
3312
  authorCounts: {},
3313
3313
  lastTouch: 0
3314
3314
  };
3315
- this.nodes.set(path38, fresh);
3315
+ this.nodes.set(path39, fresh);
3316
3316
  return fresh;
3317
3317
  }
3318
3318
  };
@@ -3393,12 +3393,12 @@ var OwnershipIndex = class _OwnershipIndex {
3393
3393
  */
3394
3394
  snapshot() {
3395
3395
  const nodes = {};
3396
- for (const [path38, raw] of this.nodes) {
3396
+ for (const [path39, raw] of this.nodes) {
3397
3397
  const authorWeights = {};
3398
3398
  for (const [email, entry] of Object.entries(raw.authorWeights)) {
3399
3399
  authorWeights[email] = { ...entry };
3400
3400
  }
3401
- nodes[path38] = { authorWeights, lastTouch: raw.lastTouch };
3401
+ nodes[path39] = { authorWeights, lastTouch: raw.lastTouch };
3402
3402
  }
3403
3403
  return { version: 1, nodes };
3404
3404
  }
@@ -3407,23 +3407,23 @@ var OwnershipIndex = class _OwnershipIndex {
3407
3407
  */
3408
3408
  static load(s) {
3409
3409
  const idx = new _OwnershipIndex();
3410
- for (const [path38, raw] of Object.entries(s.nodes)) {
3410
+ for (const [path39, raw] of Object.entries(s.nodes)) {
3411
3411
  const authorWeights = {};
3412
3412
  for (const [email, entry] of Object.entries(raw.authorWeights)) {
3413
3413
  authorWeights[email] = { ...entry };
3414
3414
  }
3415
- idx.nodes.set(path38, { authorWeights, lastTouch: raw.lastTouch });
3415
+ idx.nodes.set(path39, { authorWeights, lastTouch: raw.lastTouch });
3416
3416
  }
3417
3417
  return idx;
3418
3418
  }
3419
3419
  // -------------------------------------------------------------------------
3420
3420
  // Private helpers
3421
3421
  // -------------------------------------------------------------------------
3422
- getOrCreate(path38) {
3423
- const existing = this.nodes.get(path38);
3422
+ getOrCreate(path39) {
3423
+ const existing = this.nodes.get(path39);
3424
3424
  if (existing !== void 0) return existing;
3425
3425
  const fresh = { authorWeights: {}, lastTouch: 0 };
3426
- this.nodes.set(path38, fresh);
3426
+ this.nodes.set(path39, fresh);
3427
3427
  return fresh;
3428
3428
  }
3429
3429
  };
@@ -3599,19 +3599,121 @@ function isEnoent(err) {
3599
3599
  init_logger();
3600
3600
  import path12 from "path";
3601
3601
  import fs11 from "fs/promises";
3602
+
3603
+ // ../../packages/core/src/risk/scoring.ts
3604
+ var RISK_WEIGHTS = { churn: 0.4, bug: 0.3, coupling: 0.3 };
3605
+ var BAND_PCT = { critical: 0.05, high: 0.15, medium: 0.35 };
3606
+ var SCORE_FLOOR = 0.05;
3607
+ var SILO_BUS_FACTOR = 1;
3608
+ function percentile(values, p) {
3609
+ if (values.length === 0) return 0;
3610
+ const sorted = [...values].sort((a, b) => a - b);
3611
+ const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * p));
3612
+ return sorted[idx];
3613
+ }
3614
+ function computeRiskCaps(samples, p = 0.9) {
3615
+ return {
3616
+ churn: percentile(samples.map((s) => s.churnLines), p),
3617
+ coupling: percentile(samples.map((s) => s.couplingFanOut), p)
3618
+ };
3619
+ }
3620
+ function computeRiskBreakdown(m, caps) {
3621
+ const churnDenom = Math.max(caps.churn, 1);
3622
+ const couplingDenom = Math.max(caps.coupling, 1);
3623
+ return {
3624
+ churn: Math.min(1, m.churnLines / churnDenom),
3625
+ bugDensity: Math.min(1, m.bugDensity * 2),
3626
+ coupling: Math.min(1, m.couplingFanOut / couplingDenom)
3627
+ };
3628
+ }
3629
+ function scoreFromBreakdown(b) {
3630
+ return b.churn * RISK_WEIGHTS.churn + b.bugDensity * RISK_WEIGHTS.bug + b.coupling * RISK_WEIGHTS.coupling;
3631
+ }
3632
+ function isSiloed(m) {
3633
+ return m.busFactor <= SILO_BUS_FACTOR;
3634
+ }
3635
+ function assignLabelsByPercentile(scores) {
3636
+ const n = scores.length;
3637
+ const bands = { criticalCount: 0, highCount: 0, mediumCount: 0, lowCount: 0, totalRanked: n };
3638
+ if (n === 0) return { labels: [], bands };
3639
+ const ranked = scores.map((s, i) => ({ s, i })).sort((a, b) => b.s - a.s);
3640
+ const criticalCutoff = Math.max(1, Math.ceil(n * BAND_PCT.critical));
3641
+ const highCutoff = Math.max(criticalCutoff + 1, Math.ceil(n * BAND_PCT.high));
3642
+ const mediumCutoff = Math.max(highCutoff + 1, Math.ceil(n * BAND_PCT.medium));
3643
+ const labels = new Array(n).fill("low");
3644
+ for (let rank = 0; rank < n; rank++) {
3645
+ const { s, i } = ranked[rank];
3646
+ if (s < SCORE_FLOOR) {
3647
+ labels[i] = "low";
3648
+ bands.lowCount++;
3649
+ continue;
3650
+ }
3651
+ if (rank < criticalCutoff) {
3652
+ labels[i] = "critical";
3653
+ bands.criticalCount++;
3654
+ } else if (rank < highCutoff) {
3655
+ labels[i] = "high";
3656
+ bands.highCount++;
3657
+ } else if (rank < mediumCutoff) {
3658
+ labels[i] = "medium";
3659
+ bands.mediumCount++;
3660
+ } else {
3661
+ labels[i] = "low";
3662
+ bands.lowCount++;
3663
+ }
3664
+ }
3665
+ return { labels, bands };
3666
+ }
3667
+ function scoreAll(samples) {
3668
+ const caps = computeRiskCaps(samples);
3669
+ const intermediate = samples.map((raw) => {
3670
+ const breakdown = computeRiskBreakdown(raw, caps);
3671
+ return { raw, breakdown, score: scoreFromBreakdown(breakdown) };
3672
+ });
3673
+ const { labels, bands } = assignLabelsByPercentile(intermediate.map((i) => i.score));
3674
+ const scored = intermediate.map((i, idx) => ({
3675
+ raw: i.raw,
3676
+ breakdown: i.breakdown,
3677
+ score: i.score,
3678
+ label: labels[idx],
3679
+ siloed: isSiloed(i.raw)
3680
+ }));
3681
+ return { scored, caps, bands };
3682
+ }
3683
+
3684
+ // ../../packages/core/src/trends/TrendsRecorder.ts
3602
3685
  var FILE_SUBPATH = path12.join(".ctxloom", "trends", "snapshots.jsonl");
3686
+ var FILE_RISKS_SUBPATH = path12.join(".ctxloom", "trends", "file-risks.jsonl");
3603
3687
  var ENTRY_PATTERN = /(^|\/)(index|main|server|app|cli)\.[^/]+$/;
3604
3688
  var SEVEN_DAYS_SECONDS = 7 * 24 * 3600;
3605
3689
  var COLLAPSE_WINDOW_SECONDS = 300;
3606
3690
  var PERCENT_THRESHOLD = 0.01;
3691
+ var FILE_SCORE_DELTA = 0.02;
3607
3692
  var INTEGER_FLOOR_FIELDS = [
3608
3693
  "totalFiles",
3609
3694
  "totalEdges",
3610
3695
  "deadFiles",
3611
3696
  "highRiskFiles"
3612
3697
  ];
3613
- function computeMetrics(opts, unixSeconds) {
3614
- const { graph, overlay, gitEnabled } = opts;
3698
+ function gatherPerFileMetrics(opts) {
3699
+ const { graph, overlay } = opts;
3700
+ return graph.allFiles().map((f) => {
3701
+ const churn = overlay.churn.statsFor(f);
3702
+ const ownership = overlay.ownership.statsFor(f);
3703
+ const coupled = overlay.coChange.topFor({ node: f, limit: 100, minConfidence: 0.1 });
3704
+ return {
3705
+ file: f,
3706
+ raw: {
3707
+ churnLines: churn?.churnLines ?? 0,
3708
+ bugDensity: churn?.bugDensity ?? 0,
3709
+ busFactor: ownership?.busFactor ?? 1,
3710
+ couplingFanOut: coupled.length
3711
+ }
3712
+ };
3713
+ });
3714
+ }
3715
+ function computeAggregates(opts, unixSeconds) {
3716
+ const { graph, gitEnabled } = opts;
3615
3717
  const files = graph.allFiles();
3616
3718
  let deadFiles = 0;
3617
3719
  for (const f of files) {
@@ -3620,51 +3722,62 @@ function computeMetrics(opts, unixSeconds) {
3620
3722
  }
3621
3723
  if (!gitEnabled) {
3622
3724
  return {
3623
- totalFiles: files.length,
3624
- totalEdges: graph.edgeCount(),
3625
- deadFiles,
3626
- avgBusFactor: null,
3627
- highRiskFiles: null,
3628
- churnLinesLast7d: null
3725
+ metrics: {
3726
+ totalFiles: files.length,
3727
+ totalEdges: graph.edgeCount(),
3728
+ deadFiles,
3729
+ avgBusFactor: null,
3730
+ highRiskFiles: null,
3731
+ churnLinesLast7d: null
3732
+ },
3733
+ perFileScores: []
3629
3734
  };
3630
3735
  }
3631
3736
  let busSum = 0;
3632
3737
  let busCount = 0;
3633
- for (const f of overlay.ownership.allNodes()) {
3634
- const stats = overlay.ownership.statsFor(f);
3738
+ for (const f of opts.overlay.ownership.allNodes()) {
3739
+ const stats = opts.overlay.ownership.statsFor(f);
3635
3740
  if (stats !== null) {
3636
3741
  busSum += stats.busFactor;
3637
3742
  busCount++;
3638
3743
  }
3639
3744
  }
3640
3745
  const avgBusFactor = busCount > 0 ? busSum / busCount : 0;
3641
- let highRiskFiles = 0;
3642
- let churnLinesLast7d = 0;
3643
3746
  const sevenDaysAgo = unixSeconds - SEVEN_DAYS_SECONDS;
3747
+ let churnLinesLast7d = 0;
3644
3748
  for (const f of files) {
3645
- const churn = overlay.churn.statsFor(f);
3646
- const ownership = overlay.ownership.statsFor(f);
3647
- const coupled = overlay.coChange.topFor({ node: f, limit: 100, minConfidence: 0.1 });
3648
- const churnLines = churn?.churnLines ?? 0;
3649
- const bugDensity = churn?.bugDensity ?? 0;
3650
- const busFactor = ownership?.busFactor ?? 1;
3651
- const churnPart = Math.min(1, churnLines / 1e3);
3652
- const bugPart = Math.min(1, bugDensity * 2);
3653
- const busPart = busFactor <= 1 ? 1 : busFactor <= 2 ? 0.5 : 0;
3654
- const couplingPart = Math.min(1, coupled.length / 10);
3655
- const score = churnPart * 0.3 + bugPart * 0.3 + busPart * 0.2 + couplingPart * 0.2;
3656
- if (score > 0.6) highRiskFiles++;
3749
+ const churn = opts.overlay.churn.statsFor(f);
3657
3750
  if (churn && churn.lastTouch >= sevenDaysAgo) {
3658
3751
  churnLinesLast7d += churn.churnLines;
3659
3752
  }
3660
3753
  }
3754
+ const perFile = gatherPerFileMetrics(opts);
3755
+ const { scored } = scoreAll(perFile.map((p) => p.raw));
3756
+ let highRiskFiles = 0;
3757
+ const perFileScores = [];
3758
+ for (let i = 0; i < scored.length; i++) {
3759
+ const s = scored[i];
3760
+ const file = perFile[i].file;
3761
+ if (s.label === "critical" || s.label === "high") highRiskFiles++;
3762
+ if (s.score >= SCORE_FLOOR) {
3763
+ perFileScores.push({
3764
+ unixSeconds,
3765
+ file,
3766
+ score: Math.round(s.score * 100) / 100,
3767
+ label: s.label
3768
+ });
3769
+ }
3770
+ }
3661
3771
  return {
3662
- totalFiles: files.length,
3663
- totalEdges: graph.edgeCount(),
3664
- deadFiles,
3665
- avgBusFactor: Math.round(avgBusFactor * 100) / 100,
3666
- highRiskFiles,
3667
- churnLinesLast7d
3772
+ metrics: {
3773
+ totalFiles: files.length,
3774
+ totalEdges: graph.edgeCount(),
3775
+ deadFiles,
3776
+ avgBusFactor: Math.round(avgBusFactor * 100) / 100,
3777
+ highRiskFiles,
3778
+ churnLinesLast7d
3779
+ },
3780
+ perFileScores
3668
3781
  };
3669
3782
  }
3670
3783
  async function ensureDir(dir) {
@@ -3735,14 +3848,46 @@ async function overwriteLastLine(filePath, replacement) {
3735
3848
  const head = idx >= 0 ? trimmed.slice(0, idx + 1) : "";
3736
3849
  await fs11.writeFile(filePath, head + replacement + "\n");
3737
3850
  }
3851
+ async function loadLastScoresPerFile(filePath) {
3852
+ const out = /* @__PURE__ */ new Map();
3853
+ try {
3854
+ const raw = await fs11.readFile(filePath, "utf-8");
3855
+ for (const line of raw.split("\n")) {
3856
+ if (!line.trim()) continue;
3857
+ try {
3858
+ const p = JSON.parse(line);
3859
+ if (typeof p?.file === "string" && typeof p.score === "number") {
3860
+ out.set(p.file, p);
3861
+ }
3862
+ } catch {
3863
+ }
3864
+ }
3865
+ } catch {
3866
+ }
3867
+ return out;
3868
+ }
3869
+ async function appendFilteredPerFileScores(filePath, next, prev) {
3870
+ const lines = [];
3871
+ for (const p of next) {
3872
+ const last = prev.get(p.file);
3873
+ const labelChanged = last?.label !== p.label;
3874
+ const scoreMoved = last === void 0 || Math.abs(p.score - last.score) >= FILE_SCORE_DELTA;
3875
+ if (labelChanged || scoreMoved) lines.push(JSON.stringify(p));
3876
+ }
3877
+ if (lines.length === 0) return 0;
3878
+ await ensureDir(path12.dirname(filePath));
3879
+ await fs11.appendFile(filePath, lines.join("\n") + "\n");
3880
+ return lines.length;
3881
+ }
3738
3882
  async function recordTrendSnapshot(opts) {
3739
3883
  const now = opts.now ?? Date.now;
3740
3884
  const ms = now();
3741
3885
  const unixSeconds = Math.floor(ms / 1e3);
3742
3886
  const timestamp2 = new Date(ms).toISOString();
3743
3887
  const filePath = path12.join(opts.rootDir, FILE_SUBPATH);
3888
+ const fileRisksPath = path12.join(opts.rootDir, FILE_RISKS_SUBPATH);
3744
3889
  try {
3745
- const metrics = computeMetrics(opts, unixSeconds);
3890
+ const { metrics, perFileScores } = computeAggregates(opts, unixSeconds);
3746
3891
  const snapshot = {
3747
3892
  timestamp: timestamp2,
3748
3893
  unixSeconds,
@@ -3757,6 +3902,10 @@ async function recordTrendSnapshot(opts) {
3757
3902
  } else {
3758
3903
  await fs11.appendFile(filePath, JSON.stringify(snapshot) + "\n");
3759
3904
  }
3905
+ if (perFileScores.length > 0) {
3906
+ const lastScores = await loadLastScoresPerFile(fileRisksPath);
3907
+ await appendFilteredPerFileScores(fileRisksPath, perFileScores, lastScores);
3908
+ }
3760
3909
  return snapshot;
3761
3910
  } catch (err) {
3762
3911
  logger.warn("Failed to record trend snapshot", {
@@ -3819,8 +3968,59 @@ async function loadTrendSeries(opts) {
3819
3968
  return { snapshots: tail, gitEnabled, totalCount };
3820
3969
  }
3821
3970
 
3971
+ // ../../packages/core/src/trends/FileRiskStore.ts
3972
+ init_logger();
3973
+ import path14 from "path";
3974
+ import fs13 from "fs/promises";
3975
+ var FILE_SUBPATH3 = path14.join(".ctxloom", "trends", "file-risks.jsonl");
3976
+ var DEFAULT_LIMIT3 = 200;
3977
+ var VALID_LABELS = /* @__PURE__ */ new Set(["low", "medium", "high", "critical"]);
3978
+ function isValidPoint(value) {
3979
+ if (typeof value !== "object" || value === null) return false;
3980
+ const v = value;
3981
+ return typeof v.unixSeconds === "number" && typeof v.file === "string" && typeof v.score === "number" && typeof v.label === "string" && VALID_LABELS.has(v.label);
3982
+ }
3983
+ async function loadFileRiskHistory(opts) {
3984
+ const filePath = path14.join(opts.rootDir, FILE_SUBPATH3);
3985
+ const limit = opts.limit ?? DEFAULT_LIMIT3;
3986
+ const sinceUnixSeconds = opts.sinceUnixSeconds ?? 0;
3987
+ let raw;
3988
+ try {
3989
+ raw = await fs13.readFile(filePath, "utf-8");
3990
+ } catch {
3991
+ return { file: opts.file, points: [], totalCount: 0 };
3992
+ }
3993
+ const allMatching = [];
3994
+ let totalCount = 0;
3995
+ let malformed = 0;
3996
+ for (const line of raw.split("\n")) {
3997
+ if (!line.trim()) continue;
3998
+ let value;
3999
+ try {
4000
+ value = JSON.parse(line);
4001
+ } catch {
4002
+ malformed++;
4003
+ continue;
4004
+ }
4005
+ if (!isValidPoint(value)) {
4006
+ malformed++;
4007
+ continue;
4008
+ }
4009
+ if (value.file !== opts.file) continue;
4010
+ totalCount++;
4011
+ if (value.unixSeconds < sinceUnixSeconds) continue;
4012
+ allMatching.push(value);
4013
+ }
4014
+ if (malformed > 0) {
4015
+ logger.warn("Skipped malformed file-risk rows", { file: filePath, count: malformed });
4016
+ }
4017
+ allMatching.sort((a, b) => a.unixSeconds - b.unixSeconds);
4018
+ const tail = allMatching.length > limit ? allMatching.slice(allMatching.length - limit) : allMatching;
4019
+ return { file: opts.file, points: tail, totalCount };
4020
+ }
4021
+
3822
4022
  // ../../packages/core/src/ast/Skeletonizer.ts
3823
- import fs13 from "fs";
4023
+ import fs14 from "fs";
3824
4024
  var Skeletonizer = class {
3825
4025
  parser;
3826
4026
  constructor() {
@@ -3862,7 +4062,7 @@ var Skeletonizer = class {
3862
4062
  * "deliberately skipped" from "skeletonized to empty".
3863
4063
  */
3864
4064
  async skeletonize(filePath) {
3865
- const fileSource = fs13.readFileSync(filePath, "utf-8");
4065
+ const fileSource = fs14.readFileSync(filePath, "utf-8");
3866
4066
  const fileLines = fileSource.split("\n");
3867
4067
  const MAX_INPUT_BYTES = 256 * 1024;
3868
4068
  if (fileSource.length > MAX_INPUT_BYTES) {
@@ -3926,7 +4126,7 @@ ${methodLines.join("\n")}
3926
4126
  */
3927
4127
  async skeletonizeXML(filePath) {
3928
4128
  const nodes = await this.parser.parse(filePath);
3929
- const fileLines = fs13.readFileSync(filePath, "utf-8").split("\n");
4129
+ const fileLines = fs14.readFileSync(filePath, "utf-8").split("\n");
3930
4130
  const parts = [`<skeleton file="${filePath}">`];
3931
4131
  for (const node of nodes) {
3932
4132
  switch (node.type) {
@@ -4461,8 +4661,8 @@ function getErrorMap() {
4461
4661
 
4462
4662
  // ../../node_modules/zod/v3/helpers/parseUtil.js
4463
4663
  var makeIssue = (params) => {
4464
- const { data, path: path38, errorMaps, issueData } = params;
4465
- const fullPath = [...path38, ...issueData.path || []];
4664
+ const { data, path: path39, errorMaps, issueData } = params;
4665
+ const fullPath = [...path39, ...issueData.path || []];
4466
4666
  const fullIssue = {
4467
4667
  ...issueData,
4468
4668
  path: fullPath
@@ -4578,11 +4778,11 @@ var errorUtil;
4578
4778
 
4579
4779
  // ../../node_modules/zod/v3/types.js
4580
4780
  var ParseInputLazyPath = class {
4581
- constructor(parent, value, path38, key) {
4781
+ constructor(parent, value, path39, key) {
4582
4782
  this._cachedPath = [];
4583
4783
  this.parent = parent;
4584
4784
  this.data = value;
4585
- this._path = path38;
4785
+ this._path = path39;
4586
4786
  this._key = key;
4587
4787
  }
4588
4788
  get path() {
@@ -8035,14 +8235,14 @@ var Schema = external_exports.object({
8035
8235
  var Schema2 = external_exports.object({ path: external_exports.string().describe("Relative path to the file") });
8036
8236
 
8037
8237
  // ../../packages/core/src/tools/context-packet.ts
8038
- import path15 from "path";
8238
+ import path16 from "path";
8039
8239
  var Schema3 = external_exports.object({
8040
8240
  target_file: external_exports.string().describe("Relative path to the primary file"),
8041
8241
  mode: external_exports.enum(["edit", "read"]).optional().default("edit").describe("Context mode")
8042
8242
  });
8043
8243
 
8044
8244
  // ../../packages/core/src/tools/findCallers.ts
8045
- import path16 from "path";
8245
+ import path17 from "path";
8046
8246
 
8047
8247
  // ../../packages/core/src/tools/call-graph.ts
8048
8248
  var Schema4 = external_exports.object({
@@ -8114,7 +8314,7 @@ var Schema11 = external_exports.object({
8114
8314
  });
8115
8315
 
8116
8316
  // ../../packages/core/src/tools/knowledge-gaps.ts
8117
- import path17 from "path";
8317
+ import path18 from "path";
8118
8318
  var Schema12 = external_exports.object({
8119
8319
  min_importers: external_exports.number().min(1).max(50).optional().default(3).describe(
8120
8320
  "Minimum importers to qualify as an untested hub (default: 3)"
@@ -8174,8 +8374,8 @@ var Schema16 = external_exports.object({
8174
8374
  });
8175
8375
 
8176
8376
  // ../../packages/core/src/tools/refactor-preview.ts
8177
- import fs15 from "fs";
8178
- import path18 from "path";
8377
+ import fs16 from "fs";
8378
+ import path19 from "path";
8179
8379
  var Schema17 = external_exports.object({
8180
8380
  symbol: external_exports.string().min(1).describe("Symbol name to rename (exact match, case-sensitive)"),
8181
8381
  new_name: external_exports.string().min(1).describe("New name for the symbol"),
@@ -8200,8 +8400,8 @@ var Schema18 = external_exports.object({
8200
8400
  init_embedder();
8201
8401
  init_VectorStore();
8202
8402
  init_logger();
8203
- import fs16 from "fs";
8204
- import path19 from "path";
8403
+ import fs17 from "fs";
8404
+ import path20 from "path";
8205
8405
  var Schema19 = external_exports.object({
8206
8406
  query: external_exports.string().min(1).describe("Search query \u2014 natural language or code fragment"),
8207
8407
  limit: external_exports.number().min(1).max(100).optional().default(10).describe(
@@ -8213,8 +8413,8 @@ var Schema19 = external_exports.object({
8213
8413
  });
8214
8414
 
8215
8415
  // ../../packages/core/src/tools/apply-refactor.ts
8216
- import fs17 from "fs";
8217
- import path20 from "path";
8416
+ import fs18 from "fs";
8417
+ import path21 from "path";
8218
8418
  var Schema20 = external_exports.object({
8219
8419
  symbol: external_exports.string().min(1).describe("Symbol name to rename (exact, case-sensitive)"),
8220
8420
  new_name: external_exports.string().min(1).describe("New name for the symbol"),
@@ -8241,8 +8441,8 @@ var Schema21 = external_exports.object({
8241
8441
  });
8242
8442
 
8243
8443
  // ../../packages/core/src/tools/full-text-search.ts
8244
- import fs18 from "fs";
8245
- import path21 from "path";
8444
+ import fs19 from "fs";
8445
+ import path22 from "path";
8246
8446
  var Schema22 = external_exports.object({
8247
8447
  query: external_exports.string().min(1).describe("Search term \u2014 literal or /regex/"),
8248
8448
  mode: external_exports.enum(["hybrid", "keyword", "semantic"]).optional().default("hybrid"),
@@ -8267,8 +8467,8 @@ var Schema24 = external_exports.object({
8267
8467
  });
8268
8468
 
8269
8469
  // ../../packages/core/src/tools/graph-snapshot.ts
8270
- import fs19 from "fs";
8271
- import path22 from "path";
8470
+ import fs20 from "fs";
8471
+ import path23 from "path";
8272
8472
  var schema = external_exports.object({
8273
8473
  name: external_exports.string().min(1).max(64).regex(/^[\w.-]+$/, "Name may only contain letters, digits, dots, underscores, hyphens").describe(
8274
8474
  'Snapshot name (e.g. "before-refactor", "v1.0"). Used as the filename.'
@@ -8279,8 +8479,8 @@ var schema = external_exports.object({
8279
8479
  });
8280
8480
 
8281
8481
  // ../../packages/core/src/tools/graph-diff.ts
8282
- import fs20 from "fs";
8283
- import path23 from "path";
8482
+ import fs21 from "fs";
8483
+ import path24 from "path";
8284
8484
  var schema2 = external_exports.object({
8285
8485
  baseline: external_exports.string().min(1).describe('Name of the baseline snapshot (the "before" state).'),
8286
8486
  current: external_exports.string().min(1).describe('Name of the current snapshot (the "after" state).')
@@ -8313,8 +8513,8 @@ var Schema26 = external_exports.object({
8313
8513
  });
8314
8514
 
8315
8515
  // ../../packages/core/src/rules/loadConfig.ts
8316
- import fs21 from "fs/promises";
8317
- import path24 from "path";
8516
+ import fs22 from "fs/promises";
8517
+ import path25 from "path";
8318
8518
 
8319
8519
  // ../../node_modules/js-yaml/dist/js-yaml.mjs
8320
8520
  function isNothing(subject) {
@@ -10939,24 +11139,24 @@ var Schema27 = external_exports.object({
10939
11139
  });
10940
11140
 
10941
11141
  // ../../packages/core/src/tools/ruleManager.ts
10942
- import fs22 from "fs";
10943
- import path25 from "path";
10944
-
10945
- // ../../packages/core/src/review/AuthorResolver.ts
10946
- import fs23 from "fs/promises";
11142
+ import fs23 from "fs";
10947
11143
  import path26 from "path";
10948
11144
 
10949
- // ../../packages/core/src/review/CodeownersWriter.ts
11145
+ // ../../packages/core/src/review/AuthorResolver.ts
10950
11146
  import fs24 from "fs/promises";
10951
11147
  import path27 from "path";
10952
11148
 
10953
- // ../../packages/core/src/review/loadConfig.ts
11149
+ // ../../packages/core/src/review/CodeownersWriter.ts
10954
11150
  import fs25 from "fs/promises";
10955
11151
  import path28 from "path";
10956
11152
 
10957
- // ../../packages/core/src/security/PathValidator.ts
11153
+ // ../../packages/core/src/review/loadConfig.ts
11154
+ import fs26 from "fs/promises";
10958
11155
  import path29 from "path";
10959
- import fs26 from "fs";
11156
+
11157
+ // ../../packages/core/src/security/PathValidator.ts
11158
+ import path30 from "path";
11159
+ import fs27 from "fs";
10960
11160
  var MAX_FILE_SIZE = 5 * 1024 * 1024;
10961
11161
 
10962
11162
  // ../../packages/core/src/index.ts
@@ -10967,7 +11167,7 @@ init_logger();
10967
11167
 
10968
11168
  // ../../packages/core/src/license/LicenseStore.ts
10969
11169
  import { readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync, existsSync } from "fs";
10970
- import path30 from "path";
11170
+ import path31 from "path";
10971
11171
 
10972
11172
  // ../../packages/core/src/license/types.ts
10973
11173
  var FINGERPRINT_RE = /^sha256:[0-9a-f]{64}$/;
@@ -11003,7 +11203,7 @@ var SENTRY_DSN = process.env["SENTRY_DSN"] ?? (typeof __TELEMETRY_SENTRY_DSN__ =
11003
11203
 
11004
11204
  // server/loader.ts
11005
11205
  async function loadContext(root) {
11006
- const absRoot = path31.resolve(root);
11206
+ const absRoot = path32.resolve(root);
11007
11207
  const overlay = new GitOverlayStore(absRoot);
11008
11208
  const gitEnabled = await overlay.loadSnapshot();
11009
11209
  const graph = new DependencyGraph();
@@ -11038,42 +11238,6 @@ async function switchContext(ctx, newRoot) {
11038
11238
 
11039
11239
  // server/routes/overview.ts
11040
11240
  import { Router } from "express";
11041
-
11042
- // server/lib/risk.ts
11043
- var RISK_WEIGHTS = { churn: 0.2, bug: 0.2, bus: 0.4, coupling: 0.2 };
11044
- function percentile(values, p) {
11045
- if (values.length === 0) return 0;
11046
- const sorted = [...values].sort((a, b) => a - b);
11047
- const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * p));
11048
- return sorted[idx];
11049
- }
11050
- function computeRiskCaps(samples, p = 0.9) {
11051
- return {
11052
- churn: percentile(samples.map((s) => s.churnLines), p),
11053
- coupling: percentile(samples.map((s) => s.couplingFanOut), p)
11054
- };
11055
- }
11056
- function computeRiskBreakdown(m, caps) {
11057
- const churnDenom = Math.max(caps.churn, 1);
11058
- const couplingDenom = Math.max(caps.coupling, 1);
11059
- return {
11060
- churn: Math.min(1, m.churnLines / churnDenom),
11061
- bugDensity: Math.min(1, m.bugDensity * 2),
11062
- busFactor: Math.min(1, 1 / Math.max(1, m.busFactor)),
11063
- coupling: Math.min(1, m.couplingFanOut / couplingDenom)
11064
- };
11065
- }
11066
- function scoreFromBreakdown(b) {
11067
- return b.churn * RISK_WEIGHTS.churn + b.bugDensity * RISK_WEIGHTS.bug + b.busFactor * RISK_WEIGHTS.bus + b.coupling * RISK_WEIGHTS.coupling;
11068
- }
11069
- function riskLabel(score) {
11070
- if (score > 0.8) return "critical";
11071
- if (score > 0.6) return "high";
11072
- if (score > 0.3) return "medium";
11073
- return "low";
11074
- }
11075
-
11076
- // server/routes/overview.ts
11077
11241
  function buildOverviewRouter(ctx) {
11078
11242
  const router = Router();
11079
11243
  router.get("/", (_req, res) => {
@@ -11101,10 +11265,9 @@ function buildOverviewRouter(ctx) {
11101
11265
  };
11102
11266
  });
11103
11267
  const caps = computeRiskCaps(raw);
11104
- for (const m of raw) {
11105
- const score = scoreFromBreakdown(computeRiskBreakdown(m, caps));
11106
- risk[riskLabel(score)]++;
11107
- }
11268
+ const scores = raw.map((m) => scoreFromBreakdown(computeRiskBreakdown(m, caps)));
11269
+ const { labels } = assignLabelsByPercentile(scores);
11270
+ for (const label of labels) risk[label]++;
11108
11271
  }
11109
11272
  const body = {
11110
11273
  totalFiles: files.length,
@@ -11170,7 +11333,12 @@ function buildRiskRouter(ctx) {
11170
11333
  router.get("/", (_req, res) => {
11171
11334
  const { graph, overlay, gitEnabled } = ctx;
11172
11335
  if (!gitEnabled) {
11173
- const body = { entries: [], overallRiskScore: 0, caps: { churn: 0, coupling: 0 } };
11336
+ const body = {
11337
+ entries: [],
11338
+ overallRiskScore: 0,
11339
+ caps: { churn: 0, coupling: 0 },
11340
+ bands: { criticalCount: 0, highCount: 0, mediumCount: 0, lowCount: 0, totalRanked: 0 }
11341
+ };
11174
11342
  return res.json(body);
11175
11343
  }
11176
11344
  const files = graph.allFiles();
@@ -11188,24 +11356,26 @@ function buildRiskRouter(ctx) {
11188
11356
  };
11189
11357
  });
11190
11358
  const caps = computeRiskCaps(raw);
11191
- const entries = raw.map((m) => {
11359
+ const scored = raw.map((m) => {
11192
11360
  const breakdown = computeRiskBreakdown(m, caps);
11193
- const score = scoreFromBreakdown(breakdown);
11194
- return {
11195
- file: m.file,
11196
- riskScore: Math.round(score * 100) / 100,
11197
- riskLabel: riskLabel(score),
11198
- churnLines: m.churnLines,
11199
- bugDensity: m.bugDensity,
11200
- busFactor: m.busFactor,
11201
- topOwner: m.topOwner,
11202
- couplingFanOut: m.couplingFanOut,
11203
- breakdown
11204
- };
11361
+ return { m, breakdown, score: scoreFromBreakdown(breakdown) };
11205
11362
  });
11363
+ const { labels, bands } = assignLabelsByPercentile(scored.map((s) => s.score));
11364
+ const entries = scored.map(({ m, breakdown, score }, idx) => ({
11365
+ file: m.file,
11366
+ riskScore: Math.round(score * 100) / 100,
11367
+ riskLabel: labels[idx],
11368
+ churnLines: m.churnLines,
11369
+ bugDensity: m.bugDensity,
11370
+ busFactor: m.busFactor,
11371
+ topOwner: m.topOwner,
11372
+ couplingFanOut: m.couplingFanOut,
11373
+ breakdown,
11374
+ siloed: isSiloed(m)
11375
+ }));
11206
11376
  entries.sort((a, b) => b.riskScore - a.riskScore);
11207
11377
  const overallRiskScore = entries.length > 0 ? Math.round(entries.reduce((s, e) => s + e.riskScore, 0) / entries.length * 100) / 100 : 0;
11208
- res.json({ entries, overallRiskScore, caps });
11378
+ res.json({ entries, overallRiskScore, caps, bands });
11209
11379
  });
11210
11380
  return router;
11211
11381
  }
@@ -11286,21 +11456,21 @@ function buildOwnershipRouter(ctx) {
11286
11456
 
11287
11457
  // server/routes/file.ts
11288
11458
  import { Router as Router7 } from "express";
11289
- import fs27 from "fs/promises";
11290
- import path32 from "path";
11459
+ import fs28 from "fs/promises";
11460
+ import path33 from "path";
11291
11461
  function buildFileRouter(ctx) {
11292
11462
  const router = Router7();
11293
11463
  router.get("/", async (req, res) => {
11294
11464
  const rel = req.query.path;
11295
11465
  if (!rel) return res.status(400).json({ error: "missing path" });
11296
- const abs = path32.resolve(ctx.root, rel);
11297
- const rootBoundary = ctx.root.endsWith(path32.sep) ? ctx.root : ctx.root + path32.sep;
11466
+ const abs = path33.resolve(ctx.root, rel);
11467
+ const rootBoundary = ctx.root.endsWith(path33.sep) ? ctx.root : ctx.root + path33.sep;
11298
11468
  if (abs !== ctx.root && !abs.startsWith(rootBoundary)) {
11299
11469
  return res.status(403).json({ error: "forbidden" });
11300
11470
  }
11301
11471
  try {
11302
- const content = await fs27.readFile(abs, "utf-8");
11303
- const ext = path32.extname(abs).slice(1);
11472
+ const content = await fs28.readFile(abs, "utf-8");
11473
+ const ext = path33.extname(abs).slice(1);
11304
11474
  res.json({ content, lines: content.split("\n").length, ext });
11305
11475
  } catch {
11306
11476
  res.status(404).json({ error: "not found" });
@@ -11312,7 +11482,7 @@ function buildFileRouter(ctx) {
11312
11482
  // server/routes/open.ts
11313
11483
  import { Router as Router8 } from "express";
11314
11484
  import { execFile as execFile2 } from "child_process";
11315
- import path33 from "path";
11485
+ import path34 from "path";
11316
11486
  function tryOpen(bin, abs) {
11317
11487
  return new Promise((resolve) => {
11318
11488
  execFile2(bin, [abs], { timeout: 5e3 }, (err) => resolve(!err));
@@ -11323,8 +11493,8 @@ function buildOpenRouter(ctx) {
11323
11493
  router.post("/", async (req, res) => {
11324
11494
  const rel = req.body?.path;
11325
11495
  if (!rel || typeof rel !== "string") return res.status(400).json({ error: "missing path" });
11326
- const abs = path33.resolve(ctx.root, rel);
11327
- const rootBoundary = ctx.root.endsWith(path33.sep) ? ctx.root : ctx.root + path33.sep;
11496
+ const abs = path34.resolve(ctx.root, rel);
11497
+ const rootBoundary = ctx.root.endsWith(path34.sep) ? ctx.root : ctx.root + path34.sep;
11328
11498
  if (abs !== ctx.root && !abs.startsWith(rootBoundary)) {
11329
11499
  return res.status(403).json({ error: "forbidden" });
11330
11500
  }
@@ -11336,8 +11506,8 @@ function buildOpenRouter(ctx) {
11336
11506
 
11337
11507
  // server/routes/tokens.ts
11338
11508
  import { Router as Router9 } from "express";
11339
- import path34 from "path";
11340
- import fs28 from "fs";
11509
+ import path35 from "path";
11510
+ import fs29 from "fs";
11341
11511
  var CHARS_PER_TOKEN = 4;
11342
11512
  var cache = null;
11343
11513
  function buildTokensRouter(ctx) {
@@ -11352,9 +11522,9 @@ function buildTokensRouter(ctx) {
11352
11522
  let fullChars = 0;
11353
11523
  let skeletonChars = 0;
11354
11524
  for (const file of files) {
11355
- const absPath = path34.join(ctx.root, file);
11525
+ const absPath = path35.join(ctx.root, file);
11356
11526
  try {
11357
- const content = fs28.readFileSync(absPath, "utf-8");
11527
+ const content = fs29.readFileSync(absPath, "utf-8");
11358
11528
  fullChars += content.length;
11359
11529
  const skeleton = await skeletonizer.skeletonize(absPath);
11360
11530
  skeletonChars += skeleton.length;
@@ -11406,19 +11576,66 @@ function buildTrendsRouter(ctx) {
11406
11576
  return router;
11407
11577
  }
11408
11578
 
11409
- // server/routes/projects.ts
11579
+ // server/routes/file-trends.ts
11410
11580
  import { Router as Router11 } from "express";
11411
- import path36 from "path";
11581
+ var RANGE_TO_SECONDS2 = {
11582
+ "7d": 7 * 24 * 3600,
11583
+ "30d": 30 * 24 * 3600,
11584
+ "90d": 90 * 24 * 3600
11585
+ };
11586
+ function parseRange2(raw) {
11587
+ if (raw === "7d" || raw === "30d" || raw === "90d" || raw === "all") return raw;
11588
+ return "90d";
11589
+ }
11590
+ function parseLimit2(raw) {
11591
+ const n = typeof raw === "string" ? Number.parseInt(raw, 10) : NaN;
11592
+ if (!Number.isFinite(n) || n < 1) return 200;
11593
+ return Math.min(n, 2e3);
11594
+ }
11595
+ function buildFileTrendsRouter(ctx) {
11596
+ const router = Router11();
11597
+ router.get("/file", async (req, res) => {
11598
+ const filePath = typeof req.query.path === "string" ? req.query.path : "";
11599
+ if (!filePath) {
11600
+ return res.status(400).json({ error: "missing required query param 'path'" });
11601
+ }
11602
+ const range = parseRange2(req.query.range);
11603
+ const limit = parseLimit2(req.query.limit);
11604
+ const sinceUnixSeconds = range === "all" ? 0 : Math.floor(Date.now() / 1e3) - RANGE_TO_SECONDS2[range];
11605
+ const history = await loadFileRiskHistory({
11606
+ rootDir: ctx.root,
11607
+ file: filePath,
11608
+ sinceUnixSeconds,
11609
+ limit
11610
+ });
11611
+ const body = {
11612
+ file: history.file,
11613
+ points: history.points.map((p) => ({
11614
+ unixSeconds: p.unixSeconds,
11615
+ score: p.score,
11616
+ label: p.label
11617
+ })),
11618
+ totalCount: history.totalCount,
11619
+ gitEnabled: ctx.gitEnabled
11620
+ };
11621
+ res.json(body);
11622
+ });
11623
+ return router;
11624
+ }
11625
+
11626
+ // server/routes/projects.ts
11627
+ import { Router as Router12 } from "express";
11628
+ import path37 from "path";
11412
11629
 
11413
11630
  // server/projects.ts
11414
11631
  import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
11415
11632
  import os4 from "os";
11416
- import path35 from "path";
11633
+ import path36 from "path";
11417
11634
  import crypto5 from "crypto";
11418
11635
  var HOME = os4.homedir();
11419
- var REGISTRY_PATH = path35.join(HOME, ".ctxloom", "repos.json");
11636
+ var REGISTRY_PATH = path36.join(HOME, ".ctxloom", "repos.json");
11420
11637
  function slugFor(root) {
11421
- const abs = path35.resolve(root);
11638
+ const abs = path36.resolve(root);
11422
11639
  return crypto5.createHash("sha1").update(abs).digest("hex").slice(0, 12);
11423
11640
  }
11424
11641
  function readRegistry() {
@@ -11433,27 +11650,27 @@ function readRegistry() {
11433
11650
  }
11434
11651
  }
11435
11652
  function listProjects(defaultRoot) {
11436
- const absDefault = path35.resolve(defaultRoot);
11653
+ const absDefault = path36.resolve(defaultRoot);
11437
11654
  const out = [
11438
11655
  {
11439
11656
  slug: slugFor(absDefault),
11440
- name: path35.basename(absDefault) || absDefault,
11657
+ name: path36.basename(absDefault) || absDefault,
11441
11658
  root: absDefault,
11442
11659
  isDefault: true,
11443
- hasSnapshot: existsSync2(path35.join(absDefault, ".ctxloom"))
11660
+ hasSnapshot: existsSync2(path36.join(absDefault, ".ctxloom"))
11444
11661
  }
11445
11662
  ];
11446
11663
  const seen = /* @__PURE__ */ new Set([absDefault]);
11447
11664
  for (const entry of readRegistry()) {
11448
- const abs = path35.resolve(entry.root);
11665
+ const abs = path36.resolve(entry.root);
11449
11666
  if (seen.has(abs)) continue;
11450
11667
  seen.add(abs);
11451
11668
  out.push({
11452
11669
  slug: slugFor(abs),
11453
- name: entry.name ?? (path35.basename(abs) || abs),
11670
+ name: entry.name ?? (path36.basename(abs) || abs),
11454
11671
  root: abs,
11455
11672
  isDefault: false,
11456
- hasSnapshot: existsSync2(path35.join(abs, ".ctxloom"))
11673
+ hasSnapshot: existsSync2(path36.join(abs, ".ctxloom"))
11457
11674
  });
11458
11675
  }
11459
11676
  return out;
@@ -11462,7 +11679,7 @@ function listProjects(defaultRoot) {
11462
11679
  // server/routes/projects.ts
11463
11680
  function buildProjectsRouter(deps) {
11464
11681
  const { ctx, defaultRoot, onActiveChanged } = deps;
11465
- const router = Router11();
11682
+ const router = Router12();
11466
11683
  function annotated() {
11467
11684
  const activeSlug = slugFor(ctx.root);
11468
11685
  return listProjects(defaultRoot).map((p) => ({
@@ -11504,7 +11721,7 @@ function buildProjectsRouter(deps) {
11504
11721
  } catch (err) {
11505
11722
  const detail = err instanceof Error ? err.message : String(err);
11506
11723
  res.status(500).json({
11507
- error: `failed to switch to ${path36.basename(target.root)}: ${detail}`
11724
+ error: `failed to switch to ${path37.basename(target.root)}: ${detail}`
11508
11725
  });
11509
11726
  }
11510
11727
  });
@@ -11512,7 +11729,7 @@ function buildProjectsRouter(deps) {
11512
11729
  }
11513
11730
 
11514
11731
  // server/index.ts
11515
- var __dirname2 = path37.dirname(fileURLToPath2(import.meta.url));
11732
+ var __dirname2 = path38.dirname(fileURLToPath2(import.meta.url));
11516
11733
  async function startDashboard(options) {
11517
11734
  const { root, port, open } = options;
11518
11735
  console.log(`ctxloom dashboard \u2014 loading context from ${root}...`);
@@ -11541,6 +11758,7 @@ async function startDashboard(options) {
11541
11758
  app.use("/api/open", buildOpenRouter(ctx));
11542
11759
  app.use("/api/tokens", buildTokensRouter(ctx));
11543
11760
  app.use("/api/trends", buildTrendsRouter(ctx));
11761
+ app.use("/api/trends", buildFileTrendsRouter(ctx));
11544
11762
  app.get("/api/health", (_req, res) => res.json({ ok: true, gitEnabled: ctx.gitEnabled }));
11545
11763
  app.get("/api/status", (_req, res) => res.json({
11546
11764
  lastIndexed: ctx.lastIndexed.toISOString(),
@@ -11570,9 +11788,9 @@ async function startDashboard(options) {
11570
11788
  }
11571
11789
  activeWatcher = null;
11572
11790
  }
11573
- const snapshotDir = path37.join(targetRoot, ".ctxloom");
11791
+ const snapshotDir = path38.join(targetRoot, ".ctxloom");
11574
11792
  try {
11575
- activeWatcher = fs29.watch(snapshotDir, (_event, filename) => {
11793
+ activeWatcher = fs30.watch(snapshotDir, (_event, filename) => {
11576
11794
  if (!filename || !filename.includes("snapshot")) return;
11577
11795
  if (debounce) clearTimeout(debounce);
11578
11796
  debounce = setTimeout(async () => {
@@ -11600,11 +11818,20 @@ async function startDashboard(options) {
11600
11818
  attachSnapshotWatcher(newRoot);
11601
11819
  }
11602
11820
  }));
11603
- const clientDist = path37.join(__dirname2, "../dashboard/client");
11604
- app.use(express.static(clientDist, { dotfiles: "allow" }));
11605
- app.get(/.*/, (_req, res) => {
11606
- res.sendFile(path37.join(clientDist, "index.html"), { dotfiles: "allow" });
11607
- });
11821
+ const clientDist = path38.join(__dirname2, "../dashboard/client");
11822
+ const clientDistExists = fs30.existsSync(path38.join(clientDist, "index.html"));
11823
+ if (clientDistExists) {
11824
+ app.use(express.static(clientDist, { dotfiles: "allow" }));
11825
+ app.get(/.*/, (_req, res) => {
11826
+ res.sendFile(path38.join(clientDist, "index.html"), { dotfiles: "allow" });
11827
+ });
11828
+ } else {
11829
+ app.get(/^\/(?!api\/).*/, (_req, res) => {
11830
+ res.status(404).type("text/plain").send(
11831
+ "Dashboard client bundle not found. In dev: open http://localhost:5173. For a production preview from this port, run `npm run build:client -w @ctxloom/dashboard`."
11832
+ );
11833
+ });
11834
+ }
11608
11835
  app.listen(port, () => {
11609
11836
  const url = `http://localhost:${port}`;
11610
11837
  console.log(`