mnueron 0.1.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.
Files changed (52) hide show
  1. package/ARCHITECTURE.md +161 -0
  2. package/INSTALL.md +262 -0
  3. package/LICENSE +21 -0
  4. package/README.md +305 -0
  5. package/dashboard/index.html +838 -0
  6. package/dist/cli.js +685 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/config.js +44 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/dashboard/server.js +234 -0
  11. package/dist/dashboard/server.js.map +1 -0
  12. package/dist/detectors/claude_code.js +72 -0
  13. package/dist/detectors/claude_code.js.map +1 -0
  14. package/dist/detectors/claude_desktop.js +37 -0
  15. package/dist/detectors/claude_desktop.js.map +1 -0
  16. package/dist/detectors/cursor.js +36 -0
  17. package/dist/detectors/cursor.js.map +1 -0
  18. package/dist/detectors/extra.js +59 -0
  19. package/dist/detectors/extra.js.map +1 -0
  20. package/dist/detectors/index.js +14 -0
  21. package/dist/detectors/index.js.map +1 -0
  22. package/dist/detectors/json_detector.js +95 -0
  23. package/dist/detectors/json_detector.js.map +1 -0
  24. package/dist/detectors/types.js +13 -0
  25. package/dist/detectors/types.js.map +1 -0
  26. package/dist/import/claude.js +82 -0
  27. package/dist/import/claude.js.map +1 -0
  28. package/dist/import/openai.js +102 -0
  29. package/dist/import/openai.js.map +1 -0
  30. package/dist/index.js +77 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/plugins/loader.js +175 -0
  33. package/dist/plugins/loader.js.map +1 -0
  34. package/dist/plugins/types.js +24 -0
  35. package/dist/plugins/types.js.map +1 -0
  36. package/dist/setup.js +123 -0
  37. package/dist/setup.js.map +1 -0
  38. package/dist/store/chunking.js +150 -0
  39. package/dist/store/chunking.js.map +1 -0
  40. package/dist/store/embeddings.js +126 -0
  41. package/dist/store/embeddings.js.map +1 -0
  42. package/dist/store/local.js +720 -0
  43. package/dist/store/local.js.map +1 -0
  44. package/dist/store/provider.js +7 -0
  45. package/dist/store/provider.js.map +1 -0
  46. package/dist/store/redactor.js +114 -0
  47. package/dist/store/redactor.js.map +1 -0
  48. package/dist/store/remote.js +62 -0
  49. package/dist/store/remote.js.map +1 -0
  50. package/dist/tools.js +312 -0
  51. package/dist/tools.js.map +1 -0
  52. package/package.json +55 -0
@@ -0,0 +1,838 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>mnueron · memory dashboard</title>
7
+
8
+ <!-- Prism.js for syntax highlighting code blocks inside memories -->
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" />
10
+ <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
11
+ <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"
12
+ data-autoloader-path="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/"></script>
13
+
14
+ <style>
15
+ /* ─── reset / base ─────────────────────────────────────────────────── */
16
+ *, *::before, *::after { box-sizing: border-box; }
17
+ html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; }
18
+ body {
19
+ font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
20
+ background: var(--bg);
21
+ color: var(--fg);
22
+ -webkit-font-smoothing: antialiased;
23
+ }
24
+ button, input, select, textarea { font: inherit; color: inherit; }
25
+ button { cursor: pointer; }
26
+ a { color: var(--accent); text-decoration: none; }
27
+ a:hover { text-decoration: underline; }
28
+
29
+ /* ─── theme tokens ─────────────────────────────────────────────────── */
30
+ :root, [data-theme="dark"] {
31
+ --bg: #0f1115;
32
+ --bg-elev1: #11141c;
33
+ --bg-elev2: #161a26;
34
+ --bg-elev3: #1d2230;
35
+ --bg-rail: #0a0c10;
36
+ --border: #1f2330;
37
+ --border-soft: #161a26;
38
+ --fg: #d6dae3;
39
+ --fg-soft: #8a92a6;
40
+ --fg-dim: #6c7488;
41
+ --fg-bright: #ffffff;
42
+ --accent: #7aa2ff;
43
+ --accent-bg: #3a5cf0;
44
+ --accent-bg-h: #4a6bff;
45
+ --ok: #5cffb0;
46
+ --ok-bg: #15301f;
47
+ --ok-border: #2a5a3a;
48
+ --err: #ffa6b0;
49
+ --err-bg: #2a151b;
50
+ --err-border: #5a2530;
51
+ --pill-ns-bg: #1a2440;
52
+ --pill-ns-fg: #9bb1f0;
53
+ --pill-tag-bg: #1d2230;
54
+ --pill-tag-fg: #9fa9bf;
55
+ --pill-user-bg: #1c2d1f;
56
+ --pill-user-fg: #a6e0b0;
57
+ --pill-asst-bg: #1c2435;
58
+ --pill-asst-fg: #9bb1f0;
59
+ }
60
+ [data-theme="light"] {
61
+ --bg: #f7f8fa;
62
+ --bg-elev1: #ffffff;
63
+ --bg-elev2: #f0f2f6;
64
+ --bg-elev3: #e6e9ef;
65
+ --bg-rail: #eef0f4;
66
+ --border: #d8dde5;
67
+ --border-soft: #e9ecf1;
68
+ --fg: #1c1f26;
69
+ --fg-soft: #5d6675;
70
+ --fg-dim: #8b94a3;
71
+ --fg-bright: #000000;
72
+ --accent: #2950d9;
73
+ --accent-bg: #3a5cf0;
74
+ --accent-bg-h: #2950d9;
75
+ --ok: #1f7a3a;
76
+ --ok-bg: #d9f4e1;
77
+ --ok-border: #8ad8a3;
78
+ --err: #b3303f;
79
+ --err-bg: #fbe1e4;
80
+ --err-border: #e9a9b2;
81
+ --pill-ns-bg: #dee5f7;
82
+ --pill-ns-fg: #2950d9;
83
+ --pill-tag-bg: #e6e9ef;
84
+ --pill-tag-fg: #5d6675;
85
+ --pill-user-bg: #def0e2;
86
+ --pill-user-fg: #1f7a3a;
87
+ --pill-asst-bg: #dee5f7;
88
+ --pill-asst-fg: #2950d9;
89
+ }
90
+
91
+ /* ─── layout: three-pane grid ──────────────────────────────────────── */
92
+ .app {
93
+ display: grid;
94
+ grid-template-columns: var(--rail-w, 260px) 1fr var(--detail-w, 480px);
95
+ grid-template-rows: 100vh;
96
+ height: 100vh;
97
+ }
98
+ .rail { background: var(--bg-rail); border-right: 1px solid var(--border); padding: 16px 12px; overflow-y: auto; }
99
+ .list { background: var(--bg); display: flex; flex-direction: column; min-width: 0; }
100
+ .detail { background: var(--bg-elev1); border-left: 1px solid var(--border); overflow-y: auto; display: flex; flex-direction: column; }
101
+
102
+ /* ─── rail: brand, stats, namespaces ───────────────────────────────── */
103
+ .brand {
104
+ font-size: 16px;
105
+ font-weight: 600;
106
+ color: var(--fg-bright);
107
+ display: flex; align-items: center; gap: 8px;
108
+ margin: 0 4px 4px;
109
+ }
110
+ .brand .dot {
111
+ width: 8px; height: 8px; border-radius: 50%;
112
+ background: var(--ok); box-shadow: 0 0 6px var(--ok);
113
+ }
114
+ .brand .theme-btn {
115
+ margin-left: auto; background: none; border: none; color: var(--fg-dim);
116
+ font-size: 14px; padding: 4px;
117
+ }
118
+ .brand .theme-btn:hover { color: var(--fg); }
119
+
120
+ .stats {
121
+ margin: 14px 4px 22px;
122
+ padding: 12px;
123
+ background: var(--bg-elev1);
124
+ border: 1px solid var(--border);
125
+ border-radius: 8px;
126
+ }
127
+ .stats .row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 12px; }
128
+ .stats .row .k { color: var(--fg-soft); }
129
+ .stats .row .v { color: var(--fg); font-variant-numeric: tabular-nums; }
130
+
131
+ .nslabel {
132
+ text-transform: uppercase; font-size: 11px;
133
+ letter-spacing: 0.08em; color: var(--fg-dim);
134
+ margin: 6px 4px;
135
+ }
136
+ .ns {
137
+ display: flex; justify-content: space-between; align-items: center;
138
+ padding: 7px 10px; border-radius: 6px;
139
+ cursor: pointer; color: var(--fg);
140
+ margin-bottom: 1px;
141
+ }
142
+ .ns:hover { background: var(--bg-elev2); }
143
+ .ns.active { background: var(--bg-elev3); color: var(--fg-bright); }
144
+ .ns .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
145
+ .ns .count { font-size: 11px; color: var(--fg-dim); background: var(--bg); border-radius: 10px; padding: 1px 8px; margin-left: 8px; }
146
+ .ns.active .count { color: var(--fg); }
147
+
148
+ .filters {
149
+ margin: 14px 4px;
150
+ padding: 10px;
151
+ background: var(--bg-elev1);
152
+ border: 1px solid var(--border);
153
+ border-radius: 8px;
154
+ font-size: 12px;
155
+ }
156
+ .filters label { display: block; color: var(--fg-soft); margin-bottom: 4px; }
157
+ .filters .chip-row { display: flex; flex-wrap: wrap; gap: 4px; }
158
+ .filters .chip {
159
+ background: var(--bg-elev2); color: var(--fg-soft);
160
+ padding: 2px 8px; border-radius: 4px; font-size: 11px;
161
+ cursor: pointer; user-select: none;
162
+ }
163
+ .filters .chip.on { background: var(--accent-bg); color: var(--fg-bright); }
164
+
165
+ /* ─── list (middle pane) ───────────────────────────────────────────── */
166
+ .toolbar {
167
+ padding: 14px 18px; border-bottom: 1px solid var(--border);
168
+ display: flex; gap: 10px; align-items: center; background: var(--bg);
169
+ }
170
+ .search {
171
+ flex: 1; background: var(--bg-elev2); border: 1px solid var(--border);
172
+ border-radius: 8px; padding: 8px 12px; color: var(--fg); min-width: 0; outline: none;
173
+ transition: border-color .15s;
174
+ }
175
+ .search:focus { border-color: var(--accent-bg); }
176
+ .scope { color: var(--fg-soft); font-size: 12px; white-space: nowrap; }
177
+ .btn {
178
+ background: var(--bg-elev3); color: var(--fg); border: 1px solid var(--border);
179
+ border-radius: 8px; padding: 7px 12px; transition: background .15s; font-size: 13px;
180
+ }
181
+ .btn:hover { background: var(--bg-elev2); }
182
+ .btn.primary { background: var(--accent-bg); border-color: var(--accent-bg); color: var(--fg-bright); }
183
+ .btn.primary:hover { background: var(--accent-bg-h); }
184
+
185
+ .list-body { flex: 1; overflow-y: auto; padding: 0 0 32px; }
186
+ .empty { color: var(--fg-dim); padding: 80px 22px; text-align: center; font-size: 14px; }
187
+
188
+ /* List items (thread row or single-memory row) */
189
+ .row {
190
+ padding: 12px 18px;
191
+ border-bottom: 1px solid var(--border-soft);
192
+ cursor: pointer; display: grid;
193
+ grid-template-columns: auto 1fr auto;
194
+ gap: 4px 12px; align-items: center;
195
+ }
196
+ .row:hover { background: var(--bg-elev1); }
197
+ .row.active { background: var(--bg-elev2); }
198
+ .row .icon { color: var(--fg-dim); font-size: 16px; line-height: 1; }
199
+ .row .title {
200
+ color: var(--fg); font-weight: 500;
201
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
202
+ grid-column: 2;
203
+ }
204
+ .row.active .title { color: var(--fg-bright); }
205
+ .row .when { color: var(--fg-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
206
+ .row .meta {
207
+ grid-column: 2 / -1;
208
+ display: flex; flex-wrap: wrap; gap: 6px 10px;
209
+ font-size: 11px; color: var(--fg-soft); align-items: center;
210
+ }
211
+ .row .score {
212
+ color: var(--ok); font-variant-numeric: tabular-nums; font-size: 11px;
213
+ margin-left: 4px;
214
+ }
215
+
216
+ /* Pills */
217
+ .pill { border-radius: 4px; padding: 1px 6px; font-size: 11px; line-height: 1.5; }
218
+ .ns-pill { background: var(--pill-ns-bg); color: var(--pill-ns-fg); }
219
+ .tag { background: var(--pill-tag-bg); color: var(--pill-tag-fg); text-transform: lowercase; }
220
+ .role-pill { font-weight: 500; }
221
+ .role-pill.role-user { background: var(--pill-user-bg); color: var(--pill-user-fg); }
222
+ .role-pill.role-assistant { background: var(--pill-asst-bg); color: var(--pill-asst-fg); }
223
+ .role-pill.role-other { background: var(--pill-tag-bg); color: var(--pill-tag-fg); }
224
+
225
+ /* ─── detail (right pane) ──────────────────────────────────────────── */
226
+ .detail-empty { color: var(--fg-dim); padding: 80px 22px; text-align: center; font-size: 14px; }
227
+ .detail-header {
228
+ padding: 16px 22px 12px; border-bottom: 1px solid var(--border);
229
+ background: var(--bg-elev1); position: sticky; top: 0; z-index: 2;
230
+ }
231
+ .detail-header h2 { margin: 0 0 6px; font-size: 16px; color: var(--fg-bright); font-weight: 600; }
232
+ .detail-header .sub { color: var(--fg-soft); font-size: 12px; margin-bottom: 8px; }
233
+ .detail-header .actions { display: flex; gap: 6px; }
234
+ .detail-header .actions .btn { font-size: 12px; padding: 5px 10px; }
235
+ .delete-btn {
236
+ background: none; border: 1px solid var(--err-border); color: var(--err);
237
+ border-radius: 6px; padding: 5px 10px; font-size: 12px;
238
+ }
239
+ .delete-btn:hover { background: var(--err-bg); }
240
+
241
+ .detail-body { padding: 16px 22px 80px; flex: 1; }
242
+
243
+ /* Chat bubbles inside thread view */
244
+ .bubbles { display: flex; flex-direction: column; gap: 14px; }
245
+ .bubble {
246
+ background: var(--bg-elev1); border: 1px solid var(--border-soft);
247
+ border-radius: 10px; padding: 12px 14px;
248
+ position: relative; max-width: 100%;
249
+ }
250
+ .bubble .b-head { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
251
+ .bubble .b-head .when { color: var(--fg-dim); font-size: 11px; margin-left: auto; font-variant-numeric: tabular-nums; }
252
+ .bubble .b-body {
253
+ line-height: 1.6;
254
+ word-wrap: break-word;
255
+ }
256
+ .bubble .b-body p { margin: 0 0 8px; }
257
+ .bubble .b-body p:last-child { margin-bottom: 0; }
258
+ .bubble .b-body code:not(pre code) {
259
+ background: var(--bg-elev3); padding: 1px 5px; border-radius: 3px;
260
+ font: 0.92em ui-monospace, SFMono-Regular, Menlo, monospace;
261
+ }
262
+ .bubble .b-body pre {
263
+ background: #0a0c10; border-radius: 6px; padding: 10px 12px;
264
+ overflow-x: auto; margin: 8px 0; font-size: 12.5px;
265
+ }
266
+ .bubble .b-body pre code { background: transparent; padding: 0; font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; }
267
+ .bubble .b-body h1, .bubble .b-body h2, .bubble .b-body h3 {
268
+ margin: 14px 0 8px; color: var(--fg-bright); font-weight: 600;
269
+ }
270
+ .bubble .b-body h1 { font-size: 18px; }
271
+ .bubble .b-body h2 { font-size: 16px; }
272
+ .bubble .b-body h3 { font-size: 14px; }
273
+ .bubble .b-body ul, .bubble .b-body ol { padding-left: 20px; margin: 4px 0 8px; }
274
+ .bubble .b-body strong { color: var(--fg-bright); font-weight: 600; }
275
+
276
+ .bubble.role-user { border-left: 3px solid var(--pill-user-fg); }
277
+ .bubble.role-assistant { border-left: 3px solid var(--pill-asst-fg); }
278
+
279
+ .single-memory .b-body { line-height: 1.6; white-space: pre-wrap; }
280
+
281
+ /* Drop overlay */
282
+ .drop-overlay {
283
+ position: fixed; inset: 0; background: rgba(15, 17, 21, 0.85);
284
+ display: none; align-items: center; justify-content: center;
285
+ z-index: 100; border: 3px dashed var(--accent-bg);
286
+ }
287
+ .drop-overlay.show { display: flex; }
288
+ .drop-overlay .msg { color: var(--fg-bright); font-size: 18px; text-align: center; }
289
+ .drop-overlay .msg .sub { color: var(--fg-soft); font-size: 13px; margin-top: 6px; }
290
+
291
+ /* Toast */
292
+ .toast {
293
+ position: fixed; bottom: 22px; right: 22px;
294
+ background: var(--bg-elev3); border: 1px solid var(--border);
295
+ border-radius: 8px; padding: 12px 16px; color: var(--fg);
296
+ box-shadow: 0 10px 30px rgba(0,0,0,0.4);
297
+ transform: translateY(20px); opacity: 0;
298
+ transition: all .25s; pointer-events: none; max-width: 380px; z-index: 999;
299
+ }
300
+ .toast.show { transform: translateY(0); opacity: 1; }
301
+ .toast.err { border-color: var(--err-border); color: var(--err); background: var(--err-bg); }
302
+ .toast.ok { border-color: var(--ok-border); color: var(--ok); background: var(--ok-bg); }
303
+
304
+ /* Resize handles */
305
+ .resizer {
306
+ position: absolute; top: 0; bottom: 0; width: 4px; cursor: col-resize;
307
+ background: transparent; transition: background .12s; z-index: 5;
308
+ }
309
+ .resizer:hover, .resizer.dragging { background: var(--accent-bg); opacity: 0.4; }
310
+
311
+ .id-snippet { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: var(--fg-dim); }
312
+ .redacted-badge {
313
+ background: var(--err-bg); color: var(--err); border: 1px solid var(--err-border);
314
+ border-radius: 4px; padding: 1px 6px; font-size: 11px;
315
+ }
316
+ </style>
317
+ </head>
318
+ <body>
319
+
320
+ <div class="app">
321
+ <!-- ─── left rail ───────────────────────────────────────────────────── -->
322
+ <aside class="rail">
323
+ <div class="brand">
324
+ <span class="dot"></span> mnueron
325
+ <button class="theme-btn" id="theme-toggle" title="Toggle theme">◐</button>
326
+ </div>
327
+
328
+ <div class="stats" id="stats">
329
+ <div class="row"><span class="k">total memories</span><span class="v" id="stat-total">–</span></div>
330
+ <div class="row"><span class="k">conversations</span><span class="v" id="stat-threads">–</span></div>
331
+ <div class="row"><span class="k">namespaces</span><span class="v" id="stat-ns">–</span></div>
332
+ <div class="row"><span class="k">last activity</span><span class="v" id="stat-latest">–</span></div>
333
+ </div>
334
+
335
+ <div class="nslabel">namespaces</div>
336
+ <div id="ns-list"></div>
337
+
338
+ <div class="filters" id="filters" style="display:none">
339
+ <label>filter by source</label>
340
+ <div class="chip-row" id="source-chips"></div>
341
+ </div>
342
+ </aside>
343
+ <div class="resizer" id="resizer-left" style="grid-column:1; transform: translateX(-2px);"></div>
344
+
345
+ <!-- ─── middle: list ────────────────────────────────────────────────── -->
346
+ <main class="list">
347
+ <div class="toolbar">
348
+ <input class="search" id="search" type="text" placeholder="Search memories…" autocomplete="off" />
349
+ <span class="scope" id="scope">all namespaces</span>
350
+ <button class="btn" id="import-btn">Import…</button>
351
+ <input type="file" id="import-file" accept=".json" style="display:none" />
352
+ </div>
353
+
354
+ <div class="list-body" id="list">
355
+ <div class="empty">Loading…</div>
356
+ </div>
357
+ </main>
358
+ <div class="resizer" id="resizer-right"></div>
359
+
360
+ <!-- ─── right: detail ───────────────────────────────────────────────── -->
361
+ <aside class="detail" id="detail">
362
+ <div class="detail-empty">Select a memory to read it</div>
363
+ </aside>
364
+ </div>
365
+
366
+ <div class="drop-overlay" id="drop">
367
+ <div class="msg">
368
+ Drop a Claude or ChatGPT export <code>.json</code> here
369
+ <div class="sub">Auto-detects format. Imports into the "imported" namespace.</div>
370
+ </div>
371
+ </div>
372
+
373
+ <div class="toast" id="toast"></div>
374
+
375
+ <script>
376
+ // ─── state ────────────────────────────────────────────────────────────
377
+ const state = {
378
+ namespace: null, // null = all
379
+ query: '',
380
+ sourceFilters: new Set(),
381
+ items: [], // current list — array of {type:'thread', ...} or {type:'memory', ...}
382
+ namespaces: [],
383
+ selectedId: null, // either a parent_ref (for threads) or a memory id
384
+ theme: localStorage.getItem('theme') || 'dark',
385
+ };
386
+
387
+ document.documentElement.setAttribute('data-theme', state.theme);
388
+
389
+ // ─── api ──────────────────────────────────────────────────────────────
390
+ async function api(path, opts) {
391
+ const r = await fetch(path, opts);
392
+ if (!r.ok) {
393
+ let msg = `${r.status} ${r.statusText}`;
394
+ try { const j = await r.json(); if (j.error) msg = j.error; } catch {}
395
+ throw new Error(msg);
396
+ }
397
+ return r.json();
398
+ }
399
+
400
+ // ─── format helpers ──────────────────────────────────────────────────
401
+ function fmtDate(ts) {
402
+ if (!ts) return '–';
403
+ const d = new Date(ts);
404
+ const now = Date.now();
405
+ const diff = now - ts;
406
+ if (diff < 60_000) return 'just now';
407
+ if (diff < 3_600_000) return Math.floor(diff/60_000) + 'm';
408
+ if (diff < 86_400_000) return Math.floor(diff/3_600_000) + 'h';
409
+ if (diff < 604_800_000) return Math.floor(diff/86_400_000) + 'd';
410
+ return d.toISOString().slice(0, 10);
411
+ }
412
+ function escapeHtml(s) {
413
+ return String(s ?? '').replace(/[&<>"']/g, c => ({
414
+ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;',
415
+ }[c]));
416
+ }
417
+
418
+ // Minimal markdown → HTML. Just enough for chat content:
419
+ // **bold**, *italic*, `inline code`, ```code blocks```, # headings, lists, links
420
+ function renderMarkdown(src) {
421
+ if (!src) return '';
422
+ // Strip optional leading **Role:** header — we already render it as a pill
423
+ let text = src.replace(/^\*\*(?:User|Assistant|Claude|ChatGPT|Gemini|System|Human):\*\*\s*/i, '');
424
+ // Code blocks first (fenced)
425
+ const codeBlocks = [];
426
+ text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
427
+ const i = codeBlocks.length;
428
+ codeBlocks.push({ lang: lang || '', code });
429
+ return `CB${i}`;
430
+ });
431
+ // Escape
432
+ text = escapeHtml(text);
433
+ // Headings
434
+ text = text.replace(/^(#{1,6})\s+(.+)$/gm, (_, hashes, t) => {
435
+ const lvl = Math.min(hashes.length, 3);
436
+ return `<h${lvl}>${t}</h${lvl}>`;
437
+ });
438
+ // Bold + italic + inline code
439
+ text = text.replace(/`([^`\n]+?)`/g, '<code>$1</code>');
440
+ text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
441
+ text = text.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
442
+ // Links [text](url)
443
+ text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
444
+ // Unordered lists (simple)
445
+ text = text.replace(/(?:^|\n)((?:[-*]\s+.+\n?)+)/g, m => {
446
+ const items = m.trim().split(/\n/).map(l => l.replace(/^[-*]\s+/, '').trim());
447
+ return '\n<ul>' + items.map(i => `<li>${i}</li>`).join('') + '</ul>\n';
448
+ });
449
+ // Paragraph wrap (very rough)
450
+ text = text.split(/\n{2,}/).map(p => {
451
+ if (/^<(h\d|ul|ol|pre)/i.test(p.trim())) return p;
452
+ return `<p>${p.replace(/\n/g, '<br/>')}</p>`;
453
+ }).join('\n');
454
+ // Re-insert code blocks with Prism-friendly class
455
+ text = text.replace(/CB(\d+)/g, (_, i) => {
456
+ const { lang, code } = codeBlocks[+i];
457
+ const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
458
+ return `<pre><code${cls}>${escapeHtml(code)}</code></pre>`;
459
+ });
460
+ return text;
461
+ }
462
+
463
+ // ─── loaders ──────────────────────────────────────────────────────────
464
+ async function loadStats() {
465
+ try {
466
+ const s = await api('/api/stats');
467
+ document.getElementById('stat-total').textContent = s.total ?? '–';
468
+ document.getElementById('stat-ns').textContent = s.namespaces ?? '–';
469
+ document.getElementById('stat-latest').textContent = fmtDate(s.latest);
470
+ } catch (e) { toast('stats: ' + e.message, 'err'); }
471
+ }
472
+
473
+ async function loadNamespaces() {
474
+ try {
475
+ const list = await api('/api/namespaces');
476
+ state.namespaces = list;
477
+ renderNamespaces();
478
+ } catch (e) { toast('namespaces: ' + e.message, 'err'); }
479
+ }
480
+
481
+ function renderNamespaces() {
482
+ const root = document.getElementById('ns-list');
483
+ const items = [
484
+ { name: '__all__', count: state.namespaces.reduce((s, n) => s + n.count, 0), label: 'all namespaces' },
485
+ ...state.namespaces.map(n => ({ ...n, label: n.name })),
486
+ ];
487
+ root.innerHTML = items.map(n => {
488
+ const active = (n.name === '__all__' && state.namespace === null) || n.name === state.namespace;
489
+ return `<div class="ns ${active ? 'active' : ''}" data-ns="${escapeHtml(n.name)}">
490
+ <span class="name">${escapeHtml(n.label)}</span>
491
+ <span class="count">${n.count}</span>
492
+ </div>`;
493
+ }).join('');
494
+ root.querySelectorAll('.ns').forEach(el => {
495
+ el.addEventListener('click', () => {
496
+ const v = el.getAttribute('data-ns');
497
+ state.namespace = (v === '__all__') ? null : v;
498
+ renderNamespaces();
499
+ document.getElementById('scope').textContent = state.namespace || 'all namespaces';
500
+ loadList();
501
+ });
502
+ });
503
+ }
504
+
505
+ // Load either threads (no query) or search results (query set).
506
+ async function loadList() {
507
+ const q = state.query.trim();
508
+ if (q) {
509
+ // Search returns memories (chunks), not threads
510
+ const params = new URLSearchParams();
511
+ if (state.namespace) params.set('namespace', state.namespace);
512
+ params.set('q', q);
513
+ params.set('limit', '60');
514
+ try {
515
+ const list = await api('/api/memories?' + params.toString());
516
+ state.items = list.map(m => ({ type: 'memory', memory: m }));
517
+ renderList();
518
+ } catch (e) {
519
+ document.getElementById('list').innerHTML = `<div class="empty">error: ${escapeHtml(e.message)}</div>`;
520
+ }
521
+ } else {
522
+ // Browse mode — show threads (grouped)
523
+ const params = new URLSearchParams();
524
+ if (state.namespace) params.set('namespace', state.namespace);
525
+ params.set('limit', '200');
526
+ try {
527
+ const threads = await api('/api/threads?' + params.toString());
528
+ state.items = threads.map(t => ({ type: 'thread', thread: t }));
529
+ document.getElementById('stat-threads').textContent = threads.length;
530
+ renderList();
531
+ } catch (e) {
532
+ document.getElementById('list').innerHTML = `<div class="empty">error: ${escapeHtml(e.message)}</div>`;
533
+ }
534
+ }
535
+ }
536
+
537
+ function renderList() {
538
+ const root = document.getElementById('list');
539
+ if (state.items.length === 0) {
540
+ root.innerHTML = `<div class="empty">${state.query ? 'No matches.' : 'No memories yet.'}</div>`;
541
+ return;
542
+ }
543
+ root.innerHTML = state.items.map(it => {
544
+ if (it.type === 'thread') return renderThreadRow(it.thread);
545
+ return renderMemoryRow(it.memory);
546
+ }).join('');
547
+ root.querySelectorAll('.row').forEach(el => {
548
+ el.addEventListener('click', () => {
549
+ const id = el.getAttribute('data-id');
550
+ const kind = el.getAttribute('data-kind');
551
+ state.selectedId = id;
552
+ root.querySelectorAll('.row.active').forEach(e => e.classList.remove('active'));
553
+ el.classList.add('active');
554
+ if (kind === 'thread') loadThreadDetail(id);
555
+ else loadMemoryDetail(id);
556
+ });
557
+ });
558
+ }
559
+
560
+ function renderThreadRow(t) {
561
+ const active = state.selectedId === t.parent_ref;
562
+ const icon = t.has_chunks ? '💬' : '📝';
563
+ return `<div class="row ${active ? 'active' : ''}" data-id="${escapeHtml(t.parent_ref)}" data-kind="thread">
564
+ <div class="icon">${icon}</div>
565
+ <div class="title">${escapeHtml(t.title)}</div>
566
+ <div class="when">${fmtDate(t.last_at)}</div>
567
+ <div class="meta">
568
+ <span class="pill ns-pill">${escapeHtml(t.namespace)}</span>
569
+ ${t.has_chunks ? `<span>${t.count} turns</span>` : '<span>single note</span>'}
570
+ </div>
571
+ </div>`;
572
+ }
573
+ function renderMemoryRow(m) {
574
+ const active = state.selectedId === m.id;
575
+ const role = m.metadata?.role || (m.tags || []).find(t => t.startsWith('role:'))?.slice(5);
576
+ const roleClass = role === 'user' ? 'role-user' : role === 'assistant' ? 'role-assistant' : 'role-other';
577
+ const rolePill = role ? `<span class="pill role-pill ${roleClass}">${role}</span>` : '';
578
+ const score = (typeof m.score === 'number') ? `<span class="score">${m.score.toFixed(3)}</span>` : '';
579
+ const preview = (m.content_preview ?? m.content ?? '').replace(/^\*\*(?:User|Assistant|Claude|ChatGPT|Gemini|System|Human):\*\*\s*/i, '');
580
+ const tags = (m.tags || []).filter(t => !t.startsWith('role:') && t !== 'chunk').slice(0, 3)
581
+ .map(t => `<span class="pill tag">${escapeHtml(t)}</span>`).join(' ');
582
+ return `<div class="row ${active ? 'active' : ''}" data-id="${escapeHtml(m.id)}" data-kind="memory">
583
+ <div class="icon">${role === 'user' ? '👤' : role === 'assistant' ? '🤖' : '·'}</div>
584
+ <div class="title">${escapeHtml(preview.slice(0, 110))}</div>
585
+ <div class="when">${fmtDate(m.created_at)}${score}</div>
586
+ <div class="meta">
587
+ <span class="pill ns-pill">${escapeHtml(m.namespace)}</span>
588
+ ${rolePill}
589
+ ${tags}
590
+ </div>
591
+ </div>`;
592
+ }
593
+
594
+ // ─── detail loaders ───────────────────────────────────────────────────
595
+ async function loadThreadDetail(parentRef) {
596
+ const dEl = document.getElementById('detail');
597
+ dEl.innerHTML = `<div class="detail-empty">Loading…</div>`;
598
+ try {
599
+ const res = await api('/api/threads/' + encodeURIComponent(parentRef));
600
+ const chunks = res.chunks || [];
601
+ if (chunks.length === 0) {
602
+ dEl.innerHTML = `<div class="detail-empty">Empty thread</div>`;
603
+ return;
604
+ }
605
+ const first = chunks[0];
606
+ const title = extractTitle(first.content || '');
607
+ const lastAt = Math.max(...chunks.map(c => c.updated_at || c.created_at || 0));
608
+ const ns = first.namespace;
609
+ const headerHtml = `
610
+ <div class="detail-header">
611
+ <h2>${escapeHtml(title)}</h2>
612
+ <div class="sub">
613
+ <span class="pill ns-pill">${escapeHtml(ns)}</span>
614
+ ${chunks.length > 1 ? `${chunks.length} turns · ` : ''}${fmtDate(lastAt)}
615
+ </div>
616
+ <div class="actions">
617
+ <button class="btn" id="copy-ref">Copy parent_ref</button>
618
+ <button class="delete-btn" id="delete-thread">Delete thread</button>
619
+ </div>
620
+ </div>
621
+ <div class="detail-body">
622
+ <div class="bubbles">
623
+ ${chunks.map(c => renderBubble(c)).join('')}
624
+ </div>
625
+ </div>
626
+ `;
627
+ dEl.innerHTML = headerHtml;
628
+
629
+ document.getElementById('copy-ref').addEventListener('click', async () => {
630
+ await navigator.clipboard.writeText(parentRef);
631
+ toast('Copied parent_ref to clipboard.', 'ok');
632
+ });
633
+ document.getElementById('delete-thread').addEventListener('click', async () => {
634
+ if (!confirm(`Delete this entire ${chunks.length}-turn conversation?`)) return;
635
+ for (const c of chunks) {
636
+ try { await api('/api/memories/' + encodeURIComponent(c.id), { method: 'DELETE' }); } catch {}
637
+ }
638
+ toast(`Deleted ${chunks.length} memories.`, 'ok');
639
+ dEl.innerHTML = `<div class="detail-empty">Select a memory to read it</div>`;
640
+ state.selectedId = null;
641
+ await Promise.all([loadList(), loadNamespaces(), loadStats()]);
642
+ });
643
+
644
+ // Trigger Prism highlight if available
645
+ if (window.Prism) try { window.Prism.highlightAllUnder(dEl); } catch {}
646
+ } catch (e) {
647
+ dEl.innerHTML = `<div class="detail-empty">error: ${escapeHtml(e.message)}</div>`;
648
+ }
649
+ }
650
+
651
+ async function loadMemoryDetail(id) {
652
+ const dEl = document.getElementById('detail');
653
+ dEl.innerHTML = `<div class="detail-empty">Loading…</div>`;
654
+ try {
655
+ const m = await api('/api/memories/' + encodeURIComponent(id));
656
+ const title = extractTitle(m.content || '');
657
+ const parentRef = m.metadata?.parent_ref ?? m.source_ref;
658
+ const role = m.metadata?.role;
659
+ const redacted = m.metadata?.redacted_count;
660
+ const tags = (m.tags || []).filter(t => !t.startsWith('role:') && t !== 'chunk');
661
+ const headerHtml = `
662
+ <div class="detail-header">
663
+ <h2>${escapeHtml(title)}</h2>
664
+ <div class="sub">
665
+ <span class="pill ns-pill">${escapeHtml(m.namespace)}</span>
666
+ ${role ? `<span class="pill role-pill role-${role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : 'other'}">${role}</span>` : ''}
667
+ ${tags.map(t => `<span class="pill tag">${escapeHtml(t)}</span>`).join(' ')}
668
+ <span style="color: var(--fg-dim);">${fmtDate(m.created_at)}</span>
669
+ <span class="id-snippet">${escapeHtml(m.id.slice(0,8))}</span>
670
+ ${redacted ? `<span class="redacted-badge">${redacted} secret${redacted>1?'s':''} redacted</span>` : ''}
671
+ </div>
672
+ <div class="actions">
673
+ ${parentRef ? `<button class="btn" id="show-thread">Show full thread</button>` : ''}
674
+ <button class="delete-btn" id="delete-memory">Delete</button>
675
+ </div>
676
+ </div>
677
+ <div class="detail-body">
678
+ <div class="single-memory bubble ${role === 'user' ? 'role-user' : role === 'assistant' ? 'role-assistant' : ''}">
679
+ <div class="b-body">${renderMarkdown(m.content)}</div>
680
+ </div>
681
+ </div>
682
+ `;
683
+ dEl.innerHTML = headerHtml;
684
+ if (parentRef) {
685
+ document.getElementById('show-thread').addEventListener('click', () => {
686
+ state.selectedId = parentRef;
687
+ loadThreadDetail(parentRef);
688
+ });
689
+ }
690
+ document.getElementById('delete-memory').addEventListener('click', async () => {
691
+ if (!confirm('Delete this memory?')) return;
692
+ await api('/api/memories/' + encodeURIComponent(m.id), { method: 'DELETE' });
693
+ toast('Deleted.', 'ok');
694
+ dEl.innerHTML = `<div class="detail-empty">Select a memory to read it</div>`;
695
+ state.selectedId = null;
696
+ await Promise.all([loadList(), loadNamespaces(), loadStats()]);
697
+ });
698
+ if (window.Prism) try { window.Prism.highlightAllUnder(dEl); } catch {}
699
+ } catch (e) {
700
+ dEl.innerHTML = `<div class="detail-empty">error: ${escapeHtml(e.message)}</div>`;
701
+ }
702
+ }
703
+
704
+ function renderBubble(m) {
705
+ const role = m.metadata?.role || m.role;
706
+ const roleClass = role === 'user' ? 'role-user' : role === 'assistant' ? 'role-assistant' : '';
707
+ const roleLabel = role === 'user' ? 'User' : role === 'assistant' ? 'Assistant' : (role || '·');
708
+ const pillClass = role === 'user' ? 'role-user' : role === 'assistant' ? 'role-assistant' : 'role-other';
709
+ return `<div class="bubble ${roleClass}">
710
+ <div class="b-head">
711
+ <span class="pill role-pill ${pillClass}">${escapeHtml(roleLabel)}</span>
712
+ <span class="when">${fmtDate(m.created_at)}</span>
713
+ </div>
714
+ <div class="b-body">${renderMarkdown(m.content)}</div>
715
+ </div>`;
716
+ }
717
+
718
+ function extractTitle(content) {
719
+ if (!content) return '(empty)';
720
+ const t = content.trim();
721
+ const h = t.slice(0, 600).match(/^#{1,3}\s+(.+)$/m);
722
+ if (h) return h[1].trim().slice(0, 100);
723
+ const firstLine = t.split(/\r?\n/).map(s => s.trim()).find(Boolean) ?? t;
724
+ const noRole = firstLine.replace(/^\*\*(?:User|Assistant|Claude|ChatGPT|Gemini|System|Human):\*\*\s*/i, '');
725
+ return noRole.length > 100 ? noRole.slice(0, 100) + '…' : noRole;
726
+ }
727
+
728
+ // ─── import ───────────────────────────────────────────────────────────
729
+ async function importFile(file) {
730
+ try {
731
+ const text = await file.text();
732
+ const ns = prompt('Import into namespace:', 'imported') || 'imported';
733
+ toast(`Reading ${file.name}…`);
734
+ const res = await api('/api/import', {
735
+ method: 'POST',
736
+ headers: { 'Content-Type': 'application/json' },
737
+ body: JSON.stringify({ content: text, namespace: ns }),
738
+ });
739
+ toast(`Imported ${res.saved} memories (${res.format}) into "${res.namespace}".`, 'ok');
740
+ await Promise.all([loadList(), loadNamespaces(), loadStats()]);
741
+ } catch (e) { toast('import failed: ' + e.message, 'err'); }
742
+ }
743
+
744
+ // ─── toast ────────────────────────────────────────────────────────────
745
+ let toastTimer;
746
+ function toast(msg, kind = '') {
747
+ const el = document.getElementById('toast');
748
+ el.textContent = msg;
749
+ el.className = 'toast show ' + kind;
750
+ clearTimeout(toastTimer);
751
+ toastTimer = setTimeout(() => { el.className = 'toast ' + kind; }, 3500);
752
+ }
753
+
754
+ // ─── events ───────────────────────────────────────────────────────────
755
+ const searchEl = document.getElementById('search');
756
+ let searchTimer;
757
+ searchEl.addEventListener('input', () => {
758
+ state.query = searchEl.value;
759
+ clearTimeout(searchTimer);
760
+ searchTimer = setTimeout(loadList, 200);
761
+ });
762
+
763
+ document.getElementById('import-btn').addEventListener('click', () => {
764
+ document.getElementById('import-file').click();
765
+ });
766
+ document.getElementById('import-file').addEventListener('change', e => {
767
+ const f = e.target.files[0];
768
+ if (f) importFile(f);
769
+ e.target.value = '';
770
+ });
771
+
772
+ // Drag-drop import
773
+ const drop = document.getElementById('drop');
774
+ let dragCounter = 0;
775
+ window.addEventListener('dragenter', e => { e.preventDefault(); dragCounter++; drop.classList.add('show'); });
776
+ window.addEventListener('dragover', e => e.preventDefault());
777
+ window.addEventListener('dragleave', e => { e.preventDefault(); dragCounter = Math.max(0, dragCounter - 1); if (dragCounter === 0) drop.classList.remove('show'); });
778
+ window.addEventListener('drop', e => {
779
+ e.preventDefault(); dragCounter = 0; drop.classList.remove('show');
780
+ const f = e.dataTransfer.files[0];
781
+ if (f && f.name.endsWith('.json')) importFile(f);
782
+ else toast('Drop a .json export file.', 'err');
783
+ });
784
+
785
+ // Theme toggle
786
+ document.getElementById('theme-toggle').addEventListener('click', () => {
787
+ state.theme = state.theme === 'dark' ? 'light' : 'dark';
788
+ localStorage.setItem('theme', state.theme);
789
+ document.documentElement.setAttribute('data-theme', state.theme);
790
+ });
791
+
792
+ // Keyboard shortcuts
793
+ window.addEventListener('keydown', e => {
794
+ if (e.key === '/' && document.activeElement !== searchEl) {
795
+ e.preventDefault(); searchEl.focus();
796
+ }
797
+ });
798
+
799
+ // Pane resize (persist to localStorage)
800
+ function setupResize(handleId, cssVar, defaultPx) {
801
+ const stored = parseInt(localStorage.getItem(cssVar) || '', 10);
802
+ if (stored && stored > 120 && stored < 1000) document.documentElement.style.setProperty(cssVar, `${stored}px`);
803
+ const handle = document.getElementById(handleId);
804
+ if (!handle) return;
805
+ let startX, startWidth;
806
+ handle.addEventListener('mousedown', e => {
807
+ startX = e.clientX;
808
+ const cur = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
809
+ startWidth = parseInt(cur || `${defaultPx}`, 10);
810
+ handle.classList.add('dragging');
811
+ const onMove = ev => {
812
+ const sign = cssVar === '--rail-w' ? 1 : -1;
813
+ const delta = (ev.clientX - startX) * sign;
814
+ const next = Math.max(180, Math.min(720, startWidth + delta));
815
+ document.documentElement.style.setProperty(cssVar, `${next}px`);
816
+ };
817
+ const onUp = () => {
818
+ window.removeEventListener('mousemove', onMove);
819
+ window.removeEventListener('mouseup', onUp);
820
+ handle.classList.remove('dragging');
821
+ const cur = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
822
+ localStorage.setItem(cssVar, parseInt(cur, 10).toString());
823
+ };
824
+ window.addEventListener('mousemove', onMove);
825
+ window.addEventListener('mouseup', onUp);
826
+ });
827
+ }
828
+ setupResize('resizer-left', '--rail-w', 260);
829
+ setupResize('resizer-right', '--detail-w', 480);
830
+
831
+ // ─── boot ─────────────────────────────────────────────────────────────
832
+ loadStats();
833
+ loadNamespaces();
834
+ loadList();
835
+ setInterval(loadStats, 30_000);
836
+ </script>
837
+ </body>
838
+ </html>