wicked-brain 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1096 @@
1
+ /**
2
+ * Read-only HTML viewer for a wicked-brain instance.
3
+ *
4
+ * Single self-contained page — no framework, no build, no new dependencies.
5
+ * Styled after Material Design: AppBar, Drawer, Cards, Chips, elevation.
6
+ * Calls the server's existing `POST /api` endpoint for data.
7
+ *
8
+ * The returned HTML embeds the brain id for display; everything else is
9
+ * dynamic via fetch at runtime.
10
+ */
11
+
12
+ export function renderViewerHtml({ brainId = "brain" } = {}) {
13
+ const safeBrainId = String(brainId).replace(/[<>&"]/g, (c) =>
14
+ ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" }[c] ?? c));
15
+ return `<!doctype html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <title>${safeBrainId} · wicked-brain</title>
21
+ <style>${STYLES}</style>
22
+ </head>
23
+ <body>
24
+ <header class="app-bar">
25
+ <div class="app-bar-inner">
26
+ <div class="app-title">
27
+ <span class="app-icon">&#9788;</span>
28
+ <span class="app-title-text">${safeBrainId}</span>
29
+ </div>
30
+ <div class="app-bar-spacer"></div>
31
+ <div id="stats-chip" class="chip chip-outline" title="Indexed documents"><span id="stats-text">loading…</span></div>
32
+ <button id="btn-reonboard" class="app-bar-btn" type="button" title="Re-run onboarding (re-detect mode, re-index from disk)" aria-label="Re-run onboarding">
33
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17.65 6.35A7.958 7.958 0 0 0 12 4a8 8 0 1 0 7.73 10h-2.08A6 6 0 1 1 12 6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
34
+ </button>
35
+ <button id="btn-purge" class="app-bar-btn app-bar-btn-danger" type="button" title="Delete all content in this brain" aria-label="Delete all content">
36
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
37
+ </button>
38
+ </div>
39
+ </header>
40
+ <div id="snack" class="snack" role="status" aria-live="polite"></div>
41
+ <div class="tabs" role="tablist">
42
+ <button type="button" class="tab active" role="tab" aria-selected="true" data-tab="search">Search</button>
43
+ <button type="button" class="tab" role="tab" aria-selected="false" data-tab="wiki">Wiki</button>
44
+ </div>
45
+ <main class="content" id="content">
46
+ <section id="search-tab" class="tab-panel" role="tabpanel" aria-labelledby="Search">
47
+ <div class="content-toolbar">
48
+ <form id="search-form" class="search-field" role="search">
49
+ <svg viewBox="0 0 24 24" class="search-icon" aria-hidden="true"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zM9.5 14C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
50
+ <input id="search-input" type="search" placeholder="Search the brain…" autocomplete="off" spellcheck="false">
51
+ <button type="button" id="clear-btn" class="clear-btn" aria-label="Clear">&times;</button>
52
+ </form>
53
+ <ul class="filter-list" id="type-filters">
54
+ <li><label class="filter-row"><input type="checkbox" data-type="wiki" checked> <span class="chip chip-wiki">wiki</span></label></li>
55
+ <li><label class="filter-row"><input type="checkbox" data-type="chunk" checked> <span class="chip chip-chunk">chunk</span></label></li>
56
+ <li><label class="filter-row"><input type="checkbox" data-type="memory" checked> <span class="chip chip-memory">memory</span></label></li>
57
+ </ul>
58
+ </div>
59
+ <div id="results-list" class="results-list"></div>
60
+ <div id="results-empty" class="empty-state hidden">
61
+ <div class="empty-icon">&#9740;</div>
62
+ <div class="empty-text">Nothing to show yet.</div>
63
+ </div>
64
+ <div id="results-meta" class="results-footer"></div>
65
+ </section>
66
+ <section id="wiki-tab" class="tab-panel hidden" role="tabpanel" aria-labelledby="Wiki">
67
+ <div class="wiki-header">
68
+ <div class="wiki-title">Wiki articles</div>
69
+ <div class="wiki-count" id="wiki-count"></div>
70
+ </div>
71
+ <div id="wiki-cards" class="wiki-cards"><div class="muted">loading…</div></div>
72
+ </section>
73
+ <section id="doc-view" class="tab-panel hidden">
74
+ <div class="view-header">
75
+ <button id="back-btn" class="icon-btn" aria-label="Back">&#8592;</button>
76
+ <div class="view-title" id="doc-title">Document</div>
77
+ <div class="view-meta" id="doc-meta"></div>
78
+ </div>
79
+ <div id="doc-chips" class="doc-chips"></div>
80
+ <article id="doc-body" class="doc-body"></article>
81
+ </section>
82
+ </main>
83
+ <script>${CLIENT_JS}</script>
84
+ </body>
85
+ </html>
86
+ `;
87
+ }
88
+
89
+ // --- Styles (Material-inspired, hand-rolled — no external CSS) ---
90
+
91
+ const STYLES = `
92
+ :root {
93
+ --primary: #1976d2;
94
+ --primary-dark: #115293;
95
+ --primary-contrast: #ffffff;
96
+ --surface: #ffffff;
97
+ --bg: #f5f5f5;
98
+ --divider: rgba(0,0,0,0.12);
99
+ --text-primary: rgba(0,0,0,0.87);
100
+ --text-secondary: rgba(0,0,0,0.6);
101
+ --text-disabled: rgba(0,0,0,0.38);
102
+ --hover: rgba(0,0,0,0.04);
103
+ --selected: rgba(25,118,210,0.08);
104
+ --chip-wiki-bg: #e3f2fd;
105
+ --chip-wiki-fg: #0d47a1;
106
+ --chip-chunk-bg: #f3e5f5;
107
+ --chip-chunk-fg: #4a148c;
108
+ --chip-memory-bg: #fff3e0;
109
+ --chip-memory-fg: #e65100;
110
+ --chip-canonical-bg: #e8f5e9;
111
+ --chip-canonical-fg: #1b5e20;
112
+ --elev-1: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
113
+ --elev-2: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
114
+ --app-bar-h: 56px;
115
+ --drawer-w: 300px;
116
+ }
117
+
118
+ * { box-sizing: border-box; }
119
+ html, body { margin: 0; padding: 0; height: 100%; }
120
+ body {
121
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
122
+ "Helvetica Neue", Arial, sans-serif;
123
+ font-size: 14px;
124
+ line-height: 1.5;
125
+ color: var(--text-primary);
126
+ background: var(--bg);
127
+ }
128
+
129
+ /* AppBar */
130
+ .app-bar {
131
+ position: fixed;
132
+ top: 0; left: 0; right: 0;
133
+ height: var(--app-bar-h);
134
+ background: var(--primary);
135
+ color: var(--primary-contrast);
136
+ box-shadow: var(--elev-2);
137
+ z-index: 10;
138
+ }
139
+ .app-bar-inner {
140
+ height: 100%;
141
+ display: flex;
142
+ align-items: center;
143
+ padding: 0 16px;
144
+ gap: 16px;
145
+ }
146
+ .app-title { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
147
+ .app-icon { font-size: 20px; }
148
+ .app-title-text { font-size: 18px; font-weight: 500; letter-spacing: 0.15px; }
149
+ .app-bar-spacer { flex: 1; }
150
+
151
+ /* Main-content toolbar (search + filters) */
152
+ .content-toolbar {
153
+ padding: 16px 0 16px;
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 16px;
157
+ flex-wrap: wrap;
158
+ margin-bottom: 16px;
159
+ }
160
+ .search-field {
161
+ flex: 1 1 320px;
162
+ min-width: 240px;
163
+ display: flex;
164
+ align-items: center;
165
+ background: var(--surface);
166
+ border: 1px solid var(--divider);
167
+ border-radius: 4px;
168
+ padding: 0 8px 0 12px;
169
+ height: 40px;
170
+ transition: border-color 120ms, box-shadow 120ms;
171
+ }
172
+ .search-field:hover { border-color: rgba(0,0,0,0.3); }
173
+ .search-field:focus-within {
174
+ border-color: var(--primary);
175
+ box-shadow: 0 0 0 1px var(--primary);
176
+ }
177
+ .search-icon { width: 20px; height: 20px; fill: var(--text-secondary); margin-right: 8px; }
178
+ #search-input {
179
+ flex: 1;
180
+ background: transparent;
181
+ border: none;
182
+ outline: none;
183
+ color: var(--text-primary);
184
+ font: inherit;
185
+ padding: 8px 0;
186
+ }
187
+ #search-input::placeholder { color: var(--text-disabled); }
188
+ .clear-btn {
189
+ background: transparent;
190
+ border: none;
191
+ color: var(--text-secondary);
192
+ font-size: 20px;
193
+ line-height: 1;
194
+ cursor: pointer;
195
+ padding: 4px 8px;
196
+ border-radius: 50%;
197
+ opacity: 0.6;
198
+ visibility: hidden;
199
+ }
200
+ .clear-btn:hover { opacity: 1; background: var(--hover); }
201
+ .search-field.has-value .clear-btn { visibility: visible; }
202
+
203
+ /* Chip */
204
+ .chip {
205
+ display: inline-flex;
206
+ align-items: center;
207
+ height: 24px;
208
+ padding: 0 10px;
209
+ border-radius: 12px;
210
+ font-size: 12px;
211
+ font-weight: 500;
212
+ letter-spacing: 0.3px;
213
+ white-space: nowrap;
214
+ background: rgba(0,0,0,0.08);
215
+ color: var(--text-primary);
216
+ }
217
+ .chip-outline {
218
+ background: transparent;
219
+ border: 1px solid rgba(255,255,255,0.5);
220
+ color: rgba(255,255,255,0.95);
221
+ }
222
+ .chip-wiki { background: var(--chip-wiki-bg); color: var(--chip-wiki-fg); }
223
+ .chip-chunk { background: var(--chip-chunk-bg); color: var(--chip-chunk-fg); }
224
+ .chip-memory { background: var(--chip-memory-bg); color: var(--chip-memory-fg); }
225
+ .chip-canonical { background: var(--chip-canonical-bg); color: var(--chip-canonical-fg); }
226
+
227
+ /* Layout (full-width single column, no drawer) */
228
+ .content {
229
+ padding: 16px 32px 48px;
230
+ max-width: 960px;
231
+ margin: 0 auto;
232
+ }
233
+
234
+ /* Tabs (MUI-style) */
235
+ .tabs {
236
+ position: fixed;
237
+ top: var(--app-bar-h);
238
+ left: 0;
239
+ right: 0;
240
+ height: 48px;
241
+ background: var(--surface);
242
+ border-bottom: 1px solid var(--divider);
243
+ box-shadow: 0 1px 2px rgba(0,0,0,0.08);
244
+ display: flex;
245
+ justify-content: center;
246
+ gap: 8px;
247
+ z-index: 9;
248
+ padding: 0 16px;
249
+ }
250
+ .tab {
251
+ appearance: none;
252
+ background: transparent;
253
+ border: none;
254
+ border-bottom: 2px solid transparent;
255
+ color: var(--text-secondary);
256
+ font: inherit;
257
+ font-size: 13px;
258
+ font-weight: 500;
259
+ letter-spacing: 0.5px;
260
+ text-transform: uppercase;
261
+ padding: 0 20px;
262
+ height: 48px;
263
+ min-width: 120px;
264
+ cursor: pointer;
265
+ transition: color 120ms, border-color 120ms, background 120ms;
266
+ }
267
+ .tab:hover { background: var(--hover); color: var(--text-primary); }
268
+ .tab.active {
269
+ color: var(--primary);
270
+ border-bottom-color: var(--primary);
271
+ }
272
+ body { padding-top: calc(var(--app-bar-h) + 48px); }
273
+
274
+ /* Filters (inline chip row at the top of Search tab) */
275
+ .filter-list {
276
+ list-style: none;
277
+ margin: 0;
278
+ padding: 0;
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 4px;
282
+ flex-wrap: wrap;
283
+ }
284
+ .filter-list li { margin: 0; }
285
+ .filter-row {
286
+ display: inline-flex;
287
+ align-items: center;
288
+ gap: 6px;
289
+ padding: 4px 10px;
290
+ cursor: pointer;
291
+ border-radius: 16px;
292
+ transition: background 120ms;
293
+ }
294
+ .filter-row:hover { background: var(--hover); }
295
+ .filter-row input { accent-color: var(--primary); margin: 0; }
296
+
297
+ /* Wiki cards (Wiki tab) */
298
+ .wiki-header {
299
+ display: flex;
300
+ align-items: baseline;
301
+ gap: 12px;
302
+ margin: 16px 0 20px;
303
+ }
304
+ .wiki-title { font-size: 20px; font-weight: 500; color: var(--text-primary); }
305
+ .wiki-count { color: var(--text-secondary); font-size: 13px; }
306
+ .wiki-cards {
307
+ display: grid;
308
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
309
+ gap: 12px;
310
+ }
311
+ .wiki-card {
312
+ background: var(--surface);
313
+ border-radius: 8px;
314
+ padding: 14px 16px;
315
+ box-shadow: var(--elev-1);
316
+ cursor: pointer;
317
+ transition: box-shadow 120ms, transform 120ms;
318
+ display: flex;
319
+ flex-direction: column;
320
+ gap: 6px;
321
+ }
322
+ .wiki-card:hover { box-shadow: var(--elev-2); }
323
+ .wiki-card:active { transform: translateY(1px); }
324
+ .wiki-card-title { font-weight: 500; font-size: 15px; color: var(--text-primary); }
325
+ .wiki-card-desc { font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
326
+ .wiki-card-meta {
327
+ display: flex;
328
+ gap: 6px;
329
+ flex-wrap: wrap;
330
+ margin-top: 4px;
331
+ }
332
+ .wiki-card-path {
333
+ font-family: "SF Mono", Menlo, Consolas, monospace;
334
+ font-size: 11px;
335
+ color: var(--text-disabled);
336
+ word-break: break-all;
337
+ }
338
+
339
+ /* Tab panels */
340
+ .tab-panel { animation: fade 160ms ease; }
341
+ @keyframes fade { from { opacity: 0; } to { opacity: 1; } }
342
+
343
+ .results-footer { color: var(--text-secondary); font-size: 13px; margin-top: 16px; }
344
+
345
+ /* Main view */
346
+ .view-header {
347
+ display: flex;
348
+ align-items: center;
349
+ gap: 12px;
350
+ margin-bottom: 16px;
351
+ }
352
+ .view-title {
353
+ font-size: 20px;
354
+ font-weight: 500;
355
+ color: var(--text-primary);
356
+ flex: 1;
357
+ word-break: break-word;
358
+ }
359
+ .view-meta { color: var(--text-secondary); font-size: 13px; }
360
+ .icon-btn {
361
+ background: transparent;
362
+ border: none;
363
+ color: var(--text-primary);
364
+ width: 36px; height: 36px;
365
+ border-radius: 50%;
366
+ cursor: pointer;
367
+ font-size: 20px;
368
+ display: inline-flex;
369
+ align-items: center;
370
+ justify-content: center;
371
+ transition: background 120ms;
372
+ }
373
+ .icon-btn:hover { background: var(--hover); }
374
+
375
+ .results-list { display: flex; flex-direction: column; gap: 12px; }
376
+ .result-card {
377
+ background: var(--surface);
378
+ border-radius: 8px;
379
+ padding: 16px 20px;
380
+ box-shadow: var(--elev-1);
381
+ cursor: pointer;
382
+ transition: box-shadow 120ms, transform 120ms;
383
+ }
384
+ .result-card:hover { box-shadow: var(--elev-2); }
385
+ .result-card:active { transform: translateY(1px); }
386
+ .result-top {
387
+ display: flex;
388
+ align-items: center;
389
+ gap: 8px;
390
+ margin-bottom: 6px;
391
+ flex-wrap: wrap;
392
+ }
393
+ .result-path {
394
+ font-family: "SF Mono", Menlo, Consolas, monospace;
395
+ font-size: 13px;
396
+ color: var(--text-primary);
397
+ font-weight: 500;
398
+ flex: 1;
399
+ word-break: break-all;
400
+ }
401
+ .result-score {
402
+ color: var(--text-disabled);
403
+ font-size: 11px;
404
+ font-family: monospace;
405
+ }
406
+ .result-snippet {
407
+ font-size: 13px;
408
+ color: var(--text-secondary);
409
+ margin: 4px 0 0;
410
+ line-height: 1.55;
411
+ }
412
+ .result-snippet b { background: #fff59d; color: var(--text-primary); padding: 0 2px; border-radius: 2px; }
413
+ .result-also { margin-top: 10px; padding-top: 8px; border-top: 1px dashed var(--divider); font-size: 12px; color: var(--text-secondary); }
414
+ .result-also-label { font-weight: 500; color: var(--text-primary); margin-right: 6px; }
415
+ .result-also-item { margin-right: 10px; font-family: monospace; }
416
+
417
+ /* Doc view */
418
+ .doc-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 20px; }
419
+ .doc-body {
420
+ background: var(--surface);
421
+ border-radius: 8px;
422
+ padding: 32px 40px;
423
+ box-shadow: var(--elev-1);
424
+ font-size: 15px;
425
+ line-height: 1.7;
426
+ }
427
+ .doc-body h1, .doc-body h2, .doc-body h3, .doc-body h4 {
428
+ font-weight: 500;
429
+ color: var(--text-primary);
430
+ line-height: 1.3;
431
+ margin-top: 1.6em;
432
+ margin-bottom: 0.5em;
433
+ }
434
+ .doc-body h1 { font-size: 24px; }
435
+ .doc-body h2 { font-size: 20px; border-bottom: 1px solid var(--divider); padding-bottom: 6px; }
436
+ .doc-body h3 { font-size: 17px; }
437
+ .doc-body h4 { font-size: 15px; color: var(--text-secondary); }
438
+ .doc-body p { margin: 0 0 1em; }
439
+ .doc-body ul, .doc-body ol { padding-left: 1.4em; margin: 0 0 1em; }
440
+ .doc-body li { margin: 0.25em 0; }
441
+ .doc-body code {
442
+ font-family: "SF Mono", Menlo, Consolas, monospace;
443
+ font-size: 0.9em;
444
+ background: rgba(0,0,0,0.05);
445
+ padding: 1px 5px;
446
+ border-radius: 3px;
447
+ }
448
+ .doc-body pre {
449
+ background: #263238;
450
+ color: #eceff1;
451
+ padding: 14px 18px;
452
+ border-radius: 6px;
453
+ overflow-x: auto;
454
+ font-size: 13px;
455
+ line-height: 1.55;
456
+ margin: 0 0 1em;
457
+ }
458
+ .doc-body pre code { background: transparent; padding: 0; color: inherit; font-size: inherit; }
459
+ .doc-body a { color: var(--primary); text-decoration: none; }
460
+ .doc-body a:hover { text-decoration: underline; }
461
+ .doc-body blockquote {
462
+ border-left: 4px solid var(--primary);
463
+ padding: 6px 16px;
464
+ color: var(--text-secondary);
465
+ margin: 0 0 1em;
466
+ background: rgba(25,118,210,0.04);
467
+ }
468
+ .doc-body table { border-collapse: collapse; margin: 0 0 1em; display: block; overflow-x: auto; }
469
+ .doc-body th, .doc-body td { border: 1px solid var(--divider); padding: 6px 10px; text-align: left; font-size: 13px; }
470
+ .doc-body th { background: #fafafa; font-weight: 500; }
471
+ .doc-body hr { border: none; border-top: 1px solid var(--divider); margin: 2em 0; }
472
+
473
+ /* State */
474
+ .empty-state {
475
+ padding: 64px 16px;
476
+ text-align: center;
477
+ color: var(--text-secondary);
478
+ }
479
+ .empty-icon { font-size: 48px; color: var(--text-disabled); margin-bottom: 12px; }
480
+ .empty-text { font-size: 15px; }
481
+ .muted { color: var(--text-secondary); padding: 8px 16px; font-size: 13px; }
482
+ .hidden { display: none !important; }
483
+
484
+ /* App-bar action buttons */
485
+ .app-bar-btn {
486
+ appearance: none;
487
+ background: transparent;
488
+ border: none;
489
+ color: var(--primary-contrast);
490
+ width: 40px;
491
+ height: 40px;
492
+ border-radius: 50%;
493
+ display: inline-flex;
494
+ align-items: center;
495
+ justify-content: center;
496
+ cursor: pointer;
497
+ flex-shrink: 0;
498
+ transition: background 120ms, opacity 120ms;
499
+ }
500
+ .app-bar-btn svg { width: 22px; height: 22px; fill: currentColor; }
501
+ .app-bar-btn:hover:not(:disabled) { background: rgba(255,255,255,0.15); }
502
+ .app-bar-btn:disabled { opacity: 0.45; cursor: not-allowed; }
503
+ .app-bar-btn-danger:hover:not(:disabled) { background: rgba(255,82,82,0.25); color: #ffcdd2; }
504
+
505
+ /* Snackbar (MUI-style transient status) */
506
+ .snack {
507
+ position: fixed;
508
+ left: 50%;
509
+ bottom: 24px;
510
+ transform: translate(-50%, 40px);
511
+ min-width: 280px;
512
+ max-width: 560px;
513
+ padding: 12px 20px;
514
+ border-radius: 4px;
515
+ background: #323232;
516
+ color: #ffffff;
517
+ font-size: 14px;
518
+ line-height: 1.45;
519
+ box-shadow: var(--elev-2);
520
+ opacity: 0;
521
+ pointer-events: none;
522
+ transition: opacity 160ms, transform 160ms;
523
+ z-index: 20;
524
+ }
525
+ .snack.visible {
526
+ opacity: 1;
527
+ transform: translate(-50%, 0);
528
+ pointer-events: auto;
529
+ }
530
+ .snack.ok { background: #2e7d32; }
531
+ .snack.err { background: #c62828; }
532
+
533
+ /* Responsive */
534
+ @media (max-width: 760px) {
535
+ .content { padding: 8px 16px 48px; }
536
+ .content-toolbar { padding: 12px 0 12px; }
537
+ .app-bar-inner { gap: 8px; }
538
+ #stats-chip { display: none; }
539
+ .tab { min-width: 0; padding: 0 14px; }
540
+ }
541
+ `;
542
+
543
+ // --- Client-side JS (vanilla, embedded) ---
544
+
545
+ const CLIENT_JS = `
546
+ (() => {
547
+ const API = "/api";
548
+ const $ = (id) => document.getElementById(id);
549
+
550
+ const searchInput = $("search-input");
551
+ const searchForm = $("search-form");
552
+ const clearBtn = $("clear-btn");
553
+ const searchField = searchForm;
554
+ const statsText = $("stats-text");
555
+ const searchTab = $("search-tab");
556
+ const wikiTab = $("wiki-tab");
557
+ const resultsList = $("results-list");
558
+ const resultsMeta = $("results-meta");
559
+ const resultsEmpty = $("results-empty");
560
+ const wikiCards = $("wiki-cards");
561
+ const wikiCount = $("wiki-count");
562
+ const docView = $("doc-view");
563
+ const docTitle = $("doc-title");
564
+ const docMeta = $("doc-meta");
565
+ const docChips = $("doc-chips");
566
+ const docBody = $("doc-body");
567
+ const backBtn = $("back-btn");
568
+ const typeFilters = $("type-filters");
569
+ const btnReonboard = $("btn-reonboard");
570
+ const btnPurge = $("btn-purge");
571
+ const snack = $("snack");
572
+ const tabButtons = document.querySelectorAll(".tab");
573
+ let snackTimer = null;
574
+ let activeTab = "search"; // remembered so "Back" from doc returns here
575
+
576
+ const enabledTypes = () => new Set(
577
+ Array.from(typeFilters.querySelectorAll('input[type=checkbox]'))
578
+ .filter((c) => c.checked)
579
+ .map((c) => c.dataset.type)
580
+ );
581
+
582
+ async function api(action, params = {}) {
583
+ const r = await fetch(API, {
584
+ method: "POST",
585
+ headers: { "Content-Type": "application/json" },
586
+ body: JSON.stringify({ action, params }),
587
+ });
588
+ if (!r.ok) throw new Error("API " + action + " failed: " + r.status);
589
+ return await r.json();
590
+ }
591
+
592
+ async function loadStats() {
593
+ try {
594
+ const s = await api("stats");
595
+ const parts = [];
596
+ const total = s.total != null ? s.total : s.doc_count != null ? s.doc_count : s.documents;
597
+ if (total != null) parts.push(total + " docs");
598
+ if (s.wiki) parts.push(s.wiki + " wiki");
599
+ if (s.memory) parts.push(s.memory + " mem");
600
+ statsText.textContent = parts.length ? parts.join(" · ") : "—";
601
+ } catch (e) { statsText.textContent = "offline"; }
602
+ }
603
+
604
+ async function loadHealth() {
605
+ try {
606
+ const h = await api("health");
607
+ if (h && h.read_only) {
608
+ btnReonboard.disabled = true;
609
+ btnPurge.disabled = true;
610
+ btnReonboard.title = "Server started with --read-only";
611
+ btnPurge.title = "Server started with --read-only";
612
+ }
613
+ } catch { /* ignore */ }
614
+ }
615
+
616
+ function showSnack(text, kind) {
617
+ if (snackTimer) clearTimeout(snackTimer);
618
+ snack.textContent = text || "";
619
+ snack.className = "snack visible " + (kind || "");
620
+ const ttl = kind === "err" ? 7000 : 4500;
621
+ snackTimer = setTimeout(() => {
622
+ snack.classList.remove("visible");
623
+ }, ttl);
624
+ }
625
+
626
+ async function loadWikiList() {
627
+ try {
628
+ const r = await api("wiki_list", {});
629
+ const items = r.articles || r.wiki || r.results || [];
630
+ if (!items.length) {
631
+ wikiCards.innerHTML = '<div class="muted">No wiki articles yet. Use the Search tab or ingest content first.</div>';
632
+ wikiCount.textContent = "0 articles";
633
+ return;
634
+ }
635
+ wikiCount.textContent = items.length + (items.length === 1 ? " article" : " articles");
636
+ wikiCards.innerHTML = "";
637
+ for (const a of items.slice(0, 400)) {
638
+ wikiCards.appendChild(renderWikiCard(a));
639
+ }
640
+ } catch (e) {
641
+ wikiCards.innerHTML = '<div class="muted">Could not load wiki: ' + escapeHtml(e.message) + '</div>';
642
+ }
643
+ }
644
+
645
+ function renderWikiCard(a) {
646
+ const card = document.createElement("div");
647
+ card.className = "wiki-card";
648
+ card.dataset.path = a.path;
649
+ const title = document.createElement("div");
650
+ title.className = "wiki-card-title";
651
+ title.textContent = a.title || a.path.split("/").pop().replace(/\\.md$/, "");
652
+ card.appendChild(title);
653
+ if (a.description) {
654
+ const desc = document.createElement("div");
655
+ desc.className = "wiki-card-desc";
656
+ desc.textContent = a.description;
657
+ card.appendChild(desc);
658
+ }
659
+ const meta = document.createElement("div");
660
+ meta.className = "wiki-card-meta";
661
+ if (a.tags && a.tags.length) {
662
+ for (const t of a.tags.slice(0, 3)) {
663
+ const chip = document.createElement("span");
664
+ chip.className = "chip chip-wiki";
665
+ chip.textContent = t;
666
+ meta.appendChild(chip);
667
+ }
668
+ }
669
+ if (a.word_count != null) {
670
+ const chip = document.createElement("span");
671
+ chip.className = "chip";
672
+ chip.textContent = a.word_count + " words";
673
+ meta.appendChild(chip);
674
+ }
675
+ if (meta.childNodes.length) card.appendChild(meta);
676
+ const path = document.createElement("div");
677
+ path.className = "wiki-card-path";
678
+ path.textContent = a.path;
679
+ card.appendChild(path);
680
+ card.addEventListener("click", () => {
681
+ openDoc({ path: a.path, id: a.id, title: title.textContent });
682
+ });
683
+ return card;
684
+ }
685
+
686
+ const debounce = (fn, ms) => {
687
+ let t;
688
+ return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
689
+ };
690
+
691
+ const runSearch = debounce(async (q) => {
692
+ if (!q || !q.trim()) {
693
+ await showBrowseAll();
694
+ return;
695
+ }
696
+ resultsEmpty.classList.add("hidden");
697
+ resultsList.innerHTML = '<div class="muted">searching…</div>';
698
+ try {
699
+ const r = await api("search", { query: q, limit: 25 });
700
+ renderResults(q, r);
701
+ } catch (e) {
702
+ resultsList.innerHTML = '<div class="muted">Search failed: ' + escapeHtml(e.message) + '</div>';
703
+ }
704
+ }, 180);
705
+
706
+ /**
707
+ * Empty-query mode: show all documents filtered by the active source-type
708
+ * chips, most-recent-first. The same result-card renderer is reused so
709
+ * toggling between browse and search is visually consistent.
710
+ */
711
+ async function showBrowseAll() {
712
+ resultsEmpty.classList.add("hidden");
713
+ resultsList.innerHTML = '<div class="muted">loading…</div>';
714
+ try {
715
+ const types = [...enabledTypes()];
716
+ const r = await api("list_docs", { source_types: types, limit: 100 });
717
+ const results = r.results || [];
718
+ const metaParts = [results.length + " shown"];
719
+ if (r.total != null && r.total !== results.length) metaParts.push(r.total + " total");
720
+ if (types.length && types.length < 3) metaParts.push("types: " + types.join(", "));
721
+ resultsMeta.textContent = metaParts.join(" · ");
722
+ resultsList.innerHTML = "";
723
+ if (!results.length) {
724
+ resultsList.innerHTML = '<div class="muted">Nothing to show. Try toggling filter chips or ingest some content.</div>';
725
+ return;
726
+ }
727
+ for (const res of results) resultsList.appendChild(renderResultCard(res));
728
+ } catch (e) {
729
+ resultsList.innerHTML = '<div class="muted">Could not load documents: ' + escapeHtml(e.message) + '</div>';
730
+ }
731
+ }
732
+
733
+ function renderResults(q, r) {
734
+ const types = enabledTypes();
735
+ const results = (r.results || []).filter((x) => types.has(x.source_type || "chunk"));
736
+ const metaParts = [results.length + " shown"];
737
+ if (r.total_matches != null) metaParts.push(r.total_matches + " matched");
738
+ if (r.collapsed) metaParts.push(r.collapsed + " collapsed");
739
+ resultsMeta.textContent = metaParts.join(" · ");
740
+ resultsList.innerHTML = "";
741
+ if (!results.length) {
742
+ resultsList.innerHTML = '<div class="muted">No results matching the enabled source types.</div>';
743
+ return;
744
+ }
745
+ for (const res of results) {
746
+ resultsList.appendChild(renderResultCard(res));
747
+ }
748
+ }
749
+
750
+ function renderResultCard(res) {
751
+ const card = document.createElement("div");
752
+ card.className = "result-card";
753
+ card.addEventListener("click", () => openDoc({ id: res.id, path: res.path }));
754
+
755
+ const top = document.createElement("div");
756
+ top.className = "result-top";
757
+ const type = document.createElement("span");
758
+ type.className = "chip chip-" + (res.source_type || "chunk");
759
+ type.textContent = res.source_type || "chunk";
760
+ top.appendChild(type);
761
+ if (res.canonical_for && res.canonical_for.length) {
762
+ const canon = document.createElement("span");
763
+ canon.className = "chip chip-canonical";
764
+ canon.textContent = "canonical: " + res.canonical_for.join(", ");
765
+ top.appendChild(canon);
766
+ }
767
+ const path = document.createElement("span");
768
+ path.className = "result-path";
769
+ path.textContent = res.path;
770
+ top.appendChild(path);
771
+ if (res.composite_score != null || res.score != null) {
772
+ const score = document.createElement("span");
773
+ score.className = "result-score";
774
+ const val = res.composite_score != null ? res.composite_score : res.score;
775
+ score.textContent = typeof val === "number" ? val.toFixed(2) : String(val);
776
+ top.appendChild(score);
777
+ }
778
+ card.appendChild(top);
779
+
780
+ const snippet = document.createElement("div");
781
+ snippet.className = "result-snippet";
782
+ snippet.innerHTML = res.snippet || escapeHtml(res.body_excerpt || "");
783
+ card.appendChild(snippet);
784
+
785
+ if (res.also_found_in && res.also_found_in.length) {
786
+ const also = document.createElement("div");
787
+ also.className = "result-also";
788
+ const label = document.createElement("span");
789
+ label.className = "result-also-label";
790
+ label.textContent = "also found in:";
791
+ also.appendChild(label);
792
+ for (const a of res.also_found_in) {
793
+ const span = document.createElement("span");
794
+ span.className = "result-also-item";
795
+ span.textContent = a.path;
796
+ also.appendChild(span);
797
+ }
798
+ card.appendChild(also);
799
+ }
800
+
801
+ return card;
802
+ }
803
+
804
+ function showTab(name) {
805
+ const target = name === "wiki" ? "wiki" : "search";
806
+ activeTab = target;
807
+ tabButtons.forEach((b) => {
808
+ const on = b.dataset.tab === target;
809
+ b.classList.toggle("active", on);
810
+ b.setAttribute("aria-selected", on ? "true" : "false");
811
+ });
812
+ searchTab.classList.toggle("hidden", target !== "search");
813
+ wikiTab.classList.toggle("hidden", target !== "wiki");
814
+ docView.classList.add("hidden");
815
+ if (target === "search") searchInput.focus();
816
+ }
817
+
818
+ async function openDoc({ id, path, title }) {
819
+ searchTab.classList.add("hidden");
820
+ wikiTab.classList.add("hidden");
821
+ docView.classList.remove("hidden");
822
+ docTitle.textContent = title || path || "Loading…";
823
+ docMeta.textContent = "";
824
+ docBody.innerHTML = '<div class="muted">loading…</div>';
825
+ docChips.innerHTML = "";
826
+ try {
827
+ const params = id ? { id } : { path };
828
+ const resp = await api("get_document", params);
829
+ const doc = resp && resp.document;
830
+ if (!doc || !doc.id) {
831
+ docBody.innerHTML = '<div class="muted">Document not found: ' + escapeHtml(path || id) + '</div>';
832
+ return;
833
+ }
834
+ docTitle.textContent = extractTitle(doc) || doc.path;
835
+ const bits = [];
836
+ if (doc.indexed_at) bits.push("indexed " + new Date(doc.indexed_at).toISOString().slice(0, 10));
837
+ if (doc.brain_id) bits.push(doc.brain_id);
838
+ docMeta.textContent = bits.join(" · ");
839
+
840
+ docChips.innerHTML = "";
841
+ const sourceType = deriveSourceType(doc.path);
842
+ const typeChip = document.createElement("span");
843
+ typeChip.className = "chip chip-" + sourceType;
844
+ typeChip.textContent = sourceType;
845
+ docChips.appendChild(typeChip);
846
+ for (const id of (doc.canonical_for || [])) {
847
+ const chip = document.createElement("span");
848
+ chip.className = "chip chip-canonical";
849
+ chip.textContent = "canonical: " + id;
850
+ docChips.appendChild(chip);
851
+ }
852
+
853
+ const body = stripFrontmatter(doc.content);
854
+ docBody.innerHTML = renderMarkdown(body);
855
+ wireWikilinks(docBody);
856
+ history.replaceState(null, "", "#" + encodeURIComponent(doc.path));
857
+ } catch (e) {
858
+ docBody.innerHTML = '<div class="muted">Failed to load: ' + escapeHtml(e.message) + '</div>';
859
+ }
860
+ }
861
+
862
+ function backToResults() {
863
+ docView.classList.add("hidden");
864
+ showTab(activeTab);
865
+ history.replaceState(null, "", "#");
866
+ }
867
+
868
+ function wireWikilinks(root) {
869
+ root.querySelectorAll("a[data-wikilink]").forEach((a) => {
870
+ a.addEventListener("click", (ev) => {
871
+ ev.preventDefault();
872
+ openDoc({ path: a.dataset.wikilink });
873
+ });
874
+ });
875
+ }
876
+
877
+ function deriveSourceType(path) {
878
+ if (!path) return "chunk";
879
+ if (path.startsWith("wiki/")) return "wiki";
880
+ if (path.startsWith("memory/") || path.startsWith("memories/")) return "memory";
881
+ return "chunk";
882
+ }
883
+
884
+ function stripFrontmatter(content) {
885
+ const m = (content || "").match(/^---\\n[\\s\\S]*?\\n---\\n?([\\s\\S]*)$/);
886
+ return m ? m[1] : (content || "");
887
+ }
888
+
889
+ function extractTitle(doc) {
890
+ if (!doc.content) return null;
891
+ const firstH1 = doc.content.match(/^#\\s+(.+)$/m);
892
+ if (firstH1) return firstH1[1].trim();
893
+ const fmTitle = (doc.frontmatter || "").match(/^title:\\s*(.+)$/m);
894
+ return fmTitle ? fmTitle[1].trim().replace(/^["']|["']$/g, "") : null;
895
+ }
896
+
897
+ function escapeHtml(s) {
898
+ return String(s ?? "")
899
+ .replace(/&/g, "&amp;")
900
+ .replace(/</g, "&lt;")
901
+ .replace(/>/g, "&gt;")
902
+ .replace(/"/g, "&quot;");
903
+ }
904
+
905
+ // Minimal markdown renderer — handles headings, paragraphs, lists, code
906
+ // fences, inline code, bold/italic, links, [[wikilinks]], blockquotes,
907
+ // tables. Intentionally small; no footnotes/html passthrough.
908
+ function renderMarkdown(src) {
909
+ const lines = src.split(/\\r?\\n/);
910
+ const out = [];
911
+ let i = 0;
912
+ while (i < lines.length) {
913
+ const line = lines[i];
914
+ // Fenced code block
915
+ const fence = line.match(/^\`\`\`(\\w*)\\s*$/);
916
+ if (fence) {
917
+ i++;
918
+ const start = i;
919
+ while (i < lines.length && !/^\`\`\`\\s*$/.test(lines[i])) i++;
920
+ const code = lines.slice(start, i).join("\\n");
921
+ out.push('<pre><code>' + escapeHtml(code) + '</code></pre>');
922
+ i++;
923
+ continue;
924
+ }
925
+ // Heading
926
+ const h = line.match(/^(#{1,6})\\s+(.+?)\\s*$/);
927
+ if (h) {
928
+ const level = h[1].length;
929
+ out.push('<h' + level + '>' + inline(h[2]) + '</h' + level + '>');
930
+ i++;
931
+ continue;
932
+ }
933
+ // Blockquote
934
+ if (/^>\\s?/.test(line)) {
935
+ const buf = [];
936
+ while (i < lines.length && /^>\\s?/.test(lines[i])) {
937
+ buf.push(lines[i].replace(/^>\\s?/, ""));
938
+ i++;
939
+ }
940
+ out.push('<blockquote>' + inline(buf.join(" ")) + '</blockquote>');
941
+ continue;
942
+ }
943
+ // Horizontal rule
944
+ if (/^(-{3,}|_{3,}|\\*{3,})\\s*$/.test(line)) {
945
+ out.push('<hr>');
946
+ i++;
947
+ continue;
948
+ }
949
+ // List (unordered)
950
+ if (/^\\s*[-*+]\\s+/.test(line)) {
951
+ const items = [];
952
+ while (i < lines.length && /^\\s*[-*+]\\s+/.test(lines[i])) {
953
+ items.push(lines[i].replace(/^\\s*[-*+]\\s+/, ""));
954
+ i++;
955
+ }
956
+ out.push('<ul>' + items.map((it) => '<li>' + inline(it) + '</li>').join("") + '</ul>');
957
+ continue;
958
+ }
959
+ // List (ordered)
960
+ if (/^\\s*\\d+\\.\\s+/.test(line)) {
961
+ const items = [];
962
+ while (i < lines.length && /^\\s*\\d+\\.\\s+/.test(lines[i])) {
963
+ items.push(lines[i].replace(/^\\s*\\d+\\.\\s+/, ""));
964
+ i++;
965
+ }
966
+ out.push('<ol>' + items.map((it) => '<li>' + inline(it) + '</li>').join("") + '</ol>');
967
+ continue;
968
+ }
969
+ // Table (very simple: header | sep | rows)
970
+ if (i + 1 < lines.length && /\\|/.test(line) && /^\\s*\\|?\\s*-+/.test(lines[i + 1])) {
971
+ const header = line.split("|").map((c) => c.trim()).filter(Boolean);
972
+ i += 2;
973
+ const rows = [];
974
+ while (i < lines.length && /\\|/.test(lines[i]) && lines[i].trim().length > 0) {
975
+ rows.push(lines[i].split("|").map((c) => c.trim()).filter((c, idx, a) => idx > 0 || c.length || a.length > 1));
976
+ i++;
977
+ }
978
+ const head = '<tr>' + header.map((h) => '<th>' + inline(h) + '</th>').join("") + '</tr>';
979
+ const body = rows.map((r) => '<tr>' + r.map((c) => '<td>' + inline(c) + '</td>').join("") + '</tr>').join("");
980
+ out.push('<table><thead>' + head + '</thead><tbody>' + body + '</tbody></table>');
981
+ continue;
982
+ }
983
+ // Blank line -> paragraph break
984
+ if (line.trim() === "") { i++; continue; }
985
+ // Paragraph: collect consecutive non-blank lines
986
+ const buf = [line];
987
+ i++;
988
+ while (i < lines.length && lines[i].trim() !== "" && !/^(\`\`\`|#{1,6}\\s|>\\s|\\s*[-*+]\\s|\\s*\\d+\\.\\s)/.test(lines[i])) {
989
+ buf.push(lines[i]);
990
+ i++;
991
+ }
992
+ out.push('<p>' + inline(buf.join(" ")) + '</p>');
993
+ }
994
+ return out.join("\\n");
995
+ }
996
+
997
+ function inline(s) {
998
+ let r = escapeHtml(s);
999
+ // inline code: \`...\`
1000
+ r = r.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
1001
+ // bold: **...**
1002
+ r = r.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
1003
+ // italic: *...* (after bold so ** not eaten)
1004
+ r = r.replace(/(^|\\W)\\*([^*\\n]+)\\*(?=\\W|$)/g, '$1<em>$2</em>');
1005
+ // wikilinks: [[path]]
1006
+ r = r.replace(/\\[\\[([^\\]]+)\\]\\]/g, (m, target) => {
1007
+ const safe = target.replace(/"/g, "&quot;");
1008
+ return '<a href="#' + safe + '" data-wikilink="' + safe + '">' + safe + '</a>';
1009
+ });
1010
+ // links: [text](url)
1011
+ r = r.replace(/\\[([^\\]]+)\\]\\(([^)\\s]+)\\)/g, '<a href="$2" rel="noopener noreferrer" target="_blank">$1</a>');
1012
+ return r;
1013
+ }
1014
+
1015
+ // --- Wire events ---
1016
+ tabButtons.forEach((b) => {
1017
+ b.addEventListener("click", () => showTab(b.dataset.tab));
1018
+ });
1019
+ searchForm.addEventListener("submit", (e) => e.preventDefault());
1020
+ searchInput.addEventListener("input", () => {
1021
+ searchField.classList.toggle("has-value", searchInput.value.length > 0);
1022
+ runSearch(searchInput.value);
1023
+ });
1024
+ clearBtn.addEventListener("click", () => {
1025
+ searchInput.value = "";
1026
+ searchField.classList.remove("has-value");
1027
+ runSearch("");
1028
+ searchInput.focus();
1029
+ });
1030
+ typeFilters.addEventListener("change", () => {
1031
+ if (searchInput.value.trim()) runSearch(searchInput.value);
1032
+ else showBrowseAll();
1033
+ });
1034
+ backBtn.addEventListener("click", backToResults);
1035
+
1036
+ btnReonboard.addEventListener("click", async () => {
1037
+ if (btnReonboard.disabled) return;
1038
+ if (!confirm("Re-run onboarding?\\n\\nThis re-detects the repo mode, re-stamps CLAUDE.md / AGENTS.md with the wiki pointer, and rebuilds the search index from chunk and wiki files on disk. Authored content is NOT deleted.")) return;
1039
+ btnReonboard.disabled = true;
1040
+ btnPurge.disabled = true;
1041
+ showSnack("Re-running onboarding…");
1042
+ try {
1043
+ const r = await api("reonboard", {});
1044
+ const msg = "Re-indexed " + r.indexed + " files"
1045
+ + (r.onboard && r.onboard.mode ? ' · mode=' + r.onboard.mode : '');
1046
+ showSnack(msg, "ok");
1047
+ await Promise.all([loadStats(), loadWikiList()]);
1048
+ } catch (e) {
1049
+ showSnack("Re-onboard failed: " + e.message, "err");
1050
+ } finally {
1051
+ btnReonboard.disabled = false;
1052
+ btnPurge.disabled = false;
1053
+ }
1054
+ });
1055
+
1056
+ btnPurge.addEventListener("click", async () => {
1057
+ if (btnPurge.disabled) return;
1058
+ const typed = prompt('This permanently deletes ALL chunks, wiki articles, and memories in this brain. The SQLite index is cleared too.\\n\\nType DELETE to confirm:');
1059
+ if (typed !== "DELETE") {
1060
+ if (typed !== null) showSnack("Delete cancelled.");
1061
+ return;
1062
+ }
1063
+ btnReonboard.disabled = true;
1064
+ btnPurge.disabled = true;
1065
+ showSnack("Deleting brain content…");
1066
+ try {
1067
+ const r = await api("purge_brain", { confirm: "DELETE" });
1068
+ if (r.error) {
1069
+ showSnack("Delete blocked: " + r.error, "err");
1070
+ } else {
1071
+ const removed = r.removed || {};
1072
+ const summary = Object.entries(removed)
1073
+ .map(([k, v]) => v + " " + k)
1074
+ .join(", ");
1075
+ showSnack("Deleted: " + (summary || "nothing"), "ok");
1076
+ await Promise.all([loadStats(), loadWikiList()]);
1077
+ backToResults();
1078
+ }
1079
+ } catch (e) {
1080
+ showSnack("Delete failed: " + e.message, "err");
1081
+ } finally {
1082
+ btnReonboard.disabled = false;
1083
+ btnPurge.disabled = false;
1084
+ }
1085
+ });
1086
+
1087
+ // Deep-link: #<path> → open that doc on load
1088
+ window.addEventListener("load", async () => {
1089
+ await Promise.all([loadStats(), loadWikiList(), loadHealth(), showBrowseAll()]);
1090
+ if (location.hash && location.hash.length > 1) {
1091
+ const path = decodeURIComponent(location.hash.slice(1));
1092
+ if (path && path !== "") openDoc({ path });
1093
+ }
1094
+ });
1095
+ })();
1096
+ `;