@kopikocappu/mycelium 0.2.0 → 0.2.2

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.
Files changed (2) hide show
  1. package/dist/cli.js +1347 -25
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -7157,9 +7157,9 @@ var require_nodefs_handler = __commonJS({
7157
7157
  if (this.fsw.closed) {
7158
7158
  return;
7159
7159
  }
7160
- const dirname2 = sysPath.dirname(file);
7160
+ const dirname3 = sysPath.dirname(file);
7161
7161
  const basename2 = sysPath.basename(file);
7162
- const parent = this.fsw._getWatchedDir(dirname2);
7162
+ const parent = this.fsw._getWatchedDir(dirname3);
7163
7163
  let prevStats = stats;
7164
7164
  if (parent.has(basename2)) return;
7165
7165
  const listener = async (path9, newStats) => {
@@ -7181,7 +7181,7 @@ var require_nodefs_handler = __commonJS({
7181
7181
  prevStats = newStats2;
7182
7182
  }
7183
7183
  } catch (error) {
7184
- this.fsw._remove(dirname2, basename2);
7184
+ this.fsw._remove(dirname3, basename2);
7185
7185
  }
7186
7186
  } else if (parent.has(basename2)) {
7187
7187
  const at = newStats.atimeMs;
@@ -15727,9 +15727,10 @@ var CbmAdapter = class {
15727
15727
  }
15728
15728
  /** Run cbm index on a project */
15729
15729
  async index(repoPath) {
15730
+ const fwdPath = import_path2.default.resolve(repoPath).replace(/\\/g, "/");
15730
15731
  const result = (0, import_child_process.spawnSync)(
15731
15732
  this.cbmBin,
15732
- ["cli", "index_repository", JSON.stringify({ repo_path: import_path2.default.resolve(repoPath) })],
15733
+ ["cli", "index_repository", JSON.stringify({ repo_path: fwdPath })],
15733
15734
  { timeout: 3e5, stdio: "pipe", encoding: "utf-8" }
15734
15735
  );
15735
15736
  if (result.status !== 0) {
@@ -15739,18 +15740,19 @@ var CbmAdapter = class {
15739
15740
  /** Pull the full graph from cbm and convert to Mycelium format */
15740
15741
  async getGraph(repoPath) {
15741
15742
  const absPath = import_path2.default.resolve(repoPath);
15743
+ const slug = this.slugifyPath(absPath);
15742
15744
  const fnResult = this.runCbmCli("search_graph", {
15743
15745
  label: "Function",
15744
- project: absPath,
15746
+ project: slug,
15745
15747
  limit: 1e4
15746
15748
  });
15747
15749
  const fileResult = this.runCbmCli("search_graph", {
15748
15750
  label: "File",
15749
- project: absPath,
15751
+ project: slug,
15750
15752
  limit: 1e4
15751
15753
  });
15752
15754
  const archResult = this.runCbmCli("get_architecture", {
15753
- repo_path: absPath
15755
+ repo_path: absPath.replace(/\\/g, "/")
15754
15756
  });
15755
15757
  const fnNodes = fnResult?.results ?? [];
15756
15758
  const fileNodes = fileResult?.results ?? [];
@@ -15815,15 +15817,29 @@ var CbmAdapter = class {
15815
15817
  }));
15816
15818
  }
15817
15819
  // ─── Private helpers ────────────────────────────────────────────────────────
15820
+ /**
15821
+ * cbm stores projects as slugified absolute paths.
15822
+ * e.g. C:\Users\Minh Tran\Desktop\pakky → C-Users-Minh-Tran-Desktop-pakky
15823
+ * This is what the 'project' field expects in search_graph, trace_path, etc.
15824
+ */
15825
+ slugifyPath(absPath) {
15826
+ return absPath.replace(/\\/g, "-").replace(/\//g, "-").replace(/:/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
15827
+ }
15818
15828
  runCbmCli(tool, args) {
15819
15829
  try {
15830
+ const normalizedArgs = {};
15831
+ for (const [k, v] of Object.entries(args)) {
15832
+ normalizedArgs[k] = typeof v === "string" ? v.replace(/\\/g, "/") : v;
15833
+ }
15820
15834
  const result = (0, import_child_process.spawnSync)(
15821
15835
  this.cbmBin,
15822
- ["cli", tool, JSON.stringify(args)],
15836
+ ["cli", tool, JSON.stringify(normalizedArgs)],
15823
15837
  { timeout: 3e4, stdio: "pipe", encoding: "utf-8" }
15824
15838
  );
15825
- if (result.status !== 0 || !result.stdout) return null;
15826
- return JSON.parse(result.stdout.trim());
15839
+ if (!result.stdout?.trim()) return null;
15840
+ const parsed = JSON.parse(result.stdout.trim());
15841
+ if (parsed?.error) return null;
15842
+ return parsed;
15827
15843
  } catch {
15828
15844
  return null;
15829
15845
  }
@@ -15869,7 +15885,7 @@ var CbmAdapter = class {
15869
15885
  for (const fn of sample) {
15870
15886
  const result = this.runCbmCli("trace_path", {
15871
15887
  function_name: fn.qualified_name ?? fn.name,
15872
- project: import_path2.default.resolve(repoPath),
15888
+ project: this.slugifyPath(import_path2.default.resolve(repoPath)),
15873
15889
  direction: "outbound",
15874
15890
  depth: 1
15875
15891
  });
@@ -15896,6 +15912,1295 @@ var CbmAdapter = class {
15896
15912
  var cbmAdapter = new CbmAdapter();
15897
15913
 
15898
15914
  // src/mcp/server.ts
15915
+ var EMBEDDED_VIEWER = `<!DOCTYPE html>
15916
+ <html lang="en">
15917
+ <head>
15918
+ <meta charset="UTF-8">
15919
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
15920
+ <title>Mycelium Graph</title>
15921
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
15922
+ <style>
15923
+ :root {
15924
+ --bg: #0e0e12;
15925
+ --surface: #16161e;
15926
+ --surface2: #1e1e2a;
15927
+ --border: #2a2a3a;
15928
+ --text: #e0e0f0;
15929
+ --text-dim: #7878a0;
15930
+ --accent: #7c6af7;
15931
+ --accent2: #f7916a;
15932
+ --accent3: #6af7c4;
15933
+ --import-edge: #4a8fff;
15934
+ --call-edge: #ff8c42;
15935
+ --file-node: #7c6af7;
15936
+ --fn-node: #6af7c4;
15937
+ --class-node: #f7916a;
15938
+ --cluster-hull: rgba(124, 106, 247, 0.07);
15939
+ --sidebar-w: 340px;
15940
+ }
15941
+
15942
+ * { box-sizing: border-box; margin: 0; padding: 0; }
15943
+
15944
+ body {
15945
+ background: var(--bg);
15946
+ color: var(--text);
15947
+ font-family: 'SF Mono', 'Fira Code', monospace;
15948
+ overflow: hidden;
15949
+ height: 100vh;
15950
+ display: flex;
15951
+ }
15952
+
15953
+ /* \u2500\u2500 Sidebar \u2500\u2500 */
15954
+ #sidebar {
15955
+ width: var(--sidebar-w);
15956
+ min-width: var(--sidebar-w);
15957
+ background: var(--surface);
15958
+ border-right: 1px solid var(--border);
15959
+ display: flex;
15960
+ flex-direction: column;
15961
+ z-index: 10;
15962
+ overflow: hidden;
15963
+ }
15964
+
15965
+ #sidebar-header {
15966
+ padding: 16px;
15967
+ border-bottom: 1px solid var(--border);
15968
+ display: flex;
15969
+ align-items: center;
15970
+ gap: 10px;
15971
+ }
15972
+
15973
+ .logo {
15974
+ font-size: 18px;
15975
+ font-weight: 700;
15976
+ color: var(--accent);
15977
+ letter-spacing: -0.5px;
15978
+ }
15979
+
15980
+ .logo span { color: var(--text-dim); font-weight: 400; }
15981
+
15982
+ #search-wrap {
15983
+ padding: 12px 16px;
15984
+ border-bottom: 1px solid var(--border);
15985
+ position: relative;
15986
+ }
15987
+
15988
+ #search {
15989
+ width: 100%;
15990
+ background: var(--surface2);
15991
+ border: 1px solid var(--border);
15992
+ border-radius: 6px;
15993
+ color: var(--text);
15994
+ font-family: inherit;
15995
+ font-size: 12px;
15996
+ padding: 7px 10px 7px 30px;
15997
+ outline: none;
15998
+ transition: border-color 0.15s;
15999
+ }
16000
+
16001
+ #search:focus { border-color: var(--accent); }
16002
+
16003
+ .search-icon {
16004
+ position: absolute;
16005
+ left: 26px;
16006
+ top: 50%;
16007
+ transform: translateY(-50%);
16008
+ color: var(--text-dim);
16009
+ font-size: 12px;
16010
+ pointer-events: none;
16011
+ }
16012
+
16013
+ /* \u2500\u2500 Controls \u2500\u2500 */
16014
+ #controls {
16015
+ padding: 10px 16px;
16016
+ border-bottom: 1px solid var(--border);
16017
+ display: flex;
16018
+ flex-direction: column;
16019
+ gap: 8px;
16020
+ }
16021
+
16022
+ .control-row {
16023
+ display: flex;
16024
+ align-items: center;
16025
+ gap: 8px;
16026
+ font-size: 11px;
16027
+ color: var(--text-dim);
16028
+ }
16029
+
16030
+ .toggle-btn {
16031
+ background: var(--surface2);
16032
+ border: 1px solid var(--border);
16033
+ border-radius: 4px;
16034
+ color: var(--text-dim);
16035
+ font-family: inherit;
16036
+ font-size: 11px;
16037
+ padding: 3px 8px;
16038
+ cursor: pointer;
16039
+ transition: all 0.15s;
16040
+ }
16041
+
16042
+ .toggle-btn.active {
16043
+ background: var(--accent);
16044
+ border-color: var(--accent);
16045
+ color: white;
16046
+ }
16047
+
16048
+ .zoom-level-btns { display: flex; gap: 4px; }
16049
+
16050
+ /* \u2500\u2500 Node info panel \u2500\u2500 */
16051
+ #node-info {
16052
+ flex: 1;
16053
+ overflow-y: auto;
16054
+ padding: 16px;
16055
+ }
16056
+
16057
+ .node-info-empty {
16058
+ color: var(--text-dim);
16059
+ font-size: 12px;
16060
+ text-align: center;
16061
+ margin-top: 40px;
16062
+ }
16063
+
16064
+ .node-detail { animation: fadeIn 0.2s ease; }
16065
+
16066
+ @keyframes fadeIn {
16067
+ from { opacity: 0; transform: translateY(4px); }
16068
+ to { opacity: 1; transform: translateY(0); }
16069
+ }
16070
+
16071
+ .node-kind-badge {
16072
+ display: inline-block;
16073
+ font-size: 10px;
16074
+ padding: 2px 6px;
16075
+ border-radius: 3px;
16076
+ margin-bottom: 8px;
16077
+ text-transform: uppercase;
16078
+ letter-spacing: 0.5px;
16079
+ }
16080
+
16081
+ .kind-file { background: rgba(124,106,247,0.2); color: var(--accent); }
16082
+ .kind-function { background: rgba(106,247,196,0.2); color: var(--fn-node); }
16083
+ .kind-class { background: rgba(247,145,106,0.2); color: var(--class-node); }
16084
+
16085
+ .node-name {
16086
+ font-size: 15px;
16087
+ font-weight: 600;
16088
+ color: var(--text);
16089
+ margin-bottom: 6px;
16090
+ word-break: break-all;
16091
+ }
16092
+
16093
+ .node-description {
16094
+ font-size: 12px;
16095
+ color: var(--text-dim);
16096
+ line-height: 1.6;
16097
+ margin-bottom: 12px;
16098
+ }
16099
+
16100
+ .tags-wrap { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 14px; }
16101
+
16102
+ .tag {
16103
+ background: var(--surface2);
16104
+ border: 1px solid var(--border);
16105
+ border-radius: 3px;
16106
+ font-size: 10px;
16107
+ padding: 2px 6px;
16108
+ color: var(--text-dim);
16109
+ }
16110
+
16111
+ .section-title {
16112
+ font-size: 10px;
16113
+ text-transform: uppercase;
16114
+ letter-spacing: 0.8px;
16115
+ color: var(--text-dim);
16116
+ margin-bottom: 6px;
16117
+ margin-top: 14px;
16118
+ }
16119
+
16120
+ .dep-list { display: flex; flex-direction: column; gap: 3px; }
16121
+
16122
+ .dep-item {
16123
+ font-size: 11px;
16124
+ color: var(--text);
16125
+ padding: 4px 8px;
16126
+ background: var(--surface2);
16127
+ border-radius: 4px;
16128
+ border-left: 2px solid;
16129
+ cursor: pointer;
16130
+ transition: background 0.1s;
16131
+ word-break: break-all;
16132
+ }
16133
+
16134
+ .dep-item:hover { background: #252535; }
16135
+ .dep-import { border-left-color: var(--import-edge); }
16136
+ .dep-call { border-left-color: var(--call-edge); }
16137
+ .dep-calledby { border-left-color: var(--fn-node); }
16138
+
16139
+ .dep-label {
16140
+ font-size: 9px;
16141
+ color: var(--text-dim);
16142
+ display: block;
16143
+ margin-bottom: 1px;
16144
+ }
16145
+
16146
+ .stat-row {
16147
+ display: flex;
16148
+ justify-content: space-between;
16149
+ font-size: 11px;
16150
+ padding: 3px 0;
16151
+ border-bottom: 1px solid var(--border);
16152
+ }
16153
+
16154
+ .stat-val { color: var(--accent); }
16155
+
16156
+ /* \u2500\u2500 Legend \u2500\u2500 */
16157
+ #legend {
16158
+ padding: 12px 16px;
16159
+ border-top: 1px solid var(--border);
16160
+ display: flex;
16161
+ flex-wrap: wrap;
16162
+ gap: 10px;
16163
+ }
16164
+
16165
+ .legend-item {
16166
+ display: flex;
16167
+ align-items: center;
16168
+ gap: 5px;
16169
+ font-size: 10px;
16170
+ color: var(--text-dim);
16171
+ }
16172
+
16173
+ .legend-dot {
16174
+ width: 8px; height: 8px;
16175
+ border-radius: 50%;
16176
+ }
16177
+
16178
+ .legend-line {
16179
+ width: 16px; height: 2px;
16180
+ border-radius: 1px;
16181
+ }
16182
+
16183
+ /* \u2500\u2500 Main canvas area \u2500\u2500 */
16184
+ #canvas-wrap {
16185
+ flex: 1;
16186
+ position: relative;
16187
+ overflow: hidden;
16188
+ }
16189
+
16190
+ #graph-svg { width: 100%; height: 100%; }
16191
+
16192
+ /* \u2500\u2500 Top bar \u2500\u2500 */
16193
+ #topbar {
16194
+ position: absolute;
16195
+ top: 0; left: 0; right: 0;
16196
+ height: 44px;
16197
+ background: rgba(14,14,18,0.9);
16198
+ backdrop-filter: blur(8px);
16199
+ border-bottom: 1px solid var(--border);
16200
+ display: flex;
16201
+ align-items: center;
16202
+ padding: 0 16px;
16203
+ gap: 12px;
16204
+ z-index: 5;
16205
+ }
16206
+
16207
+ .stats-chip {
16208
+ font-size: 11px;
16209
+ color: var(--text-dim);
16210
+ background: var(--surface2);
16211
+ border: 1px solid var(--border);
16212
+ border-radius: 4px;
16213
+ padding: 3px 8px;
16214
+ }
16215
+
16216
+ .stats-chip strong { color: var(--text); }
16217
+
16218
+ #task-input-wrap {
16219
+ flex: 1;
16220
+ max-width: 400px;
16221
+ position: relative;
16222
+ }
16223
+
16224
+ #task-input {
16225
+ width: 100%;
16226
+ background: var(--surface2);
16227
+ border: 1px solid var(--border);
16228
+ border-radius: 6px;
16229
+ color: var(--text);
16230
+ font-family: inherit;
16231
+ font-size: 12px;
16232
+ padding: 6px 36px 6px 10px;
16233
+ outline: none;
16234
+ transition: border-color 0.15s;
16235
+ }
16236
+
16237
+ #task-input:focus { border-color: var(--accent); }
16238
+ #task-input::placeholder { color: var(--text-dim); }
16239
+
16240
+ #task-submit {
16241
+ position: absolute;
16242
+ right: 6px;
16243
+ top: 50%;
16244
+ transform: translateY(-50%);
16245
+ background: var(--accent);
16246
+ border: none;
16247
+ border-radius: 4px;
16248
+ color: white;
16249
+ font-family: inherit;
16250
+ font-size: 10px;
16251
+ padding: 3px 7px;
16252
+ cursor: pointer;
16253
+ }
16254
+
16255
+ /* \u2500\u2500 Minimap \u2500\u2500 */
16256
+ #minimap {
16257
+ position: absolute;
16258
+ bottom: 16px;
16259
+ right: 16px;
16260
+ width: 160px;
16261
+ height: 100px;
16262
+ background: var(--surface);
16263
+ border: 1px solid var(--border);
16264
+ border-radius: 6px;
16265
+ overflow: hidden;
16266
+ z-index: 5;
16267
+ }
16268
+
16269
+ #minimap-svg { width: 100%; height: 100%; }
16270
+ #minimap-viewport {
16271
+ fill: rgba(124,106,247,0.15);
16272
+ stroke: var(--accent);
16273
+ stroke-width: 1;
16274
+ }
16275
+
16276
+ /* \u2500\u2500 Zoom buttons \u2500\u2500 */
16277
+ #zoom-btns {
16278
+ position: absolute;
16279
+ bottom: 130px;
16280
+ right: 16px;
16281
+ display: flex;
16282
+ flex-direction: column;
16283
+ gap: 4px;
16284
+ z-index: 5;
16285
+ }
16286
+
16287
+ .zoom-btn {
16288
+ background: var(--surface);
16289
+ border: 1px solid var(--border);
16290
+ border-radius: 4px;
16291
+ color: var(--text);
16292
+ width: 32px; height: 32px;
16293
+ display: flex; align-items: center; justify-content: center;
16294
+ cursor: pointer;
16295
+ font-size: 16px;
16296
+ transition: background 0.1s;
16297
+ }
16298
+
16299
+ .zoom-btn:hover { background: var(--surface2); }
16300
+
16301
+ /* \u2500\u2500 Loading \u2500\u2500 */
16302
+ #loading {
16303
+ position: absolute;
16304
+ inset: 0;
16305
+ background: var(--bg);
16306
+ display: flex;
16307
+ flex-direction: column;
16308
+ align-items: center;
16309
+ justify-content: center;
16310
+ gap: 16px;
16311
+ z-index: 100;
16312
+ }
16313
+
16314
+ .spinner {
16315
+ width: 40px; height: 40px;
16316
+ border: 3px solid var(--border);
16317
+ border-top-color: var(--accent);
16318
+ border-radius: 50%;
16319
+ animation: spin 0.7s linear infinite;
16320
+ }
16321
+
16322
+ @keyframes spin { to { transform: rotate(360deg); } }
16323
+
16324
+ #loading-text { font-size: 13px; color: var(--text-dim); }
16325
+
16326
+ /* \u2500\u2500 Tooltip \u2500\u2500 */
16327
+ #tooltip {
16328
+ position: absolute;
16329
+ background: var(--surface);
16330
+ border: 1px solid var(--border);
16331
+ border-radius: 8px;
16332
+ padding: 12px 14px;
16333
+ font-size: 12px;
16334
+ pointer-events: none;
16335
+ z-index: 20;
16336
+ max-width: 320px;
16337
+ display: none;
16338
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
16339
+ }
16340
+
16341
+ #tooltip .tip-name { font-weight: 700; font-size: 13px; margin-bottom: 4px; color: var(--text); }
16342
+ #tooltip .tip-desc { color: var(--text-dim); font-size: 11px; line-height: 1.6; margin-top: 4px; }
16343
+ #tooltip .tip-tags { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 3px; }
16344
+ #tooltip .tip-tag { background: rgba(124,106,247,0.15); color: var(--accent); font-size: 10px; padding: 2px 6px; border-radius: 3px; }
16345
+
16346
+ /* \u2500\u2500 SVG styles \u2500\u2500 */
16347
+ .node circle { cursor: pointer; }
16348
+ .node text {
16349
+ font-family: 'SF Mono', 'Fira Code', monospace;
16350
+ font-size: 10px;
16351
+ fill: var(--text-dim);
16352
+ pointer-events: none;
16353
+ user-select: none;
16354
+ }
16355
+
16356
+ .node.selected circle { filter: drop-shadow(0 0 6px var(--accent)); }
16357
+ .node.highlighted circle { opacity: 1 !important; }
16358
+ .node.dimmed circle { opacity: 0.15 !important; }
16359
+ .node.dimmed text { opacity: 0.15; }
16360
+
16361
+ .link {
16362
+ fill: none;
16363
+ stroke: rgba(255,255,255,0.4);
16364
+ stroke-opacity: 0.7;
16365
+ }
16366
+
16367
+ .link.imports-edge { stroke: rgba(255,255,255,0.35); stroke-width: 1; }
16368
+ .link.calls-edge { stroke: rgba(255,255,255,0.18); stroke-width: 1; stroke-dasharray: 4,4; }
16369
+ .link.contains-edge { stroke: #333344; stroke-width: 0.5; stroke-dasharray: 3,3; }
16370
+
16371
+ .link.highlighted { stroke-opacity: 1 !important; stroke-width: 3 !important; }
16372
+ .link.dimmed { stroke-opacity: 0.04 !important; }
16373
+
16374
+ .hull {
16375
+ fill: none;
16376
+ stroke-width: 1.5;
16377
+ stroke-dasharray: 6,4;
16378
+ opacity: 0.5;
16379
+ }
16380
+
16381
+ .cluster-label {
16382
+ font-family: 'SF Mono', monospace;
16383
+ font-size: 11px;
16384
+ fill: rgba(124,106,247,0.5);
16385
+ font-weight: 600;
16386
+ pointer-events: none;
16387
+ }
16388
+ </style>
16389
+ </head>
16390
+ <body>
16391
+
16392
+ <!-- Sidebar -->
16393
+ <div id="sidebar">
16394
+ <div id="sidebar-header">
16395
+ <div class="logo">mycelium<span>.dev</span></div>
16396
+ </div>
16397
+ <div id="search-wrap">
16398
+ <span class="search-icon">\u2315</span>
16399
+ <input id="search" type="text" placeholder="Search files, functions, tags\u2026" autocomplete="off">
16400
+ </div>
16401
+ <div id="controls">
16402
+ <div class="control-row">
16403
+ <span>Edges:</span>
16404
+ <button class="toggle-btn active" id="toggle-imports">Imports</button>
16405
+ <button class="toggle-btn active" id="toggle-calls">Calls</button>
16406
+ <button class="toggle-btn" id="toggle-fns">Functions</button>
16407
+ </div>
16408
+ <div class="control-row">
16409
+ <span>Zoom level:</span>
16410
+ <div class="zoom-level-btns">
16411
+ <button class="toggle-btn active" id="zoom-files">Files</button>
16412
+ <button class="toggle-btn" id="zoom-symbols">Symbols</button>
16413
+ </div>
16414
+ </div>
16415
+ <div class="control-row">
16416
+ <button class="toggle-btn" id="toggle-clusters">Directory clusters</button>
16417
+ </div>
16418
+ </div>
16419
+ <div id="node-info">
16420
+ <div class="node-info-empty" style="line-height:1.8">Click any dot to see<br>what it imports, exports,<br>and connects to</div>
16421
+ </div>
16422
+ <div id="legend">
16423
+ <div id="dir-legend"></div>
16424
+ <div class="legend-item">
16425
+ <div class="legend-line" style="background:rgba(255,255,255,0.35)"></div>Import
16426
+ </div>
16427
+ <div class="legend-item">
16428
+ <div class="legend-line" style="background:rgba(255,255,255,0.25)"></div>Call
16429
+ </div>
16430
+ </div>
16431
+ </div>
16432
+
16433
+ <!-- Canvas -->
16434
+ <div id="canvas-wrap">
16435
+ <div id="topbar">
16436
+ <span class="stats-chip"><strong id="stat-nodes">\u2013</strong> files</span>
16437
+ <span class="stats-chip"><strong id="stat-edges">\u2013</strong> connections</span>
16438
+ <span class="stats-chip"><strong id="stat-fns">\u2013</strong> functions mapped</span>
16439
+ <div id="task-input-wrap">
16440
+ <input id="task-input" type="text" placeholder="e.g. add stripe checkout, fix auth bug, add new page\u2026">
16441
+ <button id="task-submit">\u2192</button>
16442
+ </div>
16443
+ </div>
16444
+ <svg id="graph-svg"></svg>
16445
+
16446
+ <div id="zoom-btns">
16447
+ <div class="zoom-btn" id="btn-zoom-in">+</div>
16448
+ <div class="zoom-btn" id="btn-zoom-fit">\u22A1</div>
16449
+ <div class="zoom-btn" id="btn-zoom-out">\u2212</div>
16450
+ </div>
16451
+
16452
+ <div id="minimap">
16453
+ <svg id="minimap-svg">
16454
+ <rect id="minimap-viewport" x="0" y="0" width="0" height="0"/>
16455
+ </svg>
16456
+ </div>
16457
+ </div>
16458
+
16459
+ <div id="loading">
16460
+ <div class="spinner"></div>
16461
+ <div id="loading-text">Loading graph\u2026</div>
16462
+ </div>
16463
+
16464
+ <div id="help-overlay" style="
16465
+ position:absolute; inset:0; background:rgba(14,14,18,0.85);
16466
+ display:flex; align-items:center; justify-content:center;
16467
+ z-index:50; cursor:pointer; backdrop-filter:blur(2px);
16468
+ " onclick="this.style.display='none'">
16469
+ <div style="text-align:center; max-width:420px; padding:40px">
16470
+ <div style="font-size:32px; margin-bottom:16px">\u{1F344}</div>
16471
+ <div style="font-size:20px; font-weight:700; color:#e0e0f0; margin-bottom:12px">
16472
+ Your codebase, mapped
16473
+ </div>
16474
+ <div style="font-size:13px; color:#7878a0; line-height:1.8; margin-bottom:24px">
16475
+ Each <span style="color:#7c6af7">\u25CF</span> dot is a file.<br>
16476
+ Color shows which folder it belongs to.<br>
16477
+ <span style="color:#5b9fff">Blue lines</span> show imports \u2014 file A uses file B.<br>
16478
+ <span style="color:#ff8c42">Orange lines</span> show function calls across files.<br><br>
16479
+ Click any dot to see what it connects to.<br>
16480
+ Type a task above to find relevant files instantly.
16481
+ </div>
16482
+ <div style="font-size:11px; color:#7878a0; background:#16161e; padding:8px 16px; border-radius:6px; border:1px solid #2a2a3a">
16483
+ Click anywhere to dismiss
16484
+ </div>
16485
+ </div>
16486
+ </div>
16487
+
16488
+ <div id="tooltip">
16489
+ <div class="tip-name"></div>
16490
+ <div class="tip-desc"></div>
16491
+ <div class="tip-tags"></div>
16492
+ </div>
16493
+
16494
+ <script>
16495
+ // \u2500\u2500\u2500 Config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16496
+ const API_BASE = ''; // same origin
16497
+ // Directory-based color palette \u2014 same folder = same color
16498
+ // Much more intuitive than spatial hulls
16499
+ const DIR_PALETTE = [
16500
+ '#6B8AFF', // soft blue \u2014 app/
16501
+ '#4ECDC4', // muted teal \u2014 lib/
16502
+ '#F7B267', // warm amber \u2014 components/
16503
+ '#C5A3FF', // soft lavender \u2014 src/
16504
+ '#7ECBA1', // sage green \u2014 hooks/
16505
+ '#F28B82', // dusty rose \u2014 context/
16506
+ '#93C5FD', // sky blue \u2014 utils/
16507
+ '#FCD34D', // muted gold \u2014 other
16508
+ ];
16509
+
16510
+ const dirColorMap = new Map();
16511
+ let dirColorIdx = 0;
16512
+
16513
+ function getDirColor(nodeId) {
16514
+ const parts = (nodeId || '').split('/');
16515
+ const dir = parts.length > 1 ? parts[0] : 'root';
16516
+ if (!dirColorMap.has(dir)) {
16517
+ dirColorMap.set(dir, DIR_PALETTE[dirColorIdx % DIR_PALETTE.length]);
16518
+ dirColorIdx++;
16519
+ }
16520
+ return dirColorMap.get(dir);
16521
+ }
16522
+
16523
+ const COLORS = {
16524
+ file: '#6B8AFF', // fallback \u2014 uses getDirColor normally
16525
+ function: 'rgba(255,255,255,0.55)', // subtle white \u2014 secondary nodes
16526
+ class: 'rgba(255,255,255,0.45)',
16527
+ import: 'rgba(255,255,255,0.35)',
16528
+ call: 'rgba(255,255,255,0.25)',
16529
+ contains: 'rgba(255,255,255,0.08)',
16530
+ };
16531
+
16532
+ // \u2500\u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16533
+ let allNodes = [], allLinks = [];
16534
+ let visNodes = [], visLinks = [];
16535
+ let selectedNode = null;
16536
+ let simulation, zoom, svg, gMain, gHulls, gLinks, gNodes;
16537
+ let showImports = true, showCalls = true, showFunctions = false, showClusters = false;
16538
+ let zoomLevel = 'files'; // 'files' | 'symbols'
16539
+ let preflight = new Set();
16540
+ let _initialFitDone = false; // prevents fitToScreen on every simulation restart
16541
+
16542
+ // \u2500\u2500\u2500 Load graph \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16543
+ async function loadGraph() {
16544
+ try {
16545
+ setLoading('Fetching graph\u2026');
16546
+ const r = await fetch(\`\${API_BASE}/graph\`);
16547
+ const data = await r.json();
16548
+ allNodes = data.nodes || [];
16549
+ allLinks = data.edges || [];
16550
+ setLoading('Building simulation\u2026');
16551
+ buildVis();
16552
+ } catch (e) {
16553
+ document.getElementById('loading-text').textContent = 'Failed to load graph. Is the server running?';
16554
+ }
16555
+ }
16556
+
16557
+ // \u2500\u2500\u2500 Build visualization \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16558
+ function buildVis() {
16559
+ const svgEl = document.getElementById('graph-svg');
16560
+ const W = svgEl.clientWidth, H = svgEl.clientHeight;
16561
+
16562
+ // Filter nodes/links based on current view settings
16563
+ visNodes = allNodes.filter(n => {
16564
+ if (n.kind === 'file') return true;
16565
+ if ((n.kind === 'function' || n.kind === 'class') && zoomLevel === 'symbols') return showFunctions;
16566
+ return false;
16567
+ });
16568
+
16569
+ const nodeIds = new Set(visNodes.map(n => n.id));
16570
+ visLinks = allLinks.filter(l => {
16571
+ if (!nodeIds.has(l.from) || !nodeIds.has(l.to)) return false;
16572
+ if (l.kind === 'imports' && !showImports) return false;
16573
+ if (l.kind === 'calls' && !showCalls) return false;
16574
+ if (l.kind === 'contains') return zoomLevel === 'symbols';
16575
+ return true;
16576
+ });
16577
+
16578
+ // Update stats
16579
+ const fnCount = allNodes.filter(n => n.kind === 'function').length;
16580
+ document.getElementById('stat-nodes').textContent = visNodes.length;
16581
+ document.getElementById('stat-edges').textContent = visLinks.length;
16582
+ document.getElementById('stat-fns').textContent = fnCount;
16583
+
16584
+ // Clear SVG
16585
+ d3.select('#graph-svg').selectAll('*').remove();
16586
+
16587
+ svg = d3.select('#graph-svg');
16588
+ _initialFitDone = false; // reset on each rebuild
16589
+
16590
+ // Zoom behavior
16591
+ zoom = d3.zoom()
16592
+ .scaleExtent([0.05, 8])
16593
+ .filter(event => event.type !== 'dblclick')
16594
+ .on('zoom', ({ transform }) => {
16595
+ gMain.attr('transform', transform);
16596
+ updateMinimap(transform, W, H);
16597
+ });
16598
+
16599
+ svg.call(zoom);
16600
+
16601
+ gMain = svg.append('g').attr('class', 'main-group');
16602
+ gHulls = gMain.append('g').attr('class', 'hulls');
16603
+ gLinks = gMain.append('g').attr('class', 'links');
16604
+ gNodes = gMain.append('g').attr('class', 'nodes');
16605
+
16606
+ // \u2500\u2500 Cluster hulls by directory \u2500\u2500
16607
+ const dirGroups = groupByDirectory(visNodes.filter(n => n.kind === 'file'));
16608
+
16609
+ // \u2500\u2500 Build link objects \u2500\u2500
16610
+ const nodeMap = new Map(visNodes.map(n => [n.id, n]));
16611
+ const linkObjs = visLinks.map(l => ({
16612
+ ...l,
16613
+ source: nodeMap.get(l.from),
16614
+ target: nodeMap.get(l.to),
16615
+ })).filter(l => l.source && l.target);
16616
+
16617
+ // \u2500\u2500 Simulation \u2500\u2500
16618
+ simulation = d3.forceSimulation(visNodes)
16619
+ .force('link', d3.forceLink(linkObjs)
16620
+ .id(d => d.id)
16621
+ .distance(d => d.kind === 'contains' ? 40 : d.kind === 'calls' ? 80 : 100)
16622
+ .strength(d => d.kind === 'contains' ? 0.8 : 0.3))
16623
+ .force('charge', d3.forceManyBody().strength(d => d.kind === 'file' ? -250 : -80))
16624
+ .force('center', d3.forceCenter(W / 2, H / 2))
16625
+ .force('collision', d3.forceCollide().radius(d => nodeRadius(d) + 8))
16626
+ .force('cluster', clusterForce(dirGroups, 0.08))
16627
+ .alphaDecay(0.02)
16628
+ .on('tick', ticked)
16629
+ .on('end', () => {
16630
+ if (!_initialFitDone) {
16631
+ hideLoading();
16632
+ if (showClusters) renderHulls(dirGroups);
16633
+ fitToScreen();
16634
+ updateDirLegend();
16635
+ _initialFitDone = true;
16636
+ }
16637
+ });
16638
+
16639
+ // \u2500\u2500 Links \u2500\u2500
16640
+ const link = gLinks.selectAll('.link')
16641
+ .data(linkObjs)
16642
+ .join('line')
16643
+ .attr('class', d => \`link \${d.kind}-edge\`)
16644
+ .attr('marker-end', d => d.kind === 'calls' ? 'url(#arrow-call)' : null);
16645
+
16646
+ // Arrow markers
16647
+ svg.append('defs').html(\`
16648
+ <marker id="arrow-call" viewBox="0 -4 8 8" refX="8" refY="0"
16649
+ markerWidth="6" markerHeight="6" orient="auto">
16650
+ <path d="M0,-4L8,0L0,4" fill="\${COLORS.call}" opacity="0.6"/>
16651
+ </marker>
16652
+ \`);
16653
+
16654
+ // \u2500\u2500 Nodes \u2500\u2500
16655
+ const node = gNodes.selectAll('.node')
16656
+ .data(visNodes)
16657
+ .join('g')
16658
+ .attr('class', 'node')
16659
+ .attr('data-id', d => d.id)
16660
+ .call(d3.drag()
16661
+ .clickDistance(8)
16662
+ .on('start', (event, d) => {
16663
+ if (!event.active) simulation.alphaTarget(0.3).restart();
16664
+ d.fx = d.x; d.fy = d.y;
16665
+ })
16666
+ .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
16667
+ .on('end', (event, d) => {
16668
+ if (!event.active) simulation.alphaTarget(0);
16669
+ d.fx = null; d.fy = null;
16670
+ }))
16671
+ .on('click', (event, d) => {
16672
+ event.stopPropagation();
16673
+ // Smoothly pan to clicked node, preserving current zoom level
16674
+ if (d.x !== undefined && d.y !== undefined) {
16675
+ const svgEl = document.getElementById('graph-svg');
16676
+ const W = svgEl.clientWidth, H = svgEl.clientHeight;
16677
+ const currentT = d3.zoomTransform(svg.node());
16678
+ const s = currentT.k; // keep current zoom level
16679
+ svg.transition().duration(400).call(
16680
+ zoom.transform,
16681
+ d3.zoomIdentity.translate(W / 2 - d.x * s, H / 2 - d.y * s).scale(s)
16682
+ );
16683
+ }
16684
+ selectNode(d, linkObjs);
16685
+ })
16686
+ .on('mouseenter', (event, d) => showTooltip(event, d))
16687
+ .on('mousemove', (event) => moveTooltip(event))
16688
+ .on('mouseleave', hideTooltip);
16689
+
16690
+ node.append('circle')
16691
+ .attr('r', d => nodeRadius(d))
16692
+ .attr('fill', d => d.kind === 'file' ? getDirColor(d.id) : (COLORS[d.kind] ?? COLORS.file))
16693
+ .attr('fill-opacity', 0.9)
16694
+ .attr('stroke', d => d.kind === 'file' ? getDirColor(d.id) : (COLORS[d.kind] ?? COLORS.file))
16695
+ .attr('stroke-width', 2)
16696
+ .attr('stroke-opacity', 0.35);
16697
+
16698
+ node.append('text')
16699
+ .attr('dy', d => nodeRadius(d) + 11)
16700
+ .attr('text-anchor', 'middle')
16701
+ .text(d => shortName(d.name || d.id));
16702
+
16703
+ // Click background to deselect
16704
+ svg.on('click', () => {
16705
+ selectNode(null, linkObjs);
16706
+ });
16707
+
16708
+ function ticked() {
16709
+ link
16710
+ .attr('x1', d => d.source.x)
16711
+ .attr('y1', d => d.source.y)
16712
+ .attr('x2', d => d.target.x)
16713
+ .attr('y2', d => d.target.y);
16714
+
16715
+ node.attr('transform', d => \`translate(\${d.x},\${d.y})\`);
16716
+
16717
+ // Update minimap nodes
16718
+ updateMinimapNodes(visNodes);
16719
+ }
16720
+ }
16721
+
16722
+ // \u2500\u2500\u2500 Cluster force \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16723
+ function clusterForce(groups, strength) {
16724
+ // Gentle force pulling nodes toward their directory centroid
16725
+ const centroids = new Map();
16726
+
16727
+ for (const [dir, nodes] of Object.entries(groups)) {
16728
+ centroids.set(dir, { x: 0, y: 0 });
16729
+ }
16730
+
16731
+ return function(alpha) {
16732
+ for (const [dir, nodes] of Object.entries(groups)) {
16733
+ if (nodes.length < 2) continue;
16734
+ let cx = 0, cy = 0;
16735
+ for (const n of nodes) { cx += n.x || 0; cy += n.y || 0; }
16736
+ cx /= nodes.length; cy /= nodes.length;
16737
+ centroids.set(dir, { x: cx, y: cy });
16738
+ for (const n of nodes) {
16739
+ n.vx = (n.vx || 0) + (cx - n.x) * strength * alpha;
16740
+ n.vy = (n.vy || 0) + (cy - n.y) * strength * alpha;
16741
+ }
16742
+ }
16743
+ };
16744
+ }
16745
+
16746
+ // \u2500\u2500\u2500 Hull rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16747
+ function renderHulls(dirGroups) {
16748
+ gHulls.selectAll('*').remove();
16749
+
16750
+ for (const [dir, nodes] of Object.entries(dirGroups)) {
16751
+ if (nodes.length < 3) continue;
16752
+ const points = nodes.map(n => [n.x, n.y]);
16753
+ const hull = d3.polygonHull(points);
16754
+ if (!hull) continue;
16755
+
16756
+ // Expand hull
16757
+ const centroid = d3.polygonCentroid(hull);
16758
+ const expanded = hull.map(([px, py]) => {
16759
+ const dx = px - centroid[0], dy = py - centroid[1];
16760
+ const dist = Math.sqrt(dx*dx + dy*dy) || 1;
16761
+ return [px + dx/dist * 30, py + dy/dist * 30];
16762
+ });
16763
+
16764
+ const hullColor = getDirColor(nodes[0]?.id ?? dir);
16765
+ gHulls.append('path')
16766
+ .attr('class', 'hull')
16767
+ .attr('d', \`M\${expanded.join('L')}Z\`)
16768
+ .attr('stroke', hullColor);
16769
+
16770
+ gHulls.append('text')
16771
+ .attr('class', 'cluster-label')
16772
+ .attr('x', centroid[0])
16773
+ .attr('y', centroid[1] - (Math.max(...nodes.map(n => Math.abs(n.y - centroid[1]))) + 40))
16774
+ .attr('text-anchor', 'middle')
16775
+ .attr('fill', hullColor)
16776
+ .text(dir);
16777
+ }
16778
+ }
16779
+
16780
+ // \u2500\u2500\u2500 Node selection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16781
+ function selectNode(nodeData, linkObjs) {
16782
+ selectedNode = nodeData;
16783
+
16784
+ if (!nodeData) {
16785
+ // Deselect all
16786
+ d3.selectAll('.node').classed('selected highlighted dimmed', false);
16787
+ d3.selectAll('.link').classed('highlighted dimmed', false);
16788
+ d3.selectAll('.node circle').attr('fill-opacity', 0.85);
16789
+ renderNodeInfo(null, linkObjs);
16790
+ return;
16791
+ }
16792
+
16793
+ const connectedIds = new Set([nodeData.id]);
16794
+ const connectedLinks = new Set();
16795
+
16796
+ for (const l of linkObjs) {
16797
+ const fromId = l.source?.id ?? l.from;
16798
+ const toId = l.target?.id ?? l.to;
16799
+ if (fromId === nodeData.id || toId === nodeData.id) {
16800
+ connectedIds.add(fromId);
16801
+ connectedIds.add(toId);
16802
+ connectedLinks.add(l);
16803
+ }
16804
+ }
16805
+
16806
+ d3.selectAll('.node')
16807
+ .classed('selected', d => d.id === nodeData.id)
16808
+ .classed('highlighted', d => connectedIds.has(d.id))
16809
+ .classed('dimmed', d => !connectedIds.has(d.id));
16810
+
16811
+ d3.selectAll('.link')
16812
+ .classed('highlighted', l => connectedLinks.has(l))
16813
+ .classed('dimmed', l => !connectedLinks.has(l));
16814
+
16815
+ renderNodeInfo(nodeData, linkObjs);
16816
+ }
16817
+
16818
+ // \u2500\u2500\u2500 Sidebar info \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16819
+ function renderNodeInfo(nodeData, linkObjs) {
16820
+ const panel = document.getElementById('node-info');
16821
+
16822
+ if (!nodeData) {
16823
+ panel.innerHTML = '<div class="node-info-empty" style="line-height:1.8">Click any dot to see<br>what it imports, exports,<br>and connects to</div>';
16824
+ return;
16825
+ }
16826
+
16827
+ const imports = linkObjs.filter(l => {
16828
+ const from = l.source?.id ?? l.from;
16829
+ return from === nodeData.id && l.kind === 'imports';
16830
+ });
16831
+
16832
+ const importedBy = linkObjs.filter(l => {
16833
+ const to = l.target?.id ?? l.to;
16834
+ return to === nodeData.id && l.kind === 'imports';
16835
+ });
16836
+
16837
+ const calls = linkObjs.filter(l => {
16838
+ const from = l.source?.id ?? l.from;
16839
+ return from === nodeData.id && l.kind === 'calls';
16840
+ });
16841
+
16842
+ const calledBy = linkObjs.filter(l => {
16843
+ const to = l.target?.id ?? l.to;
16844
+ return to === nodeData.id && l.kind === 'calls';
16845
+ });
16846
+
16847
+ const tags = (nodeData.tags || []).map(t => \`<span class="tag">\${t}</span>\`).join('');
16848
+ const tagsHtml = tags ? \`<div class="tags-wrap">\${tags}</div>\` : '';
16849
+
16850
+ function depList(items, cssClass, labelText, key) {
16851
+ if (!items.length) return '';
16852
+ return \`
16853
+ <div class="section-title">\${labelText} (\${items.length})</div>
16854
+ <div class="dep-list">
16855
+ \${items.slice(0, 12).map(l => {
16856
+ const id = key === 'to' ? (l.target?.id ?? l.to) : (l.source?.id ?? l.from);
16857
+ const shortId = id.split('/').pop();
16858
+ const extra = l.callCount ? \`<span class="dep-label">called \${l.callCount}\xD7</span>\` : '';
16859
+ return \`<div class="dep-item \${cssClass}" onclick="focusNode('\${id}')">\${extra}\${shortId}</div>\`;
16860
+ }).join('')}
16861
+ \${items.length > 12 ? \`<div style="font-size:10px;color:var(--text-dim);padding:4px 8px">+\${items.length - 12} more</div>\` : ''}
16862
+ </div>\`;
16863
+ }
16864
+
16865
+ panel.innerHTML = \`
16866
+ <div class="node-detail">
16867
+ <div class="node-kind-badge kind-\${nodeData.kind}">\${nodeData.kind}</div>
16868
+ <div class="node-name">\${nodeData.name || nodeData.id}</div>
16869
+ \${nodeData.description ? \`<div class="node-description">\${nodeData.description}</div>\` : ''}
16870
+ \${tagsHtml}
16871
+
16872
+ <div class="section-title">Stats</div>
16873
+ <div class="stat-row"><span>Lines</span><span class="stat-val">\${nodeData.lineCount ?? '\u2013'}</span></div>
16874
+ <div class="stat-row"><span>Imports</span><span class="stat-val">\${imports.length}</span></div>
16875
+ <div class="stat-row"><span>Imported by</span><span class="stat-val">\${importedBy.length}</span></div>
16876
+ \${calls.length || calledBy.length ? \`
16877
+ <div class="stat-row"><span>Calls</span><span class="stat-val">\${calls.length}</span></div>
16878
+ <div class="stat-row"><span>Called by</span><span class="stat-val">\${calledBy.length}</span></div>\` : ''}
16879
+
16880
+ \${depList(imports, 'dep-import', 'Imports', 'to')}
16881
+ \${depList(importedBy, 'dep-import', 'Imported by', 'from')}
16882
+ \${depList(calls, 'dep-call', 'Calls', 'to')}
16883
+ \${depList(calledBy, 'dep-calledby', 'Called by', 'from')}
16884
+ </div>\`;
16885
+ }
16886
+
16887
+ // \u2500\u2500\u2500 Focus a node by ID \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16888
+ function focusNode(nodeId) {
16889
+ const nodeData = allNodes.find(n => n.id === nodeId);
16890
+ if (!nodeData) return;
16891
+
16892
+ // Pan to node
16893
+ const svgEl = document.getElementById('graph-svg');
16894
+ const W = svgEl.clientWidth, H = svgEl.clientHeight;
16895
+
16896
+ const linkObjs = visLinks.map(l => ({
16897
+ ...l,
16898
+ source: allNodes.find(n => n.id === l.from),
16899
+ target: allNodes.find(n => n.id === l.to),
16900
+ })).filter(l => l.source && l.target);
16901
+
16902
+ if (nodeData.x !== undefined) {
16903
+ const currentT = d3.zoomTransform(svg.node());
16904
+ const s = currentT.k; // never change the zoom level, only pan
16905
+ svg.transition().duration(400).call(
16906
+ zoom.transform,
16907
+ d3.zoomIdentity.translate(W / 2 - nodeData.x * s, H / 2 - nodeData.y * s).scale(s)
16908
+ );
16909
+ }
16910
+
16911
+ selectNode(nodeData, linkObjs);
16912
+ }
16913
+
16914
+ // \u2500\u2500\u2500 Search \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16915
+ document.getElementById('search').addEventListener('input', function() {
16916
+ const q = this.value.toLowerCase().trim();
16917
+ if (!q) {
16918
+ d3.selectAll('.node').classed('highlighted dimmed', false);
16919
+ return;
16920
+ }
16921
+
16922
+ const matched = new Set(
16923
+ allNodes
16924
+ .filter(n =>
16925
+ (n.id || '').toLowerCase().includes(q) ||
16926
+ (n.name || '').toLowerCase().includes(q) ||
16927
+ (n.description || '').toLowerCase().includes(q) ||
16928
+ (n.tags || []).some(t => t.toLowerCase().includes(q))
16929
+ )
16930
+ .map(n => n.id)
16931
+ );
16932
+
16933
+ d3.selectAll('.node')
16934
+ .classed('highlighted', d => matched.has(d.id))
16935
+ .classed('dimmed', d => !matched.has(d.id));
16936
+ });
16937
+
16938
+ // \u2500\u2500\u2500 Preflight \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16939
+ document.getElementById('task-submit').addEventListener('click', async () => {
16940
+ const task = document.getElementById('task-input').value.trim();
16941
+ if (!task) return;
16942
+
16943
+ const btn = document.getElementById('task-submit');
16944
+ btn.textContent = '\u2026';
16945
+
16946
+ try {
16947
+ const r = await fetch(\`\${API_BASE}/preflight?task=\${encodeURIComponent(task)}\`);
16948
+ const data = await r.json();
16949
+ const fileIds = new Set((data.files || []).map(f => f.nodeId || f.id));
16950
+ preflight = fileIds;
16951
+
16952
+ // Highlight preflight results
16953
+ d3.selectAll('.node')
16954
+ .classed('highlighted', d => fileIds.has(d.id))
16955
+ .classed('dimmed', d => !fileIds.has(d.id));
16956
+
16957
+ d3.selectAll('.link')
16958
+ .classed('dimmed', l => {
16959
+ const from = l.source?.id ?? l.from;
16960
+ const to = l.target?.id ?? l.to;
16961
+ return !fileIds.has(from) && !fileIds.has(to);
16962
+ });
16963
+
16964
+ // Show results in sidebar
16965
+ const panel = document.getElementById('node-info');
16966
+ const saved = data.tokensSaved ?? 0;
16967
+ panel.innerHTML = \`
16968
+ <div class="node-detail">
16969
+ <div class="node-kind-badge kind-file">Preflight</div>
16970
+ <div class="node-name">"\${task}"</div>
16971
+ <div class="node-description">
16972
+ \${fileIds.size} files \xB7 ~\${saved} tokens saved vs reading everything
16973
+ </div>
16974
+ <div class="section-title">Relevant Files</div>
16975
+ <div class="dep-list">
16976
+ \${(data.files || []).map(f => \`
16977
+ <div class="dep-item dep-import" onclick="focusNode('\${f.nodeId || f.id}')">
16978
+ <span class="dep-label">\${f.reason || ''} \xB7 \${Math.round((f.score||0)*100)}% match</span>
16979
+ \${(f.nodeId || f.id).split('/').pop()}
16980
+ </div>
16981
+ \`).join('')}
16982
+ </div>
16983
+ </div>\`;
16984
+ } catch (e) {
16985
+ console.error('Preflight failed:', e);
16986
+ }
16987
+
16988
+ btn.textContent = '\u2192';
16989
+ });
16990
+
16991
+ document.getElementById('task-input').addEventListener('keydown', e => {
16992
+ if (e.key === 'Enter') document.getElementById('task-submit').click();
16993
+ });
16994
+
16995
+ // \u2500\u2500\u2500 Toggle controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16996
+ function makeToggle(btnId, getter, setter) {
16997
+ document.getElementById(btnId).addEventListener('click', function() {
16998
+ setter(!getter());
16999
+ this.classList.toggle('active', getter());
17000
+ rebuildVis();
17001
+ });
17002
+ }
17003
+
17004
+ makeToggle('toggle-imports', () => showImports, v => showImports = v);
17005
+ makeToggle('toggle-calls', () => showCalls, v => showCalls = v);
17006
+ makeToggle('toggle-fns', () => showFunctions, v => showFunctions = v);
17007
+
17008
+ document.getElementById('toggle-clusters').addEventListener('click', function() {
17009
+ showClusters = !showClusters;
17010
+ this.classList.toggle('active', showClusters);
17011
+ if (showClusters) {
17012
+ renderHulls(groupByDirectory(visNodes.filter(n => n.kind === 'file')));
17013
+ } else {
17014
+ gHulls.selectAll('*').remove();
17015
+ }
17016
+ });
17017
+
17018
+ document.getElementById('zoom-files').addEventListener('click', function() {
17019
+ zoomLevel = 'files';
17020
+ this.classList.add('active');
17021
+ document.getElementById('zoom-symbols').classList.remove('active');
17022
+ rebuildVis();
17023
+ });
17024
+
17025
+ document.getElementById('zoom-symbols').addEventListener('click', function() {
17026
+ zoomLevel = 'symbols';
17027
+ this.classList.add('active');
17028
+ document.getElementById('zoom-files').classList.remove('active');
17029
+ rebuildVis();
17030
+ });
17031
+
17032
+ function rebuildVis() {
17033
+ if (simulation) simulation.stop();
17034
+ document.getElementById('loading').style.display = 'flex';
17035
+ document.getElementById('loading-text').textContent = 'Rebuilding\u2026';
17036
+ setTimeout(() => buildVis(), 50);
17037
+ }
17038
+
17039
+ // \u2500\u2500\u2500 Zoom controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
17040
+ document.getElementById('btn-zoom-in').onclick = () =>
17041
+ svg.transition().duration(300).call(zoom.scaleBy, 1.5);
17042
+ document.getElementById('btn-zoom-out').onclick = () =>
17043
+ svg.transition().duration(300).call(zoom.scaleBy, 0.667);
17044
+ document.getElementById('btn-zoom-fit').onclick = fitToScreen;
17045
+
17046
+ function fitToScreen() {
17047
+ if (!visNodes.length) return;
17048
+ const svgEl = document.getElementById('graph-svg');
17049
+ const W = svgEl.clientWidth, H = svgEl.clientHeight;
17050
+
17051
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
17052
+ for (const n of visNodes) {
17053
+ if (n.x < minX) minX = n.x;
17054
+ if (n.y < minY) minY = n.y;
17055
+ if (n.x > maxX) maxX = n.x;
17056
+ if (n.y > maxY) maxY = n.y;
17057
+ }
17058
+
17059
+ const pad = 60;
17060
+ const scale = Math.min(
17061
+ (W - pad * 2) / (maxX - minX || 1),
17062
+ (H - pad * 2) / (maxY - minY || 1),
17063
+ 2.5
17064
+ );
17065
+ const tx = W / 2 - ((minX + maxX) / 2) * scale;
17066
+ const ty = H / 2 - ((minY + maxY) / 2) * scale;
17067
+
17068
+ svg.transition().duration(700).call(
17069
+ zoom.transform,
17070
+ d3.zoomIdentity.translate(tx, ty).scale(scale)
17071
+ );
17072
+ }
17073
+
17074
+ // \u2500\u2500\u2500 Minimap \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
17075
+ let minimapSvg;
17076
+
17077
+ function initMinimap() {
17078
+ minimapSvg = d3.select('#minimap-svg');
17079
+ }
17080
+
17081
+ function updateMinimapNodes(nodes) {
17082
+ if (!minimapSvg || !nodes.length) return;
17083
+ const mm = document.getElementById('minimap');
17084
+ const W = mm.clientWidth, H = mm.clientHeight;
17085
+
17086
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
17087
+ for (const n of nodes) {
17088
+ if (n.x < minX) minX = n.x;
17089
+ if (n.y < minY) minY = n.y;
17090
+ if (n.x > maxX) maxX = n.x;
17091
+ if (n.y > maxY) maxY = n.y;
17092
+ }
17093
+
17094
+ const scaleX = (W - 8) / (maxX - minX || 1);
17095
+ const scaleY = (H - 8) / (maxY - minY || 1);
17096
+ const scale = Math.min(scaleX, scaleY);
17097
+
17098
+ minimapSvg.selectAll('.mm-node').remove();
17099
+
17100
+ minimapSvg.selectAll('.mm-node')
17101
+ .data(nodes.filter(n => n.kind === 'file'))
17102
+ .join('circle')
17103
+ .attr('class', 'mm-node')
17104
+ .attr('cx', d => 4 + (d.x - minX) * scale)
17105
+ .attr('cy', d => 4 + (d.y - minY) * scale)
17106
+ .attr('r', 2)
17107
+ .attr('fill', d => COLORS[d.kind] ?? '#7c6af7')
17108
+ .attr('opacity', 0.7);
17109
+ }
17110
+
17111
+ function updateMinimap(transform, svgW, svgH) {
17112
+ if (!minimapSvg) return;
17113
+ const mm = document.getElementById('minimap');
17114
+ const W = mm.clientWidth, H = mm.clientHeight;
17115
+
17116
+ // Minimap scale is roughly W/svgW * transform.k
17117
+ const vW = (W / transform.k) * (W / svgW);
17118
+ const vH = (H / transform.k) * (H / svgH);
17119
+ const vX = -transform.x * (W / svgW) / transform.k;
17120
+ const vY = -transform.y * (H / svgH) / transform.k;
17121
+
17122
+ d3.select('#minimap-viewport')
17123
+ .attr('x', Math.max(0, vX))
17124
+ .attr('y', Math.max(0, vY))
17125
+ .attr('width', Math.min(W, vW))
17126
+ .attr('height', Math.min(H, vH));
17127
+ }
17128
+
17129
+ // \u2500\u2500\u2500 Tooltip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
17130
+ function showTooltip(event, d) {
17131
+ const tip = document.getElementById('tooltip');
17132
+ const name = (d.id || '').split('/').pop() || d.name || d.id;
17133
+ const dir = (d.id || '').split('/').slice(0, -1).join('/');
17134
+ tip.querySelector('.tip-name').textContent = name;
17135
+ tip.querySelector('.tip-path').textContent = dir ? dir + '/' : '';
17136
+ tip.querySelector('.tip-desc').textContent = d.description || 'No description yet \u2014 run mycelium init to summarize';
17137
+ tip.style.display = 'block';
17138
+ moveTooltip(event);
17139
+ }
17140
+
17141
+ function moveTooltip(event) {
17142
+ const tip = document.getElementById('tooltip');
17143
+ const sidebar = document.getElementById('sidebar');
17144
+ const x = event.clientX - sidebar.clientWidth;
17145
+ const y = event.clientY - 44; // topbar height
17146
+ tip.style.left = (x + 12) + 'px';
17147
+ tip.style.top = (y - tip.offsetHeight - 8) + 'px';
17148
+ }
17149
+
17150
+ function hideTooltip() {
17151
+ document.getElementById('tooltip').style.display = 'none';
17152
+ }
17153
+
17154
+ // \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
17155
+ function nodeRadius(d) {
17156
+ if (d.kind === 'file') return Math.max(5, Math.min(14, Math.sqrt((d.lineCount || 100) / 10)));
17157
+ if (d.kind === 'function') return 4;
17158
+ if (d.kind === 'class') return 6;
17159
+ return 5;
17160
+ }
17161
+
17162
+ function shortName(name) {
17163
+ if (!name) return '';
17164
+ const parts = name.split('/');
17165
+ const last = parts[parts.length - 1];
17166
+ return last.length > 20 ? last.slice(0, 18) + '\u2026' : last;
17167
+ }
17168
+
17169
+ function groupByDirectory(fileNodes) {
17170
+ const groups = {};
17171
+ for (const n of fileNodes) {
17172
+ const parts = (n.id || '').split('/');
17173
+ const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '.';
17174
+ if (!groups[dir]) groups[dir] = [];
17175
+ groups[dir].push(n);
17176
+ }
17177
+ return groups;
17178
+ }
17179
+
17180
+ function setLoading(text) {
17181
+ document.getElementById('loading').style.display = 'flex';
17182
+ document.getElementById('loading-text').textContent = text;
17183
+ }
17184
+
17185
+ function hideLoading() {
17186
+ document.getElementById('loading').style.display = 'none';
17187
+ }
17188
+
17189
+ // \u2500\u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
17190
+ initMinimap();
17191
+ loadGraph();
17192
+
17193
+ function updateDirLegend() {
17194
+ const el = document.getElementById('dir-legend');
17195
+ if (!el) return;
17196
+ el.innerHTML = Array.from(dirColorMap.entries()).map(([dir, color]) =>
17197
+ \`<div class="legend-item"><div class="legend-dot" style="background:\${color}"></div>\${dir}/</div>\`
17198
+ ).join('');
17199
+ }
17200
+ </script>
17201
+ </body>
17202
+ </html>
17203
+ `;
15899
17204
  var RateLimiter = class {
15900
17205
  constructor() {
15901
17206
  this.counts = /* @__PURE__ */ new Map();
@@ -16233,25 +17538,32 @@ var McpServer = class {
16233
17538
  const candidates = [
16234
17539
  path4.join(__dirname, "..", "..", "ui", "index.html"),
16235
17540
  path4.join(__dirname, "..", "ui", "index.html"),
16236
- path4.join(process.cwd(), "ui", "index.html")
17541
+ path4.join(__dirname, "ui", "index.html"),
17542
+ path4.join(process.cwd(), "ui", "index.html"),
17543
+ // npm global install location
17544
+ path4.join(path4.dirname(process.execPath), "..", "lib", "node_modules", "@kopikocappu", "mycelium", "ui", "index.html")
16237
17545
  ];
16238
17546
  for (const candidate of candidates) {
16239
17547
  if (fs3.existsSync(candidate)) {
16240
17548
  try {
16241
17549
  const html = fs3.readFileSync(candidate, "utf-8");
16242
- const injected = html.replace(
17550
+ const injected2 = html.replace(
16243
17551
  "const API_BASE = '';",
16244
17552
  `const API_BASE = 'http://localhost:${this.config.mcp.port}';`
16245
17553
  );
16246
17554
  res.writeHead(200, { "Content-Type": "text/html" });
16247
- res.end(injected);
17555
+ res.end(injected2);
16248
17556
  return;
16249
17557
  } catch {
16250
17558
  }
16251
17559
  }
16252
17560
  }
16253
17561
  res.writeHead(200, { "Content-Type": "text/html" });
16254
- res.end('<html><head><title>Mycelium</title></head><body style="background:#0e0e12;color:#e0e0f0;font-family:monospace;padding:40px"><h2>Mycelium Graph Viewer</h2><p>Graph viewer file not found. Make sure ui/index.html exists next to dist/.</p><p>API endpoints: /graph /status /preflight /search /history</p></body></html>');
17562
+ const injected = EMBEDDED_VIEWER.replace(
17563
+ "const API_BASE = '';",
17564
+ `const API_BASE = 'http://localhost:${this.config.mcp.port}';`
17565
+ );
17566
+ res.end(injected);
16255
17567
  }
16256
17568
  handleDebug(res) {
16257
17569
  const stats = this.store.getStats();
@@ -16501,19 +17813,29 @@ function detectSourceGlobs(workspaceRoot) {
16501
17813
  try {
16502
17814
  const raw = fs5.readFileSync(tsconfigPath, "utf8").replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
16503
17815
  const tsconfig = JSON.parse(raw);
16504
- if (Array.isArray(tsconfig.include) && tsconfig.include.length > 0) {
16505
- const globs = tsconfig.include.map((p) => {
17816
+ const hasExtends = !!tsconfig.extends;
17817
+ if (!hasExtends && Array.isArray(tsconfig.include) && tsconfig.include.length > 0) {
17818
+ const globs = tsconfig.include.filter((p) => {
17819
+ const hasLeadingDir = /^[^*]+\//.test(p);
17820
+ const isFileGlob = p.startsWith("*") && !hasLeadingDir;
17821
+ const isDeclarationFile = p.endsWith(".d.ts");
17822
+ return !isFileGlob && !isDeclarationFile;
17823
+ }).map((p) => {
16506
17824
  const base = p.replace(/\/\*\*\/\*(\.\w+)?$/, "").replace(/\/\*(\.\w+)?$/, "").replace(/\.$/, "").replace(/\*$/, "").replace(/\/$/, "");
16507
17825
  return base ? `${base}/**/*.${exts}` : `**/*.${exts}`;
16508
17826
  }).filter((v, i, a) => a.indexOf(v) === i);
16509
- console.log(`[GraphMem] Detected source globs from tsconfig.include:`, globs);
16510
- return globs;
16511
- }
16512
- const rootDir = tsconfig.compilerOptions?.rootDir;
16513
- if (rootDir && rootDir !== ".") {
16514
- const glob2 = `${rootDir.replace(/^\.\//, "")}/**/*.${exts}`;
16515
- console.log(`[GraphMem] Detected source glob from tsconfig.rootDir:`, glob2);
16516
- return [glob2];
17827
+ if (globs.length > 0) {
17828
+ console.log(`[GraphMem] Detected source globs from tsconfig.include:`, globs);
17829
+ return globs;
17830
+ }
17831
+ }
17832
+ if (!hasExtends) {
17833
+ const rootDir = tsconfig.compilerOptions?.rootDir;
17834
+ if (rootDir && rootDir !== ".") {
17835
+ const glob2 = `${rootDir.replace(/^\.\//, "")}/**/*.${exts}`;
17836
+ console.log(`[GraphMem] Detected source glob from tsconfig.rootDir:`, glob2);
17837
+ return [glob2];
17838
+ }
16517
17839
  }
16518
17840
  } catch (e) {
16519
17841
  console.warn("[GraphMem] Could not parse tsconfig.json, falling back to directory detection");