nodebench-mcp 2.25.0 → 2.27.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 (86) hide show
  1. package/NODEBENCH_AGENTS.md +5 -4
  2. package/README.md +145 -16
  3. package/dist/__tests__/architectComplex.test.js +3 -5
  4. package/dist/__tests__/architectComplex.test.js.map +1 -1
  5. package/dist/__tests__/batchAutopilot.test.d.ts +8 -0
  6. package/dist/__tests__/batchAutopilot.test.js +218 -0
  7. package/dist/__tests__/batchAutopilot.test.js.map +1 -0
  8. package/dist/__tests__/cliSubcommands.test.d.ts +1 -0
  9. package/dist/__tests__/cliSubcommands.test.js +138 -0
  10. package/dist/__tests__/cliSubcommands.test.js.map +1 -0
  11. package/dist/__tests__/evalHarness.test.js +1 -1
  12. package/dist/__tests__/forecastingDogfood.test.d.ts +9 -0
  13. package/dist/__tests__/forecastingDogfood.test.js +284 -0
  14. package/dist/__tests__/forecastingDogfood.test.js.map +1 -0
  15. package/dist/__tests__/forecastingScoring.test.d.ts +9 -0
  16. package/dist/__tests__/forecastingScoring.test.js +202 -0
  17. package/dist/__tests__/forecastingScoring.test.js.map +1 -0
  18. package/dist/__tests__/localDashboard.test.d.ts +1 -0
  19. package/dist/__tests__/localDashboard.test.js +226 -0
  20. package/dist/__tests__/localDashboard.test.js.map +1 -0
  21. package/dist/__tests__/multiHopDogfood.test.js +11 -11
  22. package/dist/__tests__/multiHopDogfood.test.js.map +1 -1
  23. package/dist/__tests__/openclawDogfood.test.d.ts +23 -0
  24. package/dist/__tests__/openclawDogfood.test.js +535 -0
  25. package/dist/__tests__/openclawDogfood.test.js.map +1 -0
  26. package/dist/__tests__/openclawMessaging.test.d.ts +14 -0
  27. package/dist/__tests__/openclawMessaging.test.js +232 -0
  28. package/dist/__tests__/openclawMessaging.test.js.map +1 -0
  29. package/dist/__tests__/presetRealWorldBench.test.js +0 -2
  30. package/dist/__tests__/presetRealWorldBench.test.js.map +1 -1
  31. package/dist/__tests__/tools.test.js +9 -157
  32. package/dist/__tests__/tools.test.js.map +1 -1
  33. package/dist/__tests__/toolsetGatingEval.test.js +0 -2
  34. package/dist/__tests__/toolsetGatingEval.test.js.map +1 -1
  35. package/dist/__tests__/traceabilityDogfood.test.d.ts +12 -0
  36. package/dist/__tests__/traceabilityDogfood.test.js +241 -0
  37. package/dist/__tests__/traceabilityDogfood.test.js.map +1 -0
  38. package/dist/__tests__/webmcpTools.test.d.ts +7 -0
  39. package/dist/__tests__/webmcpTools.test.js +195 -0
  40. package/dist/__tests__/webmcpTools.test.js.map +1 -0
  41. package/dist/dashboard/briefHtml.d.ts +20 -0
  42. package/dist/dashboard/briefHtml.js +1000 -0
  43. package/dist/dashboard/briefHtml.js.map +1 -0
  44. package/dist/dashboard/briefServer.d.ts +18 -0
  45. package/dist/dashboard/briefServer.js +320 -0
  46. package/dist/dashboard/briefServer.js.map +1 -0
  47. package/dist/dashboard/html.js +1470 -1230
  48. package/dist/dashboard/html.js.map +1 -1
  49. package/dist/dashboard/server.js +166 -41
  50. package/dist/dashboard/server.js.map +1 -1
  51. package/dist/index.js +210 -14
  52. package/dist/index.js.map +1 -1
  53. package/dist/tools/critterTools.js +4 -0
  54. package/dist/tools/critterTools.js.map +1 -1
  55. package/dist/tools/forecastingTools.d.ts +11 -0
  56. package/dist/tools/forecastingTools.js +616 -0
  57. package/dist/tools/forecastingTools.js.map +1 -0
  58. package/dist/tools/localDashboardTools.d.ts +8 -0
  59. package/dist/tools/localDashboardTools.js +332 -0
  60. package/dist/tools/localDashboardTools.js.map +1 -0
  61. package/dist/tools/metaTools.js +170 -1
  62. package/dist/tools/metaTools.js.map +1 -1
  63. package/dist/tools/openclawTools.d.ts +11 -0
  64. package/dist/tools/openclawTools.js +1017 -0
  65. package/dist/tools/openclawTools.js.map +1 -0
  66. package/dist/tools/overstoryTools.d.ts +14 -0
  67. package/dist/tools/overstoryTools.js +426 -0
  68. package/dist/tools/overstoryTools.js.map +1 -0
  69. package/dist/tools/progressiveDiscoveryTools.js +50 -115
  70. package/dist/tools/progressiveDiscoveryTools.js.map +1 -1
  71. package/dist/tools/selfEvalTools.js +8 -1
  72. package/dist/tools/selfEvalTools.js.map +1 -1
  73. package/dist/tools/sessionMemoryTools.js +14 -2
  74. package/dist/tools/sessionMemoryTools.js.map +1 -1
  75. package/dist/tools/toolRegistry.d.ts +1 -15
  76. package/dist/tools/toolRegistry.js +243 -228
  77. package/dist/tools/toolRegistry.js.map +1 -1
  78. package/dist/tools/visualQaTools.d.ts +2 -0
  79. package/dist/tools/visualQaTools.js +1088 -0
  80. package/dist/tools/visualQaTools.js.map +1 -0
  81. package/dist/tools/webmcpTools.d.ts +16 -0
  82. package/dist/tools/webmcpTools.js +703 -0
  83. package/dist/tools/webmcpTools.js.map +1 -0
  84. package/dist/toolsetRegistry.js +6 -2
  85. package/dist/toolsetRegistry.js.map +1 -1
  86. package/package.json +2 -2
@@ -0,0 +1,1000 @@
1
+ /**
2
+ * NodeBench MCP — Daily Brief Dashboard HTML
3
+ *
4
+ * Single inline HTML string, zero build step. Tailwind CDN, Inter font,
5
+ * dark-mode-first design matching the v4 design system from html.ts.
6
+ *
7
+ * 3 views (tab-based):
8
+ * 1. Brief — metrics, features, source summary
9
+ * 2. Narrative Lanes — threads by phase, events, claims
10
+ * 3. Ops — sync status, tool frequency, verification cycles
11
+ *
12
+ * Privacy mode (camera presence detection):
13
+ * - Opt-in via toggle in header
14
+ * - Pixel standard deviation → presence detection
15
+ * - No face detection, no identity, no image storage
16
+ * - Public mode: sanitize entity names, hide task results, hide mailbox
17
+ *
18
+ * Auto-refresh: 30s poll with hash-based diffing.
19
+ */
20
+ export function getBriefDashboardHtml() {
21
+ return `<!DOCTYPE html>
22
+ <html lang="en" class="dark">
23
+ <head>
24
+ <meta charset="UTF-8">
25
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
26
+ <title>NodeBench Daily Brief</title>
27
+ <link rel="preconnect" href="https://fonts.googleapis.com">
28
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
29
+ <script src="https://cdn.tailwindcss.com"><\/script>
30
+ <script>
31
+ tailwind.config = {
32
+ darkMode: 'class',
33
+ theme: {
34
+ extend: {
35
+ fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] },
36
+ colors: {
37
+ surface: { 0: '#09090b', 1: '#111113', 2: '#18181b', 3: '#1f1f23' },
38
+ border: { DEFAULT: '#27272a', subtle: '#1e1e22', focus: '#6366f1' },
39
+ accent: { DEFAULT: '#818cf8', bright: '#a5b4fc', dim: '#4f46e5' },
40
+ ok: '#34d399', warn: '#fbbf24', err: '#f87171',
41
+ }
42
+ }
43
+ }
44
+ }
45
+ <\/script>
46
+ <style>
47
+ :root {
48
+ --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px; --sp-6: 32px;
49
+ --border-base: #27272a; --border-hover: #3f3f46; --border-accent: #6366f1;
50
+ --surface-1: #111113; --surface-2: #18181b;
51
+ --gradient-accent: linear-gradient(135deg, #1e1b4b, #312e81);
52
+ --shadow-card: 0 0 0 1px rgba(99,102,241,.15), 0 1px 3px rgba(0,0,0,.4);
53
+ --shadow-card-hover: 0 0 0 1px rgba(99,102,241,.35), 0 4px 12px rgba(0,0,0,.5);
54
+ --radius-sm: 6px; --radius-md: 8px; --radius-lg: 10px; --radius-xl: 12px;
55
+ --transition-fast: .15s ease; --transition-base: .2s ease;
56
+ }
57
+ *, *::before, *::after { box-sizing: border-box; }
58
+ body { font-family: 'Inter', system-ui, sans-serif; -webkit-font-smoothing: antialiased; margin: 0; background: #09090b; color: #fafafa; }
59
+ .sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
60
+
61
+ /* ── Animations ─────────────────────────────────────── */
62
+ @keyframes fadeUp { from { opacity:0; transform: translateY(8px); } to { opacity:1; transform: translateY(0); } }
63
+ .fade-up { animation: fadeUp .35s ease-out both; }
64
+ .fade-up:nth-child(2) { animation-delay: 50ms; }
65
+ .fade-up:nth-child(3) { animation-delay: 100ms; }
66
+ .fade-up:nth-child(n+4) { animation-delay: 150ms; }
67
+ @keyframes pulse2 { 0%,100%{opacity:1} 50%{opacity:.35} }
68
+ .pulse-live { animation: pulse2 2s infinite; }
69
+ @keyframes shimmer { 0%,100%{background-position:200% 0} 50%{background-position:0% 0} }
70
+ .skeleton { border-radius: var(--radius-lg); background: linear-gradient(90deg, var(--surface-2) 25%, #1f1f23 50%, var(--surface-2) 75%); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; }
71
+
72
+ @media (prefers-reduced-motion: reduce) {
73
+ *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
74
+ .fade-up { animation: none; opacity: 1; transform: none; }
75
+ .pulse-live { animation: none; }
76
+ .skeleton { animation: none; }
77
+ }
78
+
79
+ /* ── Tab Bar ────────────────────────────────────────── */
80
+ .tab-bar { display:flex; gap:2px; padding:2px; background:var(--surface-1); border-radius:var(--radius-lg); border:1px solid var(--border-base); }
81
+ .tab-btn {
82
+ flex:1; padding:8px 16px; border-radius:var(--radius-md); border:none; background:transparent;
83
+ color:#71717a; font-size:12px; font-weight:600; cursor:pointer; transition: all var(--transition-fast);
84
+ font-family: inherit; display:flex; align-items:center; justify-content:center; gap:6px;
85
+ }
86
+ .tab-btn:hover { color:#d4d4d8; background:var(--surface-2); }
87
+ .tab-btn.active { background:var(--gradient-accent); color:#c7d2fe; box-shadow:var(--shadow-card); }
88
+ .tab-btn:focus-visible { outline:2px solid var(--border-accent); outline-offset:2px; }
89
+ .tab-btn svg { width:14px; height:14px; }
90
+ .tab-panel { display:none; }
91
+ .tab-panel.active { display:block; }
92
+
93
+ /* ── Metric Cards ──────────────────────────────────── */
94
+ .metric-card {
95
+ background:var(--surface-1); border:1px solid var(--border-base); border-radius:var(--radius-xl);
96
+ padding:16px 20px; transition: border-color var(--transition-base), box-shadow var(--transition-base);
97
+ }
98
+ .metric-card:hover { border-color:var(--border-hover); box-shadow:var(--shadow-card-hover); }
99
+ .metric-value { font-size:28px; font-weight:800; letter-spacing:-.02em; color:#fafafa; }
100
+ .metric-label { font-size:11px; font-weight:500; color:#71717a; text-transform:uppercase; letter-spacing:.05em; margin-top:2px; }
101
+ .metric-delta { font-size:11px; font-weight:600; margin-top:4px; }
102
+ .metric-delta.up { color:#34d399; }
103
+ .metric-delta.down { color:#f87171; }
104
+
105
+ /* ── Phase Lanes ───────────────────────────────────── */
106
+ .phase-lane {
107
+ border-left:3px solid var(--border-base); padding-left:16px; margin-bottom:20px;
108
+ transition: border-color var(--transition-base);
109
+ }
110
+ .phase-lane.emerging { border-left-color:#818cf8; }
111
+ .phase-lane.escalating { border-left-color:#fbbf24; }
112
+ .phase-lane.climax { border-left-color:#f87171; }
113
+ .phase-lane.resolution { border-left-color:#34d399; }
114
+ .phase-lane.dormant { border-left-color:#52525b; }
115
+ .phase-label {
116
+ font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.08em;
117
+ padding:2px 8px; border-radius:4px; display:inline-block; margin-bottom:8px;
118
+ }
119
+ .phase-label.emerging { background:#1e1b4b; color:#a5b4fc; }
120
+ .phase-label.escalating { background:#451a03; color:#fde68a; }
121
+ .phase-label.climax { background:#450a0a; color:#fca5a5; }
122
+ .phase-label.resolution { background:#052e16; color:#86efac; }
123
+ .phase-label.dormant { background:#18181b; color:#71717a; }
124
+
125
+ /* ── Thread Card ───────────────────────────────────── */
126
+ .thread-card {
127
+ background:var(--surface-1); border:1px solid var(--border-base); border-radius:var(--radius-lg);
128
+ padding:12px 16px; margin-bottom:8px; cursor:pointer;
129
+ transition: border-color var(--transition-base), box-shadow var(--transition-base);
130
+ }
131
+ .thread-card:hover { border-color:var(--border-hover); box-shadow:var(--shadow-card); }
132
+ .thread-card:focus-visible { outline:2px solid var(--border-accent); outline-offset:2px; }
133
+ .thread-name { font-size:13px; font-weight:600; color:#fafafa; }
134
+ .thread-thesis { font-size:11px; color:#a1a1aa; margin-top:4px; line-height:1.5; }
135
+ .thread-meta { display:flex; align-items:center; gap:8px; margin-top:8px; flex-wrap:wrap; }
136
+ .thread-tag { font-size:10px; padding:2px 6px; border-radius:4px; background:var(--surface-2); color:#71717a; border:1px solid #1e1e22; }
137
+ .thread-badge { font-size:10px; font-weight:600; padding:2px 8px; border-radius:9px; }
138
+
139
+ /* ── Event Timeline ─────────────────────────────────── */
140
+ .event-item { display:flex; gap:12px; padding:8px 0; border-bottom:1px solid #1e1e22; }
141
+ .event-item:last-child { border-bottom:none; }
142
+ .event-dot { width:8px; height:8px; border-radius:50%; margin-top:5px; flex-shrink:0; }
143
+ .event-dot.minor { background:#52525b; }
144
+ .event-dot.moderate { background:#818cf8; }
145
+ .event-dot.major { background:#fbbf24; }
146
+ .event-dot.plot_twist { background:#f87171; }
147
+ .event-headline { font-size:12px; color:#d4d4d8; font-weight:500; }
148
+ .event-time { font-size:10px; color:#52525b; margin-top:2px; }
149
+
150
+ /* ── Sync Status ───────────────────────────────────── */
151
+ .sync-badge { font-size:10px; font-weight:600; padding:3px 10px; border-radius:9px; }
152
+ .sync-badge.success { background:#052e16; color:#86efac; }
153
+ .sync-badge.error { background:#450a0a; color:#fca5a5; }
154
+ .sync-badge.running { background:#1e1b4b; color:#a5b4fc; }
155
+
156
+ /* ── Privacy Shield ────────────────────────────────── */
157
+ .privacy-toggle {
158
+ width:36px; height:20px; border-radius:10px; border:1px solid var(--border-base);
159
+ background:var(--surface-2); cursor:pointer; position:relative; transition: all var(--transition-fast);
160
+ }
161
+ .privacy-toggle.on { background:#4f46e5; border-color:var(--border-accent); }
162
+ .privacy-toggle .knob {
163
+ width:14px; height:14px; border-radius:50%; background:#fafafa;
164
+ position:absolute; top:2px; left:2px; transition: transform var(--transition-fast);
165
+ }
166
+ .privacy-toggle.on .knob { transform:translateX(16px); }
167
+ .privacy-indicator { display:none; }
168
+ .privacy-indicator.active { display:flex; align-items:center; gap:4px; font-size:10px; color:#fbbf24; font-weight:600; }
169
+
170
+ /* ── Confidence Bar ─────────────────────────────────── */
171
+ .conf-bar { height:4px; border-radius:2px; background:var(--surface-2); overflow:hidden; }
172
+ .conf-fill { height:100%; border-radius:2px; transition: width var(--transition-base); }
173
+
174
+ /* ── Sparkline ──────────────────────────────────────── */
175
+ .sparkline { display:flex; align-items:end; gap:2px; height:32px; }
176
+ .spark-bar { flex:1; min-width:4px; border-radius:2px 2px 0 0; background:#4f46e5; transition: height var(--transition-base); }
177
+
178
+ /* ── Scrollable areas ──────────────────────────────── */
179
+ .scroll-area { max-height:400px; overflow-y:auto; scrollbar-width:thin; scrollbar-color:var(--border-base) transparent; }
180
+ .scroll-area::-webkit-scrollbar { width:6px; }
181
+ .scroll-area::-webkit-scrollbar-track { background:transparent; }
182
+ .scroll-area::-webkit-scrollbar-thumb { background:var(--border-base); border-radius:3px; }
183
+
184
+ /* ── Gauge (SVG arc) ───────────────────────────────── */
185
+ .gauge-ring { fill:none; stroke-width:6; stroke-linecap:round; }
186
+ .gauge-bg { stroke:var(--border-base); }
187
+ .gauge-fg { transition: stroke-dashoffset .6s ease; }
188
+
189
+ /* ── Responsive ─────────────────────────────────────── */
190
+ @media (max-width: 640px) {
191
+ .metric-grid { grid-template-columns: 1fr 1fr !important; }
192
+ .tab-btn { font-size:11px; padding:6px 8px; }
193
+ .tab-btn span.label-text { display:none; }
194
+ }
195
+ </style>
196
+ </head>
197
+ <body>
198
+ <!-- Hidden video for privacy detection (opt-in only) -->
199
+ <video id="privacyVideo" style="display:none" playsinline muted></video>
200
+ <canvas id="privacyCanvas" style="display:none" width="64" height="48"></canvas>
201
+
202
+ <div class="max-w-4xl mx-auto px-4 py-6" id="app">
203
+
204
+ <!-- ── Header ──────────────────────────────────────── -->
205
+ <header class="flex items-center justify-between mb-6">
206
+ <div class="flex items-center gap-3">
207
+ <div class="w-8 h-8 rounded-lg flex items-center justify-center" style="background:var(--gradient-accent)">
208
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#c7d2fe" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
209
+ </div>
210
+ <div>
211
+ <h1 class="text-sm font-bold text-zinc-100">Daily Brief</h1>
212
+ <p class="text-[10px] text-zinc-500" id="lastRefresh">Loading...</p>
213
+ </div>
214
+ </div>
215
+ <div class="flex items-center gap-3">
216
+ <div id="privacyStatus" class="privacy-indicator" role="status" aria-live="polite">
217
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
218
+ <span>Public mode</span>
219
+ </div>
220
+ <label class="flex items-center gap-2 cursor-pointer" title="Camera privacy detection">
221
+ <span class="text-[10px] text-zinc-500">Privacy</span>
222
+ <button id="privacyToggle" class="privacy-toggle" role="switch" aria-checked="false" aria-label="Enable camera privacy detection">
223
+ <div class="knob"></div>
224
+ </button>
225
+ </label>
226
+ </div>
227
+ </header>
228
+
229
+ <!-- ── Tab Bar ──────────────────────────────────────── -->
230
+ <nav class="tab-bar mb-6" role="tablist" aria-label="Dashboard views">
231
+ <button class="tab-btn active" role="tab" aria-selected="true" aria-controls="panel-brief" id="tab-brief" data-tab="brief">
232
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
233
+ <span class="label-text">Brief</span>
234
+ </button>
235
+ <button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-narrative" id="tab-narrative" data-tab="narrative">
236
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
237
+ <span class="label-text">Narrative</span>
238
+ </button>
239
+ <button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-ops" id="tab-ops" data-tab="ops">
240
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
241
+ <span class="label-text">Ops</span>
242
+ </button>
243
+ </nav>
244
+
245
+ <!-- ══════════════════════════════════════════════════ -->
246
+ <!-- PANEL: BRIEF -->
247
+ <!-- ══════════════════════════════════════════════════ -->
248
+ <div id="panel-brief" class="tab-panel active" role="tabpanel" aria-labelledby="tab-brief">
249
+ <!-- Date Picker -->
250
+ <div class="flex items-center gap-2 mb-4 overflow-x-auto pb-1" id="datePicker" role="group" aria-label="Select date"></div>
251
+
252
+ <!-- Gauge + Metrics -->
253
+ <div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-6 metric-grid" id="metricsGrid">
254
+ <div class="metric-card flex items-center justify-center" id="gaugeCard">
255
+ <div class="text-center">
256
+ <svg width="80" height="80" viewBox="0 0 80 80" id="readinessGauge" aria-label="Tech readiness gauge">
257
+ <circle class="gauge-ring gauge-bg" cx="40" cy="40" r="34"/>
258
+ <circle class="gauge-ring gauge-fg" cx="40" cy="40" r="34" stroke="#818cf8"
259
+ stroke-dasharray="213.6" stroke-dashoffset="213.6"
260
+ transform="rotate(-90 40 40)" id="gaugeFg"/>
261
+ <text x="40" y="44" text-anchor="middle" font-size="18" font-weight="800" fill="#fafafa" id="gaugeText">--</text>
262
+ </svg>
263
+ <div class="metric-label mt-1">Readiness</div>
264
+ </div>
265
+ </div>
266
+ <div class="metric-card fade-up">
267
+ <div class="metric-value" id="metricThreads">--</div>
268
+ <div class="metric-label">Threads</div>
269
+ </div>
270
+ <div class="metric-card fade-up">
271
+ <div class="metric-value" id="metricEvents">--</div>
272
+ <div class="metric-label">Events</div>
273
+ </div>
274
+ <div class="metric-card fade-up">
275
+ <div class="metric-value" id="metricSources">--</div>
276
+ <div class="metric-label">Sources</div>
277
+ </div>
278
+ </div>
279
+
280
+ <!-- Source Summary -->
281
+ <section id="sourceSummarySection" class="mb-6 hidden">
282
+ <h2 class="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3">Source Summary</h2>
283
+ <div id="sourceSummaryGrid" class="grid grid-cols-2 md:grid-cols-4 gap-2"></div>
284
+ </section>
285
+
286
+ <!-- Features -->
287
+ <section id="featuresSection" class="mb-6 hidden">
288
+ <h2 class="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3">Features</h2>
289
+ <div id="featuresList" class="space-y-2"></div>
290
+ </section>
291
+
292
+ <!-- Task Results -->
293
+ <section id="taskResultsSection" class="mb-6 hidden" data-sensitive="true">
294
+ <h2 class="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3">Task Results</h2>
295
+ <div id="taskResultsList" class="space-y-2"></div>
296
+ </section>
297
+ </div>
298
+
299
+ <!-- ══════════════════════════════════════════════════ -->
300
+ <!-- PANEL: NARRATIVE -->
301
+ <!-- ══════════════════════════════════════════════════ -->
302
+ <div id="panel-narrative" class="tab-panel" role="tabpanel" aria-labelledby="tab-narrative">
303
+ <!-- Phase filter -->
304
+ <div class="flex items-center gap-2 mb-4 flex-wrap" id="phaseFilter" role="group" aria-label="Filter by phase">
305
+ <button class="tab-btn text-[11px] py-1 px-3 rounded-full active" data-phase="all" style="flex:0">All</button>
306
+ <button class="tab-btn text-[11px] py-1 px-3 rounded-full" data-phase="emerging" style="flex:0">Emerging</button>
307
+ <button class="tab-btn text-[11px] py-1 px-3 rounded-full" data-phase="escalating" style="flex:0">Escalating</button>
308
+ <button class="tab-btn text-[11px] py-1 px-3 rounded-full" data-phase="climax" style="flex:0">Climax</button>
309
+ <button class="tab-btn text-[11px] py-1 px-3 rounded-full" data-phase="resolution" style="flex:0">Resolution</button>
310
+ <button class="tab-btn text-[11px] py-1 px-3 rounded-full" data-phase="dormant" style="flex:0">Dormant</button>
311
+ </div>
312
+
313
+ <!-- Threads container -->
314
+ <div id="narrativeLanes" class="space-y-4">
315
+ <div class="skeleton h-32 mb-3"></div>
316
+ <div class="skeleton h-32 mb-3"></div>
317
+ </div>
318
+
319
+ <!-- Thread Detail Drawer -->
320
+ <div id="threadDrawer" class="hidden fixed inset-y-0 right-0 w-full max-w-md z-50" style="background:rgba(0,0,0,.6)">
321
+ <div class="h-full overflow-y-auto" style="background:var(--surface-1); border-left:1px solid var(--border-base);">
322
+ <div class="p-5">
323
+ <div class="flex items-center justify-between mb-4">
324
+ <h3 class="text-sm font-bold text-zinc-100" id="drawerTitle">Thread</h3>
325
+ <button id="drawerClose" class="text-zinc-500 hover:text-zinc-200 text-lg" aria-label="Close drawer">&times;</button>
326
+ </div>
327
+ <p class="text-xs text-zinc-400 mb-4" id="drawerThesis"></p>
328
+ <div id="drawerEvents" class="scroll-area"></div>
329
+ </div>
330
+ </div>
331
+ </div>
332
+ </div>
333
+
334
+ <!-- ══════════════════════════════════════════════════ -->
335
+ <!-- PANEL: OPS -->
336
+ <!-- ══════════════════════════════════════════════════ -->
337
+ <div id="panel-ops" class="tab-panel" role="tabpanel" aria-labelledby="tab-ops">
338
+ <!-- Sync Status -->
339
+ <section class="mb-6">
340
+ <h2 class="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3">Last Sync</h2>
341
+ <div class="metric-card" id="syncStatusCard">
342
+ <div class="flex items-center justify-between">
343
+ <div>
344
+ <span class="sync-badge" id="syncBadge">--</span>
345
+ <span class="text-xs text-zinc-500 ml-2" id="syncTime">--</span>
346
+ </div>
347
+ <span class="text-xs text-zinc-500" id="syncDuration">--</span>
348
+ </div>
349
+ <div class="mt-2 text-xs text-zinc-500" id="syncCounts"></div>
350
+ <div class="mt-1 text-xs text-red-400 hidden" id="syncError"></div>
351
+ </div>
352
+ </section>
353
+
354
+ <!-- Tool Call Frequency -->
355
+ <section class="mb-6">
356
+ <h2 class="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3">Tool Call Frequency (24h)</h2>
357
+ <div id="toolFrequency" class="space-y-1">
358
+ <div class="skeleton h-6 mb-1"></div>
359
+ <div class="skeleton h-6 mb-1"></div>
360
+ <div class="skeleton h-6 mb-1"></div>
361
+ </div>
362
+ </section>
363
+
364
+ <!-- Active Verification Cycles -->
365
+ <section class="mb-6">
366
+ <h2 class="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3">Active Verification Cycles</h2>
367
+ <div id="activeCycles" class="space-y-2">
368
+ <p class="text-xs text-zinc-500">None active</p>
369
+ </div>
370
+ </section>
371
+
372
+ <!-- Privacy Mode Stats -->
373
+ <section class="mb-6" id="privacyStatsSection">
374
+ <h2 class="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3">Audience Mode</h2>
375
+ <div class="metric-card" id="privacyStatsCard">
376
+ <p class="text-xs text-zinc-500">No audience events recorded</p>
377
+ </div>
378
+ </section>
379
+
380
+ <!-- Data Counts -->
381
+ <section class="mb-6">
382
+ <h2 class="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3">Data Summary</h2>
383
+ <div class="grid grid-cols-3 gap-3" id="dataCounts">
384
+ <div class="metric-card text-center">
385
+ <div class="metric-value text-lg" id="countBriefs">--</div>
386
+ <div class="metric-label">Briefs</div>
387
+ </div>
388
+ <div class="metric-card text-center">
389
+ <div class="metric-value text-lg" id="countThreads">--</div>
390
+ <div class="metric-label">Threads</div>
391
+ </div>
392
+ <div class="metric-card text-center">
393
+ <div class="metric-value text-lg" id="countEvents">--</div>
394
+ <div class="metric-label">Events</div>
395
+ </div>
396
+ </div>
397
+ </section>
398
+
399
+ <!-- Sync History -->
400
+ <section class="mb-6">
401
+ <h2 class="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-3">Sync History</h2>
402
+ <div class="scroll-area" id="syncHistory">
403
+ <table class="w-full text-xs">
404
+ <thead>
405
+ <tr class="text-zinc-500 border-b border-zinc-800">
406
+ <th class="text-left py-2 font-medium">Time</th>
407
+ <th class="text-left py-2 font-medium">Status</th>
408
+ <th class="text-right py-2 font-medium">Duration</th>
409
+ </tr>
410
+ </thead>
411
+ <tbody id="syncHistoryBody"></tbody>
412
+ </table>
413
+ </div>
414
+ </section>
415
+ </div>
416
+
417
+ </div>
418
+
419
+ <script>
420
+ (function() {
421
+ 'use strict';
422
+
423
+ // ── State ───────────────────────────────────────────
424
+ let currentTab = 'brief';
425
+ let currentDate = null;
426
+ let lastDataHash = '';
427
+ let isPublicMode = false;
428
+ let cameraEnabled = false;
429
+ let cameraStream = null;
430
+ let presenceCheckInterval = null;
431
+ let consecutivePresence = 0;
432
+
433
+ // ── XSS safety ──────────────────────────────────────
434
+ function esc(s) {
435
+ if (s == null) return '';
436
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
437
+ }
438
+
439
+ function truncate(s, n) {
440
+ if (!s) return '';
441
+ return s.length > n ? s.slice(0, n) + '...' : s;
442
+ }
443
+
444
+ function sanitizeEntity(name) {
445
+ if (!isPublicMode || !name) return esc(name);
446
+ // In public mode: show first letter + dots
447
+ return esc(name.charAt(0)) + '***';
448
+ }
449
+
450
+ function sanitizeDomain(url) {
451
+ if (!url) return '';
452
+ try { return new URL(url).hostname; } catch { return esc(url); }
453
+ }
454
+
455
+ function relativeTime(ts) {
456
+ if (!ts) return '--';
457
+ const d = typeof ts === 'number' ? new Date(ts) : new Date(ts);
458
+ const diff = Date.now() - d.getTime();
459
+ if (diff < 60000) return 'just now';
460
+ if (diff < 3600000) return Math.floor(diff/60000) + 'm ago';
461
+ if (diff < 86400000) return Math.floor(diff/3600000) + 'h ago';
462
+ return Math.floor(diff/86400000) + 'd ago';
463
+ }
464
+
465
+ function fmtDate(d) {
466
+ return new Date(d).toLocaleDateString('en-US', { month:'short', day:'numeric' });
467
+ }
468
+
469
+ // ── Tab Switching ───────────────────────────────────
470
+ document.querySelectorAll('[role="tab"]').forEach(function(tab) {
471
+ tab.addEventListener('click', function() {
472
+ var target = this.dataset.tab;
473
+ currentTab = target;
474
+ document.querySelectorAll('[role="tab"]').forEach(function(t) {
475
+ t.classList.remove('active');
476
+ t.setAttribute('aria-selected','false');
477
+ });
478
+ this.classList.add('active');
479
+ this.setAttribute('aria-selected','true');
480
+ document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
481
+ document.getElementById('panel-' + target).classList.add('active');
482
+ refreshData();
483
+ });
484
+ });
485
+
486
+ // ── Phase Filter ────────────────────────────────────
487
+ document.getElementById('phaseFilter').addEventListener('click', function(e) {
488
+ var btn = e.target.closest('[data-phase]');
489
+ if (!btn) return;
490
+ this.querySelectorAll('[data-phase]').forEach(function(b) { b.classList.remove('active'); });
491
+ btn.classList.add('active');
492
+ renderNarrative(window.__narrativeData, btn.dataset.phase);
493
+ });
494
+
495
+ // ── Thread Drawer ───────────────────────────────────
496
+ document.getElementById('drawerClose').addEventListener('click', function() {
497
+ document.getElementById('threadDrawer').classList.add('hidden');
498
+ });
499
+
500
+ // ── Date helpers ────────────────────────────────────
501
+ function getDateRange(n) {
502
+ var dates = [];
503
+ for (var i = 0; i < n; i++) {
504
+ var d = new Date();
505
+ d.setDate(d.getDate() - i);
506
+ // Use local date (not UTC) to match server-side date strings
507
+ var yyyy = d.getFullYear();
508
+ var mm = String(d.getMonth() + 1).padStart(2, '0');
509
+ var dd = String(d.getDate()).padStart(2, '0');
510
+ dates.push(yyyy + '-' + mm + '-' + dd);
511
+ }
512
+ return dates;
513
+ }
514
+
515
+ function renderDatePicker() {
516
+ var dates = getDateRange(7);
517
+ var today = dates[0];
518
+ if (!currentDate) currentDate = today;
519
+ var picker = document.getElementById('datePicker');
520
+ picker.innerHTML = dates.map(function(d) {
521
+ var isActive = d === currentDate;
522
+ var label = d === today ? 'Today' : fmtDate(d);
523
+ return '<button class="tab-btn text-[11px] py-1 px-3 rounded-full' + (isActive ? ' active' : '') + '" data-date="' + d + '" style="flex:0">' + label + '</button>';
524
+ }).join('');
525
+
526
+ picker.addEventListener('click', function(e) {
527
+ var btn = e.target.closest('[data-date]');
528
+ if (!btn) return;
529
+ currentDate = btn.dataset.date;
530
+ picker.querySelectorAll('[data-date]').forEach(function(b) { b.classList.remove('active'); });
531
+ btn.classList.add('active');
532
+ fetchBrief(currentDate);
533
+ });
534
+ }
535
+
536
+ // ── Data Fetching ───────────────────────────────────
537
+ async function fetchBrief(date) {
538
+ try {
539
+ var url = date ? '/api/brief/date/' + encodeURIComponent(date) : '/api/brief/latest';
540
+ var res = await fetch(url);
541
+ var data = await res.json();
542
+ renderBrief(data);
543
+
544
+ // Also fetch memories for this date
545
+ if (date || data.date_string) {
546
+ var mDate = date || data.date_string;
547
+ var mRes = await fetch('/api/brief/memories/' + encodeURIComponent(mDate));
548
+ var mData = await mRes.json();
549
+ renderMemories(mData);
550
+ }
551
+ } catch (err) {
552
+ console.error('[brief] fetch error:', err);
553
+ }
554
+ }
555
+
556
+ async function fetchNarrative(phase) {
557
+ try {
558
+ var url = '/api/narrative/threads' + (phase && phase !== 'all' ? '?phase=' + phase : '');
559
+ var res = await fetch(url);
560
+ var data = await res.json();
561
+ window.__narrativeData = data;
562
+ renderNarrative(data, phase || 'all');
563
+ } catch (err) {
564
+ console.error('[narrative] fetch error:', err);
565
+ }
566
+ }
567
+
568
+ async function fetchOps() {
569
+ try {
570
+ var [syncRes, statsRes] = await Promise.all([
571
+ fetch('/api/ops/sync-status'),
572
+ fetch('/api/ops/stats')
573
+ ]);
574
+ var sync = await syncRes.json();
575
+ var stats = await statsRes.json();
576
+ renderOps(sync, stats);
577
+ } catch (err) {
578
+ console.error('[ops] fetch error:', err);
579
+ }
580
+ }
581
+
582
+ async function refreshData() {
583
+ if (currentTab === 'brief') await fetchBrief(currentDate);
584
+ else if (currentTab === 'narrative') await fetchNarrative();
585
+ else if (currentTab === 'ops') await fetchOps();
586
+ document.getElementById('lastRefresh').textContent = 'Updated ' + new Date().toLocaleTimeString();
587
+ }
588
+
589
+ // ── Render: Brief ───────────────────────────────────
590
+ function renderBrief(data) {
591
+ if (!data || data.empty) {
592
+ document.getElementById('metricsGrid').innerHTML =
593
+ '<div class="metric-card col-span-4 text-center py-8">' +
594
+ '<p class="text-zinc-500 text-sm">No brief data synced yet</p>' +
595
+ '<p class="text-zinc-600 text-xs mt-1">Run: npm run local:sync</p></div>';
596
+ return;
597
+ }
598
+
599
+ var metrics = data.dashboard_metrics || {};
600
+ var summary = data.source_summary || {};
601
+
602
+ // Readiness gauge
603
+ var readiness = metrics.techReadiness ?? metrics.readiness ?? 0;
604
+ var pct = Math.min(100, Math.max(0, readiness));
605
+ var circumference = 2 * Math.PI * 34;
606
+ var offset = circumference - (pct / 100) * circumference;
607
+ var gaugeFg = document.getElementById('gaugeFg');
608
+ gaugeFg.setAttribute('stroke-dashoffset', String(offset));
609
+ gaugeFg.setAttribute('stroke', pct >= 70 ? '#34d399' : pct >= 40 ? '#fbbf24' : '#f87171');
610
+ document.getElementById('gaugeText').textContent = Math.round(pct);
611
+
612
+ // Metric cards
613
+ document.getElementById('metricThreads').textContent = metrics.threadCount ?? metrics.threads ?? '--';
614
+ document.getElementById('metricEvents').textContent = metrics.eventCount ?? metrics.events ?? '--';
615
+ document.getElementById('metricSources').textContent = metrics.sourceCount ?? metrics.sources ?? '--';
616
+
617
+ // Source summary
618
+ if (summary && typeof summary === 'object' && Object.keys(summary).length > 0) {
619
+ var grid = document.getElementById('sourceSummaryGrid');
620
+ grid.innerHTML = Object.entries(summary).map(function(entry) {
621
+ var key = entry[0], val = entry[1];
622
+ var displayVal = typeof val === 'number' ? val : (val && val.count != null ? val.count : '?');
623
+ return '<div class="metric-card text-center">' +
624
+ '<div class="text-lg font-bold text-zinc-100">' + esc(String(displayVal)) + '</div>' +
625
+ '<div class="metric-label">' + (isPublicMode ? sanitizeDomain(key) : esc(key)) + '</div></div>';
626
+ }).join('');
627
+ document.getElementById('sourceSummarySection').classList.remove('hidden');
628
+ }
629
+ }
630
+
631
+ function renderMemories(data) {
632
+ if (!data) return;
633
+ var memories = data.memories || [];
634
+ var tasks = data.tasks || [];
635
+
636
+ // Features
637
+ if (memories.length > 0) {
638
+ var allFeatures = [];
639
+ memories.forEach(function(m) {
640
+ var feats = m.features || [];
641
+ feats.forEach(function(f) {
642
+ allFeatures.push(typeof f === 'string' ? { name: f, status: 'unknown' } : f);
643
+ });
644
+ });
645
+
646
+ if (allFeatures.length > 0) {
647
+ var list = document.getElementById('featuresList');
648
+ list.innerHTML = allFeatures.map(function(f) {
649
+ var status = (f.status || 'unknown').toLowerCase();
650
+ var badgeClass = status === 'passing' || status === 'done' ? 'bg-emerald-900/50 text-emerald-300' :
651
+ status === 'failing' || status === 'error' ? 'bg-red-900/50 text-red-300' :
652
+ 'bg-amber-900/50 text-amber-300';
653
+ var name = isPublicMode ? sanitizeEntity(f.name) : esc(f.name);
654
+ return '<div class="flex items-center justify-between p-2 rounded-lg" style="background:var(--surface-1);border:1px solid var(--border-base)">' +
655
+ '<span class="text-xs text-zinc-200">' + name + '</span>' +
656
+ '<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full ' + badgeClass + '">' + esc(status) + '</span></div>';
657
+ }).join('');
658
+ document.getElementById('featuresSection').classList.remove('hidden');
659
+ }
660
+ }
661
+
662
+ // Task results (hidden in public mode)
663
+ if (tasks.length > 0 && !isPublicMode) {
664
+ var taskList = document.getElementById('taskResultsList');
665
+ taskList.innerHTML = tasks.map(function(t, i) {
666
+ return '<details class="group">' +
667
+ '<summary class="flex items-center justify-between p-2 rounded-lg cursor-pointer text-xs text-zinc-300 hover:text-zinc-100" ' +
668
+ 'style="background:var(--surface-1);border:1px solid var(--border-base)">' +
669
+ '<span>' + esc(t.task_id || 'Task ' + (i+1)) + '</span>' +
670
+ '<svg class="w-3 h-3 text-zinc-500 group-open:rotate-90 transition-transform" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>' +
671
+ '</summary>' +
672
+ '<div class="mt-1 p-3 text-xs text-zinc-400 leading-relaxed" style="background:var(--surface-2);border-radius:var(--radius-md)">' +
673
+ esc(t.result_markdown || 'No result') + '</div></details>';
674
+ }).join('');
675
+ document.getElementById('taskResultsSection').classList.remove('hidden');
676
+ }
677
+
678
+ if (isPublicMode) {
679
+ document.getElementById('taskResultsSection').classList.add('hidden');
680
+ }
681
+ }
682
+
683
+ // ── Render: Narrative ───────────────────────────────
684
+ function renderNarrative(threads, phaseFilter) {
685
+ var container = document.getElementById('narrativeLanes');
686
+ if (!threads || threads.length === 0) {
687
+ container.innerHTML = '<div class="text-center py-8"><p class="text-zinc-500 text-sm">No narrative threads synced</p>' +
688
+ '<p class="text-zinc-600 text-xs mt-1">Run: npm run local:sync</p></div>';
689
+ return;
690
+ }
691
+
692
+ // Group by phase
693
+ var phases = ['emerging','escalating','climax','resolution','dormant'];
694
+ var grouped = {};
695
+ phases.forEach(function(p) { grouped[p] = []; });
696
+ threads.forEach(function(t) {
697
+ var p = (t.current_phase || 'dormant').toLowerCase();
698
+ if (!grouped[p]) grouped[p] = [];
699
+ grouped[p].push(t);
700
+ });
701
+
702
+ var html = '';
703
+ phases.forEach(function(phase) {
704
+ var items = grouped[phase] || [];
705
+ if (phaseFilter && phaseFilter !== 'all' && phaseFilter !== phase) return;
706
+ if (items.length === 0) return;
707
+
708
+ html += '<div class="phase-lane ' + phase + ' fade-up">' +
709
+ '<div class="phase-label ' + phase + '">' + phase + ' (' + items.length + ')</div>';
710
+
711
+ items.forEach(function(t) {
712
+ var tags = (t.topic_tags || []).slice(0, 3);
713
+ var entities = t.entity_keys || [];
714
+ var name = isPublicMode ? sanitizeEntity(t.name) : esc(t.name);
715
+ var thesis = isPublicMode ? '' : '<div class="thread-thesis">' + esc(truncate(t.thesis, 120)) + '</div>';
716
+ var quality = t.quality || {};
717
+ var evCount = t.event_count || 0;
718
+ var twistCount = t.plot_twist_count || 0;
719
+
720
+ html += '<div class="thread-card" tabindex="0" role="button" aria-label="View thread: ' + esc(t.name) + '" data-thread-id="' + esc(t.id) + '">' +
721
+ '<div class="thread-name">' + name + '</div>' +
722
+ thesis +
723
+ '<div class="thread-meta">';
724
+
725
+ if (evCount > 0) {
726
+ html += '<span class="thread-badge" style="background:var(--surface-2);color:#a1a1aa">' + evCount + ' events</span>';
727
+ }
728
+ if (twistCount > 0) {
729
+ html += '<span class="thread-badge" style="background:#450a0a;color:#fca5a5">' + twistCount + (twistCount === 1 ? ' twist' : ' twists') + '</span>';
730
+ }
731
+
732
+ if (!isPublicMode) {
733
+ tags.forEach(function(tag) {
734
+ html += '<span class="thread-tag">' + esc(tag) + '</span>';
735
+ });
736
+ }
737
+
738
+ if (quality && quality.confidence != null) {
739
+ html += '<div class="flex items-center gap-1 ml-auto"><span class="text-[10px] text-zinc-500">' + Math.round(quality.confidence * 100) + '%</span>' +
740
+ '<div class="conf-bar w-12"><div class="conf-fill" style="width:' + Math.round(quality.confidence * 100) + '%;background:' +
741
+ (quality.confidence >= 0.7 ? '#34d399' : quality.confidence >= 0.4 ? '#fbbf24' : '#f87171') + '"></div></div></div>';
742
+ }
743
+
744
+ html += '</div></div>';
745
+ });
746
+
747
+ html += '</div>';
748
+ });
749
+
750
+ container.innerHTML = html || '<div class="text-center py-8"><p class="text-zinc-500 text-sm">No threads match this filter</p></div>';
751
+
752
+ // Thread click → drawer
753
+ container.querySelectorAll('.thread-card').forEach(function(card) {
754
+ card.addEventListener('click', function() { openThreadDrawer(this.dataset.threadId); });
755
+ card.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openThreadDrawer(this.dataset.threadId); } });
756
+ });
757
+ }
758
+
759
+ async function openThreadDrawer(threadId) {
760
+ if (!threadId) return;
761
+ var drawer = document.getElementById('threadDrawer');
762
+ drawer.classList.remove('hidden');
763
+
764
+ try {
765
+ var res = await fetch('/api/narrative/thread/' + encodeURIComponent(threadId));
766
+ var data = await res.json();
767
+ if (data.empty) {
768
+ document.getElementById('drawerTitle').textContent = 'Not found';
769
+ document.getElementById('drawerThesis').textContent = '';
770
+ document.getElementById('drawerEvents').innerHTML = '';
771
+ return;
772
+ }
773
+
774
+ var thread = data.thread;
775
+ document.getElementById('drawerTitle').textContent = isPublicMode ? sanitizeEntity(thread.name) : thread.name;
776
+ document.getElementById('drawerThesis').textContent = isPublicMode ? '' : (thread.thesis || '');
777
+
778
+ var events = data.events || [];
779
+ if (isPublicMode) {
780
+ document.getElementById('drawerEvents').innerHTML = '<p class="text-xs text-zinc-500">' + events.length + ' events (hidden in public mode)</p>';
781
+ } else if (events.length === 0) {
782
+ document.getElementById('drawerEvents').innerHTML = '<p class="text-xs text-zinc-500">No events recorded for this thread</p>';
783
+ } else {
784
+ document.getElementById('drawerEvents').innerHTML = events.map(function(e) {
785
+ var sig = (e.significance || 'minor').toLowerCase();
786
+ return '<div class="event-item">' +
787
+ '<div class="event-dot ' + sig + '"></div>' +
788
+ '<div><div class="event-headline">' + esc(e.headline) + '</div>' +
789
+ '<div class="event-time">' + relativeTime(e.occurred_at) + '</div>' +
790
+ (e.summary ? '<p class="text-[11px] text-zinc-500 mt-1">' + esc(truncate(e.summary, 200)) + '</p>' : '') +
791
+ '</div></div>';
792
+ }).join('');
793
+ }
794
+ } catch (err) {
795
+ document.getElementById('drawerEvents').innerHTML = '<p class="text-xs text-red-400">Error loading thread</p>';
796
+ }
797
+ }
798
+
799
+ // ── Render: Ops ─────────────────────────────────────
800
+ function renderOps(sync, stats) {
801
+ // Sync status
802
+ if (sync && sync.latest) {
803
+ var s = sync.latest;
804
+ var badge = document.getElementById('syncBadge');
805
+ badge.textContent = s.status || '--';
806
+ badge.className = 'sync-badge ' + (s.status || '');
807
+ document.getElementById('syncTime').textContent = relativeTime(s.started_at || s.completed_at);
808
+ document.getElementById('syncDuration').textContent = s.duration_ms ? (s.duration_ms + 'ms') : '--';
809
+
810
+ var tables = s.tables_synced || {};
811
+ if (typeof tables === 'object' && Object.keys(tables).length > 0) {
812
+ document.getElementById('syncCounts').textContent = Object.entries(tables).map(function(e) {
813
+ return e[0] + ': ' + e[1];
814
+ }).join(' | ');
815
+ }
816
+
817
+ if (s.error) {
818
+ var errEl = document.getElementById('syncError');
819
+ errEl.textContent = s.error;
820
+ errEl.classList.remove('hidden');
821
+ }
822
+ }
823
+
824
+ // Sync history
825
+ if (sync && sync.history) {
826
+ var tbody = document.getElementById('syncHistoryBody');
827
+ tbody.innerHTML = sync.history.map(function(r) {
828
+ return '<tr class="border-b border-zinc-800/50">' +
829
+ '<td class="py-2 text-zinc-400">' + relativeTime(r.started_at) + '</td>' +
830
+ '<td class="py-2"><span class="sync-badge ' + (r.status || '') + '">' + esc(r.status) + '</span></td>' +
831
+ '<td class="py-2 text-right text-zinc-500">' + (r.duration_ms ? r.duration_ms + 'ms' : '--') + '</td></tr>';
832
+ }).join('');
833
+ }
834
+
835
+ // Tool frequency
836
+ if (stats) {
837
+ var tools = stats.toolCallFrequency || [];
838
+ var toolEl = document.getElementById('toolFrequency');
839
+ if (tools.length === 0) {
840
+ toolEl.innerHTML = '<p class="text-xs text-zinc-500">No tool calls in last 24h</p>';
841
+ } else {
842
+ var maxCount = Math.max.apply(null, tools.map(function(t) { return t.count; }));
843
+ toolEl.innerHTML = tools.map(function(t) {
844
+ var pct = maxCount > 0 ? Math.round((t.count / maxCount) * 100) : 0;
845
+ var errPct = t.count > 0 ? Math.round((t.errors / t.count) * 100) : 0;
846
+ return '<div class="flex items-center gap-2 text-xs py-1">' +
847
+ '<span class="text-zinc-400 w-36 truncate" title="' + esc(t.tool_name) + '">' + esc(t.tool_name) + '</span>' +
848
+ '<div class="flex-1 conf-bar"><div class="conf-fill" style="width:' + pct + '%;background:' + (errPct > 20 ? '#f87171' : '#4f46e5') + '"></div></div>' +
849
+ '<span class="text-zinc-500 w-8 text-right">' + t.count + '</span>' +
850
+ (t.errors > 0 ? '<span class="text-red-400 text-[10px]">' + t.errors + 'err</span>' : '') +
851
+ '</div>';
852
+ }).join('');
853
+ }
854
+
855
+ // Active cycles
856
+ var cycles = stats.activeCycles || [];
857
+ var cycleEl = document.getElementById('activeCycles');
858
+ if (cycles.length === 0) {
859
+ cycleEl.innerHTML = '<p class="text-xs text-zinc-500">None active</p>';
860
+ } else {
861
+ cycleEl.innerHTML = cycles.map(function(c) {
862
+ return '<div class="metric-card text-xs">' +
863
+ '<div class="font-semibold text-zinc-200">' + esc(c.title) + '</div>' +
864
+ '<div class="text-zinc-500 mt-1">Status: ' + esc(c.status) + ' | ' + relativeTime(c.created_at) + '</div></div>';
865
+ }).join('');
866
+ }
867
+
868
+ // Privacy stats
869
+ var priv = stats.privacyMode;
870
+ if (priv) {
871
+ document.getElementById('privacyStatsCard').innerHTML =
872
+ '<div class="flex items-center gap-4">' +
873
+ '<div><span class="text-lg font-bold text-zinc-100">' + (priv.triggeredToday || 0) + '</span>' +
874
+ '<div class="metric-label">Triggers today</div></div>' +
875
+ '<div><span class="text-lg font-bold text-zinc-100">' + (priv.totalEvents || 0) + '</span>' +
876
+ '<div class="metric-label">Total events</div></div></div>';
877
+ }
878
+
879
+ // Data counts
880
+ var dc = stats.dataCounts || {};
881
+ document.getElementById('countBriefs').textContent = dc.briefs ?? '--';
882
+ document.getElementById('countThreads').textContent = dc.threads ?? '--';
883
+ document.getElementById('countEvents').textContent = dc.events ?? '--';
884
+ }
885
+ }
886
+
887
+ // ── Privacy / Camera Detection ──────────────────────
888
+ var privacyToggle = document.getElementById('privacyToggle');
889
+ privacyToggle.addEventListener('click', function() {
890
+ cameraEnabled = !cameraEnabled;
891
+ this.classList.toggle('on', cameraEnabled);
892
+ this.setAttribute('aria-checked', String(cameraEnabled));
893
+
894
+ if (cameraEnabled) startPresenceDetection();
895
+ else stopPresenceDetection();
896
+ });
897
+
898
+ async function startPresenceDetection() {
899
+ try {
900
+ var video = document.getElementById('privacyVideo');
901
+ cameraStream = await navigator.mediaDevices.getUserMedia({ video: { width:64, height:48, frameRate:1 } });
902
+ video.srcObject = cameraStream;
903
+ await video.play();
904
+
905
+ presenceCheckInterval = setInterval(checkPresence, 1000);
906
+ logAudienceEvent('session_start', 0, false);
907
+ } catch (err) {
908
+ console.warn('[privacy] Camera access denied:', err.message);
909
+ cameraEnabled = false;
910
+ privacyToggle.classList.remove('on');
911
+ privacyToggle.setAttribute('aria-checked', 'false');
912
+ }
913
+ }
914
+
915
+ function stopPresenceDetection() {
916
+ if (presenceCheckInterval) { clearInterval(presenceCheckInterval); presenceCheckInterval = null; }
917
+ if (cameraStream) { cameraStream.getTracks().forEach(function(t) { t.stop(); }); cameraStream = null; }
918
+ var video = document.getElementById('privacyVideo');
919
+ video.srcObject = null;
920
+ setPublicMode(false);
921
+ consecutivePresence = 0;
922
+ }
923
+
924
+ function checkPresence() {
925
+ var video = document.getElementById('privacyVideo');
926
+ var canvas = document.getElementById('privacyCanvas');
927
+ var ctx = canvas.getContext('2d');
928
+ ctx.drawImage(video, 0, 0, 64, 48);
929
+ var frame = ctx.getImageData(0, 0, 64, 48);
930
+ var pixels = frame.data;
931
+
932
+ // Compute pixel standard deviation (grayscale)
933
+ var sum = 0, sumSq = 0, n = pixels.length / 4;
934
+ for (var i = 0; i < pixels.length; i += 4) {
935
+ var gray = 0.299 * pixels[i] + 0.587 * pixels[i+1] + 0.114 * pixels[i+2];
936
+ sum += gray;
937
+ sumSq += gray * gray;
938
+ }
939
+ var mean = sum / n;
940
+ var variance = (sumSq / n) - (mean * mean);
941
+ var stdDev = Math.sqrt(Math.abs(variance));
942
+
943
+ // Above threshold = presence detected (scene complexity)
944
+ var hasPresence = stdDev > 25;
945
+ if (hasPresence) {
946
+ consecutivePresence++;
947
+ } else {
948
+ consecutivePresence = Math.max(0, consecutivePresence - 1);
949
+ }
950
+
951
+ // Two consecutive presence detections → public mode
952
+ var shouldBePublic = consecutivePresence >= 2;
953
+ if (shouldBePublic !== isPublicMode) {
954
+ setPublicMode(shouldBePublic);
955
+ logAudienceEvent('mode_switch', shouldBePublic ? 2 : 0, shouldBePublic);
956
+ }
957
+ }
958
+
959
+ function setPublicMode(pub) {
960
+ isPublicMode = pub;
961
+ var indicator = document.getElementById('privacyStatus');
962
+ indicator.classList.toggle('active', pub);
963
+
964
+ // Re-render current view with sanitization
965
+ refreshData();
966
+ }
967
+
968
+ function logAudienceEvent(type, viewerCount, isPublic) {
969
+ fetch('/api/audience/event', {
970
+ method: 'POST',
971
+ headers: { 'Content-Type': 'application/json' },
972
+ body: JSON.stringify({ event_type: type, viewer_count: viewerCount, is_public: isPublic })
973
+ }).catch(function() {});
974
+ }
975
+
976
+ // ── Hash-based diffing for auto-refresh ─────────────
977
+ function hashStr(s) {
978
+ var h = 0;
979
+ for (var i = 0; i < s.length; i++) {
980
+ h = ((h << 5) - h) + s.charCodeAt(i);
981
+ h |= 0;
982
+ }
983
+ return String(h);
984
+ }
985
+
986
+ // ── Init ────────────────────────────────────────────
987
+ renderDatePicker();
988
+ refreshData();
989
+
990
+ // Auto-refresh every 30s
991
+ setInterval(function() {
992
+ refreshData();
993
+ }, 30000);
994
+
995
+ })();
996
+ <\/script>
997
+ </body>
998
+ </html>`;
999
+ }
1000
+ //# sourceMappingURL=briefHtml.js.map