codesearch-mcp 0.0.17-alpha → 0.0.18-alpha

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 (83) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +30 -26
  3. package/dist/app.js +34 -10
  4. package/dist/src/commands/ConfigCommand.d.ts +1 -0
  5. package/dist/src/commands/ConfigCommand.js +84 -4
  6. package/dist/src/commands/IndexCommand.js +22 -7
  7. package/dist/src/commands/McpCommand.js +4 -3
  8. package/dist/src/commands/WebCommand.d.ts +8 -0
  9. package/dist/src/commands/WebCommand.js +56 -0
  10. package/dist/src/config-store.d.ts +43 -0
  11. package/dist/src/config-store.js +92 -27
  12. package/dist/src/config.js +16 -17
  13. package/dist/src/constants.d.ts +2 -1
  14. package/dist/src/constants.js +6 -4
  15. package/dist/src/container.d.ts +10 -6
  16. package/dist/src/container.js +6 -3
  17. package/dist/src/graph.d.ts +60 -0
  18. package/dist/src/graph.js +239 -120
  19. package/dist/src/help.js +15 -1
  20. package/dist/src/interfaces/IAstParser.d.ts +6 -3
  21. package/dist/src/interfaces/IDatabase.d.ts +13 -0
  22. package/dist/src/interfaces/IDatabase.js +2 -0
  23. package/dist/src/interfaces/IEmbedder.d.ts +1 -0
  24. package/dist/src/interfaces/ILogger.d.ts +10 -0
  25. package/dist/src/interfaces/ILogger.js +2 -0
  26. package/dist/src/interfaces/IMcpToolHandler.d.ts +22 -0
  27. package/dist/src/interfaces/IMcpToolHandler.js +2 -0
  28. package/dist/src/interfaces/IMonorepoResolver.d.ts +33 -0
  29. package/dist/src/interfaces/IMonorepoResolver.js +2 -0
  30. package/dist/src/interfaces/IProjectDiscovery.d.ts +28 -0
  31. package/dist/src/interfaces/IProjectDiscovery.js +2 -0
  32. package/dist/src/interfaces/ISearcher.d.ts +36 -0
  33. package/dist/src/interfaces/ISearcher.js +2 -0
  34. package/dist/src/interfaces/index.d.ts +7 -0
  35. package/dist/src/interfaces/index.js +2 -0
  36. package/dist/src/project-selector.d.ts +1 -1
  37. package/dist/src/services/AstParserService.d.ts +4 -6
  38. package/dist/src/services/AstParserService.js +29 -13
  39. package/dist/src/services/DatabaseService.d.ts +2 -1
  40. package/dist/src/services/IndexerService.d.ts +6 -8
  41. package/dist/src/services/IndexerService.js +5 -8
  42. package/dist/src/services/LoggerService.d.ts +2 -1
  43. package/dist/src/services/McpServer.d.ts +16 -7
  44. package/dist/src/services/McpServer.js +54 -367
  45. package/dist/src/services/MonorepoAwareDiscoveryService.d.ts +62 -0
  46. package/dist/src/services/MonorepoAwareDiscoveryService.js +125 -0
  47. package/dist/src/services/NxMonorepoResolver.d.ts +45 -0
  48. package/dist/src/services/NxMonorepoResolver.js +308 -0
  49. package/dist/src/services/OllamaEmbedder.d.ts +5 -2
  50. package/dist/src/services/OllamaEmbedder.js +9 -2
  51. package/dist/src/services/ProjectDiscoveryService.d.ts +8 -0
  52. package/dist/src/services/ProjectDiscoveryService.js +52 -0
  53. package/dist/src/services/SearcherService.d.ts +39 -6
  54. package/dist/src/services/SearcherService.js +151 -40
  55. package/dist/src/services/WebServerService.d.ts +72 -0
  56. package/dist/src/services/WebServerService.js +247 -0
  57. package/dist/src/setup.js +7 -7
  58. package/dist/src/tools/FindSymbolTool.d.ts +8 -0
  59. package/dist/src/tools/FindSymbolTool.js +45 -0
  60. package/dist/src/tools/GetCallersTool.d.ts +8 -0
  61. package/dist/src/tools/GetCallersTool.js +42 -0
  62. package/dist/src/tools/GetDependenciesTool.d.ts +8 -0
  63. package/dist/src/tools/GetDependenciesTool.js +38 -0
  64. package/dist/src/tools/GetDependentsTool.d.ts +8 -0
  65. package/dist/src/tools/GetDependentsTool.js +57 -0
  66. package/dist/src/tools/ListFilesTool.d.ts +8 -0
  67. package/dist/src/tools/ListFilesTool.js +39 -0
  68. package/dist/src/tools/ListProjectsTool.d.ts +8 -0
  69. package/dist/src/tools/ListProjectsTool.js +44 -0
  70. package/dist/src/tools/ReadFileRangeTool.d.ts +8 -0
  71. package/dist/src/tools/ReadFileRangeTool.js +44 -0
  72. package/dist/src/tools/ReadFileTool.d.ts +8 -0
  73. package/dist/src/tools/ReadFileTool.js +42 -0
  74. package/dist/src/tools/SearchCodebaseTool.d.ts +13 -0
  75. package/dist/src/tools/SearchCodebaseTool.js +81 -0
  76. package/dist/src/tools/format-helpers.d.ts +8 -0
  77. package/dist/src/tools/format-helpers.js +57 -0
  78. package/dist/src/tools/index.d.ts +9 -0
  79. package/dist/src/tools/index.js +21 -0
  80. package/dist/src/web/dashboard.d.ts +9 -0
  81. package/dist/src/web/dashboard.js +337 -0
  82. package/docs/web-dashboard.md +187 -0
  83. package/package.json +5 -2
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.textResponse = textResponse;
4
+ exports.errorResponse = errorResponse;
5
+ exports.formatChunk = formatChunk;
6
+ exports.formatGraphContext = formatGraphContext;
7
+ exports.formatDependencies = formatDependencies;
8
+ exports.formatDependents = formatDependents;
9
+ function textResponse(text) {
10
+ return { content: [{ type: 'text', text }] };
11
+ }
12
+ function errorResponse(text) {
13
+ return { isError: true, content: [{ type: 'text', text }] };
14
+ }
15
+ function formatChunk(r, label) {
16
+ return `${label ? `*(${label})*\n` : ''}**File:** ${r.filePath}\n**Project:** ${r.serviceName}\n**Line:** ${r.startLine}\n\`\`\`\n${r.content}\n\`\`\``;
17
+ }
18
+ function formatGraphContext(ctx) {
19
+ const parts = [];
20
+ if (ctx?.imports.length)
21
+ parts.push(`**Imports:** ${ctx.imports.join(' · ')}`);
22
+ if (ctx?.usedBy.length)
23
+ parts.push(`**Used by:** ${ctx.usedBy.join(' · ')}`);
24
+ return parts.length > 0 ? '\n' + parts.join('\n') : '';
25
+ }
26
+ function formatDependencies(filePath, rows, transitive) {
27
+ const direct = rows.filter(r => !transitive || (r.depth ?? 1) === 1);
28
+ const indirect = transitive ? rows.filter(r => (r.depth ?? 1) > 1) : [];
29
+ const lines = [`**Dependencies of** \`${filePath}\`\n`, '**Direct:**'];
30
+ for (const r of direct) {
31
+ const sym = r.toSymbol ? ` (${r.depType} \`${r.toSymbol}\`)` : ` (${r.depType})`;
32
+ lines.push(` → \`${r.toFile}\`${sym}`);
33
+ }
34
+ if (indirect.length > 0) {
35
+ lines.push('\n**Transitive:**');
36
+ for (const r of indirect) {
37
+ lines.push(` → \`${r.toFile}\` (depth ${r.depth}, via \`${r.via}\`)`);
38
+ }
39
+ }
40
+ return lines.join('\n');
41
+ }
42
+ function formatDependents(filePath, rows, transitive) {
43
+ const direct = rows.filter(r => !transitive || (r.depth ?? 1) === 1);
44
+ const indirect = transitive ? rows.filter(r => (r.depth ?? 1) > 1) : [];
45
+ const lines = [`**Dependents of** \`${filePath}\`\n`, '**Direct:**'];
46
+ for (const r of direct) {
47
+ const sym = r.fromSymbol ? ` (in \`${r.fromSymbol}\`)` : '';
48
+ lines.push(` ← \`${r.fromFile}\`${sym}`);
49
+ }
50
+ if (indirect.length > 0) {
51
+ lines.push('\n**Transitive:**');
52
+ for (const r of indirect) {
53
+ lines.push(` ← \`${r.fromFile}\` (depth ${r.depth}, via \`${r.via}\`)`);
54
+ }
55
+ }
56
+ return lines.join('\n');
57
+ }
@@ -0,0 +1,9 @@
1
+ export { ListProjectsTool } from './ListProjectsTool';
2
+ export { SearchCodebaseTool } from './SearchCodebaseTool';
3
+ export { ReadFileRangeTool } from './ReadFileRangeTool';
4
+ export { GetDependenciesTool } from './GetDependenciesTool';
5
+ export { GetDependentsTool } from './GetDependentsTool';
6
+ export { FindSymbolTool } from './FindSymbolTool';
7
+ export { GetCallersTool } from './GetCallersTool';
8
+ export { ListFilesTool } from './ListFilesTool';
9
+ export { ReadFileTool } from './ReadFileTool';
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ReadFileTool = exports.ListFilesTool = exports.GetCallersTool = exports.FindSymbolTool = exports.GetDependentsTool = exports.GetDependenciesTool = exports.ReadFileRangeTool = exports.SearchCodebaseTool = exports.ListProjectsTool = void 0;
4
+ var ListProjectsTool_1 = require("./ListProjectsTool");
5
+ Object.defineProperty(exports, "ListProjectsTool", { enumerable: true, get: function () { return ListProjectsTool_1.ListProjectsTool; } });
6
+ var SearchCodebaseTool_1 = require("./SearchCodebaseTool");
7
+ Object.defineProperty(exports, "SearchCodebaseTool", { enumerable: true, get: function () { return SearchCodebaseTool_1.SearchCodebaseTool; } });
8
+ var ReadFileRangeTool_1 = require("./ReadFileRangeTool");
9
+ Object.defineProperty(exports, "ReadFileRangeTool", { enumerable: true, get: function () { return ReadFileRangeTool_1.ReadFileRangeTool; } });
10
+ var GetDependenciesTool_1 = require("./GetDependenciesTool");
11
+ Object.defineProperty(exports, "GetDependenciesTool", { enumerable: true, get: function () { return GetDependenciesTool_1.GetDependenciesTool; } });
12
+ var GetDependentsTool_1 = require("./GetDependentsTool");
13
+ Object.defineProperty(exports, "GetDependentsTool", { enumerable: true, get: function () { return GetDependentsTool_1.GetDependentsTool; } });
14
+ var FindSymbolTool_1 = require("./FindSymbolTool");
15
+ Object.defineProperty(exports, "FindSymbolTool", { enumerable: true, get: function () { return FindSymbolTool_1.FindSymbolTool; } });
16
+ var GetCallersTool_1 = require("./GetCallersTool");
17
+ Object.defineProperty(exports, "GetCallersTool", { enumerable: true, get: function () { return GetCallersTool_1.GetCallersTool; } });
18
+ var ListFilesTool_1 = require("./ListFilesTool");
19
+ Object.defineProperty(exports, "ListFilesTool", { enumerable: true, get: function () { return ListFilesTool_1.ListFilesTool; } });
20
+ var ReadFileTool_1 = require("./ReadFileTool");
21
+ Object.defineProperty(exports, "ReadFileTool", { enumerable: true, get: function () { return ReadFileTool_1.ReadFileTool; } });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Renders the RAG dashboard as a single self-contained HTML page (inline CSS +
3
+ * JS), mirroring the approach in graph.ts. The page is schema-driven: it fetches
4
+ * /api/schema (from buildJsonSchema — the same source as Step 1's editor support)
5
+ * and /api/config, then renders a typed form. The Graph tab embeds the full
6
+ * standalone graph page (GET /graph — same buildHtml as project-graph.html) in
7
+ * an iframe rather than re-implementing the network here.
8
+ */
9
+ export declare function renderDashboardHtml(): string;
@@ -0,0 +1,337 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderDashboardHtml = renderDashboardHtml;
4
+ /**
5
+ * Renders the RAG dashboard as a single self-contained HTML page (inline CSS +
6
+ * JS), mirroring the approach in graph.ts. The page is schema-driven: it fetches
7
+ * /api/schema (from buildJsonSchema — the same source as Step 1's editor support)
8
+ * and /api/config, then renders a typed form. The Graph tab embeds the full
9
+ * standalone graph page (GET /graph — same buildHtml as project-graph.html) in
10
+ * an iframe rather than re-implementing the network here.
11
+ */
12
+ function renderDashboardHtml() {
13
+ return `<!DOCTYPE html>
14
+ <html lang="en">
15
+ <head>
16
+ <meta charset="UTF-8">
17
+ <meta name="viewport" content="width=device-width, initial-scale=1">
18
+ <title>RAG Dashboard</title>
19
+ <style>
20
+ * { margin: 0; padding: 0; box-sizing: border-box; }
21
+ body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; height: 100vh; display: flex; flex-direction: column; }
22
+ #header { padding: 12px 20px; background: #16213e; border-bottom: 1px solid #0f3460; display: flex; align-items: center; gap: 24px; flex-shrink: 0; }
23
+ #header h1 { font-size: 16px; font-weight: 600; }
24
+ #tabs { display: flex; gap: 6px; }
25
+ .tab { padding: 6px 14px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: #88aabb; font-size: 13px; cursor: pointer; }
26
+ .tab.active { background: #4A90D9; color: #fff; }
27
+ #main { flex: 1; overflow: auto; padding: 24px; }
28
+ .panel { display: none; max-width: 900px; margin: 0 auto; }
29
+ .panel.active { display: block; }
30
+ /* Graph panel uses the full available width (not the 900px form column). */
31
+ #graph-panel { max-width: none; }
32
+
33
+ /* Config form */
34
+ .section { background: #16213e; border: 1px solid #0f3460; border-radius: 10px; padding: 16px 18px; margin-bottom: 16px; }
35
+ .section h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: #7aa; margin-bottom: 14px; }
36
+ .field { display: grid; grid-template-columns: 220px 1fr; align-items: center; gap: 14px; margin-bottom: 12px; }
37
+ .field:last-child { margin-bottom: 0; }
38
+ .field label { font-size: 13px; color: #cde; }
39
+ .field .desc { display: block; font-size: 11px; color: #667; margin-top: 2px; }
40
+ .field .defval { display: block; font-size: 11px; color: #667; margin-top: 2px; }
41
+ .field .defval.using { color: #c9a24a; } /* amber — this field is currently using the default */
42
+ .field input[type=text], .field input[type=password], .field input[type=number], .field select {
43
+ width: 100%; padding: 7px 9px; border-radius: 6px; border: 1px solid #2a3f6a;
44
+ background: #0f1a33; color: #eee; font-size: 13px; font-family: inherit;
45
+ }
46
+ .field input:focus, .field select:focus { outline: none; border-color: #4A90D9; }
47
+ .field.invalid input, .field.invalid select { border-color: #e0555a; }
48
+ .field input[type=checkbox] { width: 16px; height: 16px; accent-color: #4A90D9; }
49
+ #actions { display: flex; align-items: center; gap: 14px; margin-top: 4px; }
50
+ button.primary { padding: 9px 20px; border-radius: 8px; border: none; background: #4A90D9; color: #fff; font-size: 13px; cursor: pointer; }
51
+ button.primary:hover { background: #5a9fe0; }
52
+ button.primary:disabled { opacity: .5; cursor: default; }
53
+ #banner { display: none; padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-bottom: 16px; white-space: pre-wrap; }
54
+ #banner.err { display: block; background: #3a1a1f; border: 1px solid #e0555a; color: #f0b3b6; }
55
+ #banner.ok { display: block; background: #16332a; border: 1px solid #3fae7a; color: #a8e6c4; }
56
+
57
+ /* Projects */
58
+ #cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
59
+ .card { background: #16213e; border: 1px solid #0f3460; border-radius: 10px; padding: 14px 16px; }
60
+ .card h3 { font-size: 14px; margin-bottom: 4px; }
61
+ .card .chunks { font-size: 11px; color: #4A90D9; margin-bottom: 8px; }
62
+ .card .stack { font-size: 12px; color: #8ab; margin-bottom: 6px; }
63
+ .card .descr { font-size: 12px; color: #99a; line-height: 1.4; }
64
+ .card .rel { font-size: 11px; color: #778; margin-top: 8px; }
65
+ .empty { color: #667; font-size: 13px; padding: 20px 0; }
66
+
67
+ /* Graph — embeds the full standalone graph page (/graph) in an iframe, so
68
+ the filters/legend/project-list sidebar comes for free. A definite height
69
+ is required; a flex/percentage height would collapse the iframe. */
70
+ #graph-frame { width: 100%; height: calc(100vh - 130px); border: 1px solid #0f3460; border-radius: 10px; background: #131a30; display: block; }
71
+ </style>
72
+ </head>
73
+ <body>
74
+ <div id="header">
75
+ <h1>RAG Dashboard</h1>
76
+ <div id="tabs">
77
+ <button class="tab active" data-tab="config">Config</button>
78
+ <button class="tab" data-tab="projects">Projects</button>
79
+ <button class="tab" data-tab="graph">Graph</button>
80
+ </div>
81
+ </div>
82
+ <div id="main">
83
+ <div class="panel active" id="config-panel">
84
+ <div id="banner"></div>
85
+ <form id="config-form"></form>
86
+ <div id="actions">
87
+ <button class="primary" id="save-btn" type="button">Save</button>
88
+ <span id="save-hint" style="font-size:12px;color:#667"></span>
89
+ </div>
90
+ </div>
91
+ <div class="panel" id="projects-panel">
92
+ <div id="cards"></div>
93
+ </div>
94
+ <div class="panel" id="graph-panel">
95
+ <iframe id="graph-frame" title="Project dependency graph"></iframe>
96
+ </div>
97
+ </div>
98
+
99
+ <script>
100
+ const MASK = '********';
101
+ let schema = null;
102
+ let config = null;
103
+ let defaults = {};
104
+ let fieldOptions = {};
105
+ let graphLoaded = false;
106
+
107
+ // ---- tabs ----
108
+ document.querySelectorAll('.tab').forEach(btn => {
109
+ btn.addEventListener('click', () => {
110
+ document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
111
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
112
+ btn.classList.add('active');
113
+ const tab = btn.dataset.tab;
114
+ document.getElementById(tab + '-panel').classList.add('active');
115
+ if (tab === 'projects') loadProjects();
116
+ if (tab === 'graph') loadGraph();
117
+ });
118
+ });
119
+
120
+ // ---- config form ----
121
+ function nested(obj, path) {
122
+ return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
123
+ }
124
+
125
+ function fmtDefault(v) {
126
+ if (v === '') return "'' (empty)";
127
+ return String(v);
128
+ }
129
+
130
+ // A dropdown limited to the given values. Preserves an unknown current value
131
+ // as an extra option so saving never silently drops it.
132
+ function selectEl(key, values, value, def) {
133
+ const sel = document.createElement('select');
134
+ sel.dataset.key = key; sel.dataset.type = 'string';
135
+ const blank = document.createElement('option');
136
+ blank.value = ''; blank.textContent = def !== undefined ? '(default: ' + def + ')' : '(default)';
137
+ sel.appendChild(blank);
138
+ const all = values.slice();
139
+ if (value && !all.includes(value)) all.unshift(value);
140
+ all.forEach(v => {
141
+ const o = document.createElement('option'); o.value = v; o.textContent = v;
142
+ if (value === v) o.selected = true;
143
+ sel.appendChild(o);
144
+ });
145
+ return sel;
146
+ }
147
+
148
+ // Free-text input with a datalist of suggestions (combobox).
149
+ function comboEl(key, values, value, def) {
150
+ const frag = document.createDocumentFragment();
151
+ const inp = document.createElement('input');
152
+ inp.type = 'text'; inp.dataset.key = key; inp.dataset.type = 'string';
153
+ const listId = 'dl-' + key.replace(/\\./g, '-');
154
+ inp.setAttribute('list', listId);
155
+ if (value !== undefined && value !== null) inp.value = value;
156
+ else if (def !== undefined && def !== '') inp.placeholder = String(def);
157
+ const dl = document.createElement('datalist'); dl.id = listId;
158
+ values.forEach(v => { const o = document.createElement('option'); o.value = v; dl.appendChild(o); });
159
+ frag.appendChild(inp); frag.appendChild(dl);
160
+ return frag;
161
+ }
162
+
163
+ function fieldInput(key, spec, value, def) {
164
+ const isUnset = value === undefined || value === null;
165
+ const opt = fieldOptions[key];
166
+ if (opt) return opt.strict ? selectEl(key, opt.values, value, def) : comboEl(key, opt.values, value, def);
167
+ const enumVals = spec.enum;
168
+ if (spec.type === 'boolean') {
169
+ const cb = document.createElement('input');
170
+ cb.type = 'checkbox'; cb.dataset.key = key; cb.dataset.type = 'boolean';
171
+ cb.checked = value === true;
172
+ return cb;
173
+ }
174
+ if (Array.isArray(enumVals)) return selectEl(key, enumVals, value, def);
175
+ const inp = document.createElement('input');
176
+ const isNum = spec.type === 'number' || spec.type === 'integer';
177
+ inp.dataset.key = key;
178
+ inp.dataset.type = isNum ? 'number' : 'string';
179
+ if (key === 'db.password') inp.type = 'password';
180
+ else if (isNum) {
181
+ inp.type = 'number';
182
+ // Only apply small, meaningful bounds (skip zod's safe-integer noise).
183
+ if (typeof spec.minimum === 'number' && Math.abs(spec.minimum) < 1e6) inp.min = spec.minimum;
184
+ if (typeof spec.maximum === 'number' && Math.abs(spec.maximum) < 1e6) inp.max = spec.maximum;
185
+ if (spec.maximum === 1) inp.step = '0.05';
186
+ } else inp.type = 'text';
187
+ if (!isUnset) inp.value = value;
188
+ // Show the applied default as placeholder when the field is left unset.
189
+ else if (def !== undefined && def !== '') inp.placeholder = String(def);
190
+ return inp;
191
+ }
192
+
193
+ function renderField(container, key, spec, value, def) {
194
+ const isUnset = value === undefined || value === null;
195
+ const wrap = document.createElement('div');
196
+ wrap.className = 'field'; wrap.dataset.fieldKey = key;
197
+ const label = document.createElement('label');
198
+ let html = key.split('.').pop();
199
+ if (spec.description) html += '<span class="desc">' + spec.description + '</span>';
200
+ if (def !== undefined) {
201
+ html += '<span class="defval' + (isUnset ? ' using' : '') + '">'
202
+ + (isUnset ? '↪ using default: ' : 'default: ') + fmtDefault(def) + '</span>';
203
+ }
204
+ label.innerHTML = html;
205
+ wrap.appendChild(label);
206
+ wrap.appendChild(fieldInput(key, spec, value, def));
207
+ container.appendChild(wrap);
208
+ }
209
+
210
+ function renderForm() {
211
+ const form = document.getElementById('config-form');
212
+ form.innerHTML = '';
213
+ const props = schema.properties || {};
214
+ for (const [group, gspec] of Object.entries(props)) {
215
+ const sec = document.createElement('div'); sec.className = 'section';
216
+ const h = document.createElement('h2'); h.textContent = group; sec.appendChild(h);
217
+ if (gspec.type === 'object' && gspec.properties) {
218
+ for (const [field, fspec] of Object.entries(gspec.properties)) {
219
+ const key = group + '.' + field;
220
+ renderField(sec, key, fspec, nested(config, key), defaults[key]);
221
+ }
222
+ } else {
223
+ renderField(sec, group, gspec, nested(config, group), defaults[group]);
224
+ }
225
+ form.appendChild(sec);
226
+ }
227
+ }
228
+
229
+ function collectForm() {
230
+ const out = {};
231
+ document.querySelectorAll('[data-key]').forEach(el => {
232
+ const key = el.dataset.key, type = el.dataset.type;
233
+ let val;
234
+ if (type === 'boolean') val = el.checked;
235
+ else if (el.value === '') return; // unset → omit (stay minimal)
236
+ else if (type === 'number') { const n = Number(el.value); if (Number.isNaN(n)) return; val = n; }
237
+ else val = el.value;
238
+ const parts = key.split('.');
239
+ if (parts.length === 1) out[parts[0]] = val;
240
+ else { (out[parts[0]] = out[parts[0]] || {})[parts[1]] = val; }
241
+ });
242
+ return out;
243
+ }
244
+
245
+ function showBanner(cls, msg) {
246
+ const b = document.getElementById('banner');
247
+ b.className = cls; b.textContent = msg;
248
+ }
249
+ function clearInvalid() {
250
+ document.querySelectorAll('.field.invalid').forEach(f => f.classList.remove('invalid'));
251
+ }
252
+
253
+ async function save() {
254
+ clearInvalid();
255
+ const btn = document.getElementById('save-btn');
256
+ btn.disabled = true;
257
+ try {
258
+ const res = await fetch('/api/config', {
259
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
260
+ body: JSON.stringify(collectForm()),
261
+ });
262
+ const data = await res.json();
263
+ if (res.ok) {
264
+ showBanner('ok', 'Saved.');
265
+ await loadConfig(); // refresh (re-mask password, normalize)
266
+ } else {
267
+ showBanner('err', data.error || 'Validation failed');
268
+ // best-effort inline highlight: mark fields whose key appears in the message
269
+ document.querySelectorAll('[data-field-key]').forEach(f => {
270
+ if ((data.error || '').includes(f.dataset.fieldKey)) f.classList.add('invalid');
271
+ });
272
+ }
273
+ } catch (e) {
274
+ showBanner('err', 'Request failed: ' + e.message);
275
+ } finally {
276
+ btn.disabled = false;
277
+ }
278
+ }
279
+ document.getElementById('save-btn').addEventListener('click', save);
280
+
281
+ async function loadConfig() {
282
+ [schema, config, defaults, fieldOptions] = await Promise.all([
283
+ fetch('/api/schema').then(r => r.json()),
284
+ fetch('/api/config').then(r => r.json()),
285
+ fetch('/api/defaults').then(r => r.json()),
286
+ fetch('/api/field-options').then(r => r.json()),
287
+ ]);
288
+ config = config || {};
289
+ defaults = defaults || {};
290
+ fieldOptions = fieldOptions || {};
291
+ renderForm();
292
+ }
293
+
294
+ // ---- projects ----
295
+ async function loadProjects() {
296
+ const cards = document.getElementById('cards');
297
+ cards.innerHTML = '<div class="empty">Loading…</div>';
298
+ try {
299
+ const projects = await fetch('/api/projects').then(r => r.json());
300
+ if (!projects.length) { cards.innerHTML = '<div class="empty">No indexed projects.</div>'; return; }
301
+ cards.innerHTML = '';
302
+ projects.forEach(p => {
303
+ const rel = p.relations ? Object.entries(p.relations)
304
+ .map(([k, v]) => k + ': ' + (Array.isArray(v) ? v.join(', ') : v)).join(' · ') : '';
305
+ const card = document.createElement('div'); card.className = 'card';
306
+ card.innerHTML =
307
+ '<h3></h3><div class="chunks"></div>' +
308
+ (p.stack ? '<div class="stack"></div>' : '') +
309
+ (p.description ? '<div class="descr"></div>' : '') +
310
+ (rel ? '<div class="rel"></div>' : '');
311
+ card.querySelector('h3').textContent = p.serviceName;
312
+ card.querySelector('.chunks').textContent = p.chunks + ' chunks';
313
+ if (p.stack) card.querySelector('.stack').textContent = p.stack;
314
+ if (p.description) card.querySelector('.descr').textContent = p.description;
315
+ if (rel) card.querySelector('.rel').textContent = rel;
316
+ cards.appendChild(card);
317
+ });
318
+ } catch (e) {
319
+ cards.innerHTML = '<div class="empty">Failed to load projects: ' + e.message + '</div>';
320
+ }
321
+ }
322
+
323
+ // ---- graph ----
324
+ // Reuse the full standalone graph page (same HTML as project-graph.html) via
325
+ // an iframe instead of re-implementing the network here. Loaded lazily on the
326
+ // first Graph-tab open so the graph page isn't fetched until needed.
327
+ function loadGraph() {
328
+ if (graphLoaded) return;
329
+ graphLoaded = true;
330
+ document.getElementById('graph-frame').src = '/graph';
331
+ }
332
+
333
+ loadConfig().catch(e => showBanner('err', 'Failed to load config: ' + e.message));
334
+ </script>
335
+ </body>
336
+ </html>`;
337
+ }
@@ -0,0 +1,187 @@
1
+ # Web dashboard (`rag-web`)
2
+
3
+ A local web UI for configuring and inspecting the RAG server. It provides a
4
+ schema-driven config editor, an overview of indexed projects, and an interactive
5
+ project-dependency graph — all served from a small built-in HTTP server with no
6
+ external framework and no new dependencies (`node:http`).
7
+
8
+ The dashboard is a thin presentation layer over primitives that already exist:
9
+ config I/O and validation (`src/config-store.ts`), project data
10
+ (`SearcherService.listProjects`), and the graph builder (`src/graph.ts`). It adds
11
+ no business logic of its own beyond request routing and a password-preservation
12
+ merge.
13
+
14
+ ---
15
+
16
+ ## Running
17
+
18
+ ```bash
19
+ rag-web # http://127.0.0.1:4319
20
+ rag-web --port 8080 # custom port
21
+ npm run start:web # dev mode (ts-node, no build)
22
+ ```
23
+
24
+ If the `rag-web` binary is not on your `PATH`, either run it from the project
25
+ (`node dist/app.js web`) or register it once with `npm link`.
26
+
27
+ Keep the terminal open — the server runs until you press `Ctrl+C`. The browser
28
+ can only reach the dashboard while the process is alive.
29
+
30
+ ---
31
+
32
+ ## Security model
33
+
34
+ - **Binds to `127.0.0.1` only.** The server is never exposed on the network;
35
+ it is a single-user local dev tool, so there is no authentication.
36
+ - **The DB password is never sent to the browser.** `GET /api/config` returns it
37
+ masked as `********`. On save, if the field is still the mask, the stored
38
+ password is preserved (`mergeConfigForSave`); a genuinely changed value is
39
+ written through.
40
+ - **No arbitrary file serving.** Only the known routes below respond; there is no
41
+ static file handler, so no path-traversal surface.
42
+
43
+ ---
44
+
45
+ ## Requirements & failure modes
46
+
47
+ - Requires a valid `config.json` — `rag-web` goes through the normal container
48
+ bootstrap and `workspaceRoot` guard. To repair a broken config, use
49
+ `rag-config edit` (CLI) instead.
50
+ - A failed **DB** connection is non-fatal: the config editor still works; the
51
+ Projects and Graph tabs show an in-page error.
52
+ - A failed **Ollama** connection only affects model suggestions (see
53
+ *Field options*); everything else works.
54
+
55
+ ---
56
+
57
+ ## UI tabs
58
+
59
+ ### Config
60
+ A form generated from the config JSON Schema (`buildJsonSchema()` — the same
61
+ schema that powers editor autocomplete on `config.json`). Fields are grouped by
62
+ section and typed automatically:
63
+
64
+ | Schema shape | Rendered as |
65
+ |---|---|
66
+ | `boolean` | checkbox |
67
+ | `enum` (e.g. `logging.level`) | `<select>` |
68
+ | `integer` / `number` | number input (with sensible `min`/`max`/`step`) |
69
+ | `string` | text input |
70
+ | field in `/api/field-options`, `strict` | `<select>` limited to the list |
71
+ | field in `/api/field-options`, non-strict | text input + `<datalist>` (combobox) |
72
+
73
+ Two affordances make the effective configuration obvious:
74
+
75
+ - **Defaults.** Any key left unset shows `↪ using default: <value>` (amber) and
76
+ the applied value as the input placeholder. A key that *is* set shows a muted
77
+ `default: <value>` for reference. Source: `GET /api/defaults`
78
+ (`CONFIG_DEFAULTS`).
79
+ - **Field options.** Fields whose valid values are constrained get a dropdown
80
+ instead of free text. Source: `GET /api/field-options`. See below.
81
+
82
+ Saving validates through `parseConfig` and, on failure, shows the validation
83
+ message and highlights the offending fields; on success the config is written
84
+ (with the `$schema` reference preserved) and the form reloads.
85
+
86
+ ### Projects
87
+ Cards for each indexed project from `SearcherService.listProjects()` — name,
88
+ chunk count, and any `.ragmeta.json` metadata (stack, description, relations).
89
+
90
+ ### Graph
91
+ The **full standalone graph page embedded in an `<iframe>`** (`src="/graph"`),
92
+ loaded lazily on first open. `GET /graph` serves the exact same HTML as the
93
+ generated `project-graph.html` (`buildHtml()` in `src/graph.ts`), so the whole
94
+ sidebar comes for free: the node-color legend, the edge-type filter buttons, and
95
+ the project toggle list, plus hover highlighting and auto-fit. The dashboard
96
+ does **not** re-implement vis-network — reusing one page keeps the two graph
97
+ views identical. `GET /api/graph` still returns the raw `{ nodes, edges }` JSON
98
+ (`buildGraphData()`) for programmatic use.
99
+
100
+ ---
101
+
102
+ ## Field options (model dropdowns)
103
+
104
+ Some settings have a constrained set of valid values, so the form offers a list
105
+ rather than free text. `GET /api/field-options` returns
106
+ `{ [dottedKey]: { values: string[], strict: boolean } }`:
107
+
108
+ - **`ollama.model` — strict.** The embedding model is tied to the vector column
109
+ dimension (`bge-m3` → 1024). Values come from `SUPPORTED_EMBED_MODELS`
110
+ (`src/constants.ts`); the dropdown does **not** allow free text, so a
111
+ dimension-incompatible model cannot be set. To add a model, confirm its
112
+ dimension matches and append it to `SUPPORTED_EMBED_MODELS`.
113
+ - **`search.translateModel`, `reranker.model` — non-strict.** Suggestions are the
114
+ user's installed Ollama models (`ollama.list()`), but free text is allowed
115
+ because these are not dimension-bound and the user may pull a new model later.
116
+ If Ollama is unreachable the list is empty and the field degrades to plain
117
+ text.
118
+
119
+ A strict `<select>` preserves an unknown current value as an extra option, so
120
+ saving never silently drops a value that isn't in the list.
121
+
122
+ ---
123
+
124
+ ## HTTP API
125
+
126
+ All routes are served from `127.0.0.1:<port>`.
127
+
128
+ | Method | Path | Response |
129
+ |---|---|---|
130
+ | `GET` | `/` | Dashboard HTML (self-contained) |
131
+ | `GET` | `/api/schema` | JSON Schema of the config (`buildJsonSchema()`) |
132
+ | `GET` | `/api/defaults` | `CONFIG_DEFAULTS` — dotted key → default value |
133
+ | `GET` | `/api/field-options` | dotted key → `{ values, strict }` |
134
+ | `GET` | `/api/config` | Current config, `db.password` masked |
135
+ | `PUT` | `/api/config` | Save. Body = config JSON. `200 {ok:true}` or `400 {error}` |
136
+ | `GET` | `/api/projects` | `SearcherService.listProjects()` |
137
+ | `GET` | `/api/graph` | `{ nodes, edges }` JSON (`buildGraphData()`) |
138
+ | `GET` | `/graph` | Full interactive graph page (`buildHtml()`), embedded by the Graph tab's iframe |
139
+
140
+ ---
141
+
142
+ ## Architecture
143
+
144
+ | File | Responsibility |
145
+ |---|---|
146
+ | `src/commands/WebCommand.ts` | CLI entry. `db.initDb()`, starts the server, keeps `execute()` pending until `SIGINT`/`SIGTERM` (mirrors `McpCommand`), then `server.stop()`. Parses `--port` (default `4319`). |
147
+ | `src/services/WebServerService.ts` | `WebServer(searcher, config, logger)` — `node:http` server, routing, and the API handlers. Exports the pure `mergeConfigForSave()` and the `FieldOption` type. |
148
+ | `src/web/dashboard.ts` | `renderDashboardHtml()` — the entire page (HTML + CSS + JS) as one self-contained string, mirroring `graph.ts:buildHtml`. |
149
+
150
+ Reused primitives (no duplication):
151
+
152
+ - `readConfigJson` / `parseConfig` / `writeConfigJson` / `buildJsonSchema` /
153
+ `CONFIG_DEFAULTS` — `src/config-store.ts`
154
+ - `buildGraphData` — `src/graph.ts`
155
+ - `listProjects` — `src/services/SearcherService.ts`
156
+ - `SUPPORTED_EMBED_MODELS` — `src/constants.ts`
157
+
158
+ Wiring: `app.ts` detects the `web` mode and dispatches
159
+ `new WebCommand(container, effectiveArgs)`. The command uses the shared
160
+ `Container` services (`db`, `searcher`, `config`, `logger`) — no container
161
+ changes were needed.
162
+
163
+ ---
164
+
165
+ ## Extending
166
+
167
+ - **Constrain another field to a list** — add an entry to `fieldOptions()` in
168
+ `WebServerService.ts` (`{ values, strict }`). The form renders it automatically
169
+ (strict → dropdown, non-strict → combobox).
170
+ - **Add a tab / endpoint** — add a route in `WebServer.handle`, a fetch + render
171
+ in `dashboard.ts`, and a tab button. Keep handlers thin — delegate to a service.
172
+ - **Actions (e.g. trigger reindex)** — deferred by design. This is heavier (a
173
+ long-running process with live progress, likely via SSE, plus concurrency
174
+ control) and belongs in its own change rather than the read/config-edit shell.
175
+
176
+ ---
177
+
178
+ ## Keep in sync
179
+
180
+ - `CONFIG_DEFAULTS` (`config-store.ts`) is the **single source of truth** for
181
+ built-in defaults: `resolvedSchema` in `config.ts` references it in its
182
+ `.default(...)` calls, so the runtime default and the dashboard-displayed
183
+ default cannot diverge — no manual sync needed. (A unit test still asserts
184
+ every key is a valid config key.)
185
+ - `SUPPORTED_EMBED_MODELS` (`constants.ts`) must contain only models whose
186
+ embedding dimension matches the `vector(...)` column — this one is still a
187
+ manual invariant, guarded only at the membership level by a unit test.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codesearch-mcp",
3
- "version": "0.0.17-alpha",
3
+ "version": "0.0.18-alpha",
4
4
  "description": "Local semantic codebase search via MCP — indexes projects into pgvector, exposes search to Claude",
5
5
  "license": "MIT",
6
6
  "main": "dist/app.js",
@@ -8,11 +8,13 @@
8
8
  "rag-index": "dist/app.js",
9
9
  "rag-mcp": "dist/app.js",
10
10
  "rag-setup": "dist/app.js",
11
- "rag-config": "dist/app.js"
11
+ "rag-config": "dist/app.js",
12
+ "rag-web": "dist/app.js"
12
13
  },
13
14
  "files": [
14
15
  "dist/",
15
16
  "docker/",
17
+ "docs/",
16
18
  "README.md",
17
19
  "CHANGELOG.md"
18
20
  ],
@@ -21,6 +23,7 @@
21
23
  "postbuild": "node -e \"const fs=require('fs');const f='dist/app.js';fs.writeFileSync(f,'#!/usr/bin/env node\\n'+fs.readFileSync(f,'utf8'));fs.chmodSync(f,0o755);\"",
22
24
  "prepublishOnly": "npm run build && npm run typecheck",
23
25
  "start:mcp": "ts-node app.ts mcp",
26
+ "start:web": "ts-node app.ts web",
24
27
  "index:workspace": "ts-node app.ts index",
25
28
  "index:reindex": "ts-node app.ts index --reindex",
26
29
  "index:backfill": "ts-node app.ts backfill",