davinci-resolve-mcp 2.24.0 → 2.25.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.
@@ -14,10 +14,11 @@ import webbrowser
14
14
  from http import HTTPStatus
15
15
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
16
  from typing import Any, Dict, List, Optional, Tuple
17
- from urllib.parse import parse_qs, urlparse
17
+ from urllib.parse import parse_qs, unquote, urlparse
18
18
 
19
19
  from src.utils.media_analysis import (
20
20
  analysis_index_status,
21
+ analysis_root_coverage,
21
22
  build_analysis_index,
22
23
  detect_capabilities,
23
24
  query_analysis_index,
@@ -36,6 +37,9 @@ from src.utils.media_analysis_jobs import (
36
37
  )
37
38
  from src.utils.platform import setup_environment
38
39
  from src.utils.analysis_memory import read_panel_state, write_panel_state
40
+ from src.utils import brain_edits as _brain_edits
41
+ from src.utils import timeline_versioning as _timeline_versioning
42
+ from src.utils import timeline_brain_db as _timeline_brain_db
39
43
 
40
44
 
41
45
  HTML = r"""<!doctype html>
@@ -1360,6 +1364,76 @@ HTML = r"""<!doctype html>
1360
1364
  color: var(--text-tertiary);
1361
1365
  min-height: 14px;
1362
1366
  }
1367
+ /* ─── Readiness card (coverage_report rollup) ─────────────────────── */
1368
+ .readiness-card {
1369
+ margin: var(--space-4) 0;
1370
+ padding: var(--space-3) var(--space-4);
1371
+ border: 1px solid var(--border-default);
1372
+ background: var(--lab-panel-elevated);
1373
+ border-radius: var(--radius-md);
1374
+ display: flex;
1375
+ flex-direction: column;
1376
+ gap: var(--space-2);
1377
+ }
1378
+ .readiness-card-header {
1379
+ display: flex;
1380
+ align-items: center;
1381
+ gap: var(--space-3);
1382
+ }
1383
+ .readiness-title {
1384
+ font-weight: 600;
1385
+ color: var(--text-primary);
1386
+ letter-spacing: 0.02em;
1387
+ text-transform: uppercase;
1388
+ font-size: 11px;
1389
+ }
1390
+ .readiness-evidence {
1391
+ flex: 1;
1392
+ color: var(--text-secondary);
1393
+ font-size: 13px;
1394
+ line-height: 1.4;
1395
+ }
1396
+ .readiness-summary-row {
1397
+ display: flex;
1398
+ flex-wrap: wrap;
1399
+ gap: var(--space-3);
1400
+ }
1401
+ .readiness-stat {
1402
+ display: flex;
1403
+ flex-direction: column;
1404
+ gap: 2px;
1405
+ min-width: 84px;
1406
+ padding: var(--space-2) var(--space-3);
1407
+ border-radius: var(--radius-sm);
1408
+ background: var(--bg-subtle);
1409
+ }
1410
+ .readiness-stat .stat-value {
1411
+ font-size: 22px;
1412
+ font-weight: 600;
1413
+ color: var(--text-primary);
1414
+ }
1415
+ .readiness-stat .stat-label {
1416
+ font-size: 10.5px;
1417
+ color: var(--text-tertiary);
1418
+ text-transform: uppercase;
1419
+ letter-spacing: 0.05em;
1420
+ }
1421
+ .readiness-stat.warn .stat-value { color: var(--accent-warn, #d18b00); }
1422
+ .readiness-stat.danger .stat-value { color: var(--accent-danger, #d24c4c); }
1423
+ .readiness-stat.good .stat-value { color: var(--accent-success, #4caf50); }
1424
+ .readiness-details {
1425
+ color: var(--text-tertiary);
1426
+ font-size: 11.5px;
1427
+ display: flex;
1428
+ flex-wrap: wrap;
1429
+ gap: var(--space-3);
1430
+ }
1431
+ .readiness-details .chip {
1432
+ padding: 2px 8px;
1433
+ border-radius: 999px;
1434
+ background: var(--bg-subtle);
1435
+ color: var(--text-secondary);
1436
+ }
1363
1437
  /* ─── V2 Review surface ───────────────────────────────────────────── */
1364
1438
  .review-grid {
1365
1439
  display: grid;
@@ -2084,9 +2158,817 @@ HTML = r"""<!doctype html>
2084
2158
  flex: 0 0 160px;
2085
2159
  aspect-ratio: 16 / 9;
2086
2160
  }
2087
- .review-list .review-clip-card-name { flex: 1 1 auto; }
2088
- .review-list .review-clip-card-meta { flex-shrink: 0; }
2089
- .review-list .review-clip-card-oneliner { flex: 2 1 0; }
2161
+ .review-list .review-clip-card-name { flex: 1 1 auto; }
2162
+ .review-list .review-clip-card-meta { flex-shrink: 0; }
2163
+ .review-list .review-clip-card-oneliner { flex: 2 1 0; }
2164
+
2165
+ /* ─── C6 Timeline history view ──────────────────────────────────── */
2166
+ .history-layout {
2167
+ display: grid;
2168
+ grid-template-columns: 280px 1fr;
2169
+ gap: var(--space-3);
2170
+ align-items: start;
2171
+ }
2172
+ .history-sidebar {
2173
+ border: 1px solid var(--border-default);
2174
+ border-radius: var(--radius-md);
2175
+ background: var(--lab-panel-elevated);
2176
+ padding: var(--space-2);
2177
+ display: flex;
2178
+ flex-direction: column;
2179
+ gap: var(--space-2);
2180
+ max-height: 70vh;
2181
+ overflow-y: auto;
2182
+ }
2183
+ .history-sidebar-header {
2184
+ display: flex;
2185
+ align-items: center;
2186
+ justify-content: space-between;
2187
+ gap: var(--space-2);
2188
+ font-size: var(--text-sm);
2189
+ }
2190
+ .history-timeline-list {
2191
+ display: flex;
2192
+ flex-direction: column;
2193
+ gap: var(--space-1);
2194
+ }
2195
+ .history-timeline-row {
2196
+ cursor: pointer;
2197
+ padding: var(--space-1) var(--space-2);
2198
+ border-radius: var(--radius-sm);
2199
+ border: 1px solid transparent;
2200
+ display: flex;
2201
+ justify-content: space-between;
2202
+ align-items: center;
2203
+ gap: var(--space-2);
2204
+ font-size: var(--text-sm);
2205
+ }
2206
+ .history-timeline-row:hover { background: var(--bg-muted); }
2207
+ .history-timeline-row.is-selected {
2208
+ border-color: var(--accent-primary);
2209
+ background: var(--bg-muted);
2210
+ }
2211
+ .history-timeline-row .name { font-weight: 500; }
2212
+ .history-timeline-row .count {
2213
+ font-size: var(--text-xs);
2214
+ color: var(--text-muted);
2215
+ }
2216
+ .history-archive-now {
2217
+ display: flex;
2218
+ flex-direction: column;
2219
+ gap: var(--space-1);
2220
+ margin-top: auto;
2221
+ padding-top: var(--space-2);
2222
+ border-top: 1px solid var(--border-default);
2223
+ }
2224
+ .history-archive-now button { width: 100%; }
2225
+ .history-archive-now input {
2226
+ width: 100%;
2227
+ padding: var(--space-1) var(--space-2);
2228
+ border: 1px solid var(--border-default);
2229
+ background: var(--bg-base);
2230
+ color: var(--text-primary);
2231
+ border-radius: var(--radius-sm);
2232
+ font-size: var(--text-xs);
2233
+ }
2234
+ .history-detail {
2235
+ border: 1px solid var(--border-default);
2236
+ border-radius: var(--radius-md);
2237
+ background: var(--lab-panel-elevated);
2238
+ padding: var(--space-3);
2239
+ min-height: 300px;
2240
+ }
2241
+ .history-detail-header {
2242
+ display: flex;
2243
+ justify-content: space-between;
2244
+ align-items: baseline;
2245
+ gap: var(--space-2);
2246
+ margin-bottom: var(--space-3);
2247
+ padding-bottom: var(--space-2);
2248
+ border-bottom: 1px solid var(--border-default);
2249
+ }
2250
+ .history-detail-header .timeline-name {
2251
+ font-size: var(--text-base);
2252
+ font-weight: 600;
2253
+ }
2254
+ .history-detail-body {
2255
+ display: flex;
2256
+ flex-direction: column;
2257
+ gap: var(--space-3);
2258
+ }
2259
+ .history-version-card {
2260
+ border: 1px solid var(--border-default);
2261
+ border-radius: var(--radius-sm);
2262
+ padding: var(--space-2);
2263
+ background: var(--bg-base);
2264
+ display: grid;
2265
+ grid-template-columns: 160px 1fr;
2266
+ gap: var(--space-3);
2267
+ }
2268
+ .history-version-card .thumb {
2269
+ aspect-ratio: 16/9;
2270
+ background: var(--bg-muted);
2271
+ border-radius: var(--radius-sm);
2272
+ overflow: hidden;
2273
+ display: flex;
2274
+ align-items: center;
2275
+ justify-content: center;
2276
+ color: var(--text-muted);
2277
+ font-size: var(--text-xs);
2278
+ }
2279
+ .history-version-card .thumb img {
2280
+ width: 100%;
2281
+ height: 100%;
2282
+ object-fit: cover;
2283
+ display: block;
2284
+ }
2285
+ .history-version-card .body {
2286
+ display: flex;
2287
+ flex-direction: column;
2288
+ gap: var(--space-2);
2289
+ min-width: 0;
2290
+ }
2291
+ .history-version-card .version-row {
2292
+ display: flex;
2293
+ align-items: center;
2294
+ gap: var(--space-2);
2295
+ justify-content: space-between;
2296
+ }
2297
+ .history-version-card .version-label {
2298
+ font-size: var(--text-sm);
2299
+ font-weight: 600;
2300
+ }
2301
+ .history-version-card .archived-name {
2302
+ font-size: var(--text-xs);
2303
+ color: var(--text-muted);
2304
+ font-family: var(--font-mono);
2305
+ }
2306
+ .history-version-card .timestamp {
2307
+ font-size: var(--text-xs);
2308
+ color: var(--text-muted);
2309
+ }
2310
+ .history-version-card .reason {
2311
+ font-size: var(--text-xs);
2312
+ color: var(--text-muted);
2313
+ font-style: italic;
2314
+ }
2315
+ .history-version-card .drt-collapsed {
2316
+ font-size: var(--text-xs);
2317
+ color: var(--accent-warning);
2318
+ }
2319
+ .history-edits-table {
2320
+ width: 100%;
2321
+ border-collapse: collapse;
2322
+ font-size: var(--text-xs);
2323
+ }
2324
+ .history-edits-table th, .history-edits-table td {
2325
+ padding: var(--space-1) var(--space-2);
2326
+ border-bottom: 1px solid var(--border-default);
2327
+ text-align: left;
2328
+ vertical-align: top;
2329
+ }
2330
+ .history-edits-table th { color: var(--text-muted); font-weight: 500; }
2331
+ .history-edits-table .edit-type {
2332
+ font-family: var(--font-mono);
2333
+ color: var(--text-primary);
2334
+ }
2335
+ .history-edits-table .metric { color: var(--text-muted); }
2336
+ .history-edits-table .delta-pos { color: var(--accent-success); }
2337
+ .history-edits-table .delta-neg { color: var(--accent-error); }
2338
+ .history-edits-table .delta-zero { color: var(--text-muted); }
2339
+ .history-rollback-btn {
2340
+ background: var(--accent-warning-muted);
2341
+ color: var(--accent-warning);
2342
+ border: 1px solid var(--accent-warning);
2343
+ padding: var(--space-1) var(--space-2);
2344
+ border-radius: var(--radius-sm);
2345
+ cursor: pointer;
2346
+ font-size: var(--text-xs);
2347
+ }
2348
+ .history-rollback-btn:hover { background: var(--accent-warning); color: var(--bg-base); }
2349
+
2350
+ /* ─── Analysis caps preferences widget ──────────────────────────── */
2351
+ .caps-section {
2352
+ margin-top: var(--space-4);
2353
+ padding: var(--space-3);
2354
+ border: 1px solid var(--border-default);
2355
+ border-radius: var(--radius-md);
2356
+ background: var(--lab-panel-elevated, var(--bg-base));
2357
+ }
2358
+ .caps-section + .caps-section { margin-top: var(--space-3); }
2359
+ .caps-section-head {
2360
+ display: flex;
2361
+ flex-direction: column;
2362
+ gap: 2px;
2363
+ margin-bottom: var(--space-2);
2364
+ }
2365
+ .caps-section-title {
2366
+ font-size: var(--text-sm);
2367
+ font-weight: 600;
2368
+ color: var(--text-primary);
2369
+ }
2370
+ .caps-section-hint {
2371
+ font-size: var(--text-xs);
2372
+ color: var(--text-muted);
2373
+ }
2374
+ .caps-section-subtitle {
2375
+ font-size: var(--text-xs);
2376
+ color: var(--text-muted);
2377
+ margin-top: var(--space-2);
2378
+ margin-bottom: var(--space-1);
2379
+ text-transform: uppercase;
2380
+ letter-spacing: 0.04em;
2381
+ }
2382
+
2383
+ /* Preset cards */
2384
+ .caps-preset-cards {
2385
+ display: grid;
2386
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2387
+ gap: var(--space-2);
2388
+ }
2389
+ .caps-preset-card {
2390
+ display: flex;
2391
+ flex-direction: column;
2392
+ gap: var(--space-2);
2393
+ padding: var(--space-2) var(--space-3);
2394
+ border: 1px solid var(--border-default);
2395
+ border-radius: var(--radius-sm);
2396
+ background: var(--bg-base);
2397
+ cursor: pointer;
2398
+ text-align: left;
2399
+ transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
2400
+ color: inherit;
2401
+ font: inherit;
2402
+ }
2403
+ .caps-preset-card:hover {
2404
+ border-color: var(--accent-primary);
2405
+ background: var(--bg-muted);
2406
+ }
2407
+ .caps-preset-card.is-active {
2408
+ border-color: var(--accent-primary);
2409
+ background: var(--accent-primary-muted, rgba(64, 156, 255, 0.12));
2410
+ box-shadow: 0 0 0 1px var(--accent-primary) inset;
2411
+ }
2412
+ .caps-preset-card-head {
2413
+ display: flex;
2414
+ justify-content: space-between;
2415
+ align-items: baseline;
2416
+ }
2417
+ .caps-preset-card-name {
2418
+ font-size: var(--text-sm);
2419
+ font-weight: 600;
2420
+ text-transform: capitalize;
2421
+ }
2422
+ .caps-preset-card-badge {
2423
+ font-size: 10px;
2424
+ color: var(--accent-primary);
2425
+ text-transform: uppercase;
2426
+ letter-spacing: 0.04em;
2427
+ }
2428
+ .caps-preset-card-tag {
2429
+ font-size: var(--text-xs);
2430
+ color: var(--text-muted);
2431
+ }
2432
+ .caps-preset-card-stats {
2433
+ display: grid;
2434
+ grid-template-columns: 1fr auto;
2435
+ gap: 2px var(--space-1);
2436
+ font-size: 11px;
2437
+ font-family: var(--font-mono);
2438
+ color: var(--text-muted);
2439
+ }
2440
+ .caps-preset-card-stats .stat-label { color: var(--text-muted); }
2441
+ .caps-preset-card-stats .stat-value { color: var(--text-primary); text-align: right; }
2442
+ .caps-preset-card.is-active .stat-value { color: var(--accent-primary); }
2443
+
2444
+ /* Gauges */
2445
+ .caps-usage-block {
2446
+ padding: 0;
2447
+ background: transparent;
2448
+ border: 0;
2449
+ }
2450
+ .caps-usage-gauges {
2451
+ display: grid;
2452
+ grid-template-columns: repeat(3, 1fr);
2453
+ gap: var(--space-2);
2454
+ }
2455
+ .caps-gauge {
2456
+ display: flex;
2457
+ flex-direction: column;
2458
+ gap: 4px;
2459
+ padding: var(--space-2);
2460
+ border: 1px solid var(--border-default);
2461
+ border-radius: var(--radius-sm);
2462
+ background: var(--bg-base);
2463
+ }
2464
+ .caps-gauge-row {
2465
+ display: flex;
2466
+ justify-content: space-between;
2467
+ align-items: baseline;
2468
+ gap: var(--space-1);
2469
+ }
2470
+ .caps-gauge-label {
2471
+ font-size: 11px;
2472
+ color: var(--text-muted);
2473
+ text-transform: uppercase;
2474
+ letter-spacing: 0.04em;
2475
+ }
2476
+ .caps-gauge-bar {
2477
+ height: 6px;
2478
+ background: var(--bg-muted);
2479
+ border-radius: 3px;
2480
+ overflow: hidden;
2481
+ }
2482
+ .caps-gauge-fill {
2483
+ display: block;
2484
+ height: 100%;
2485
+ width: 0%;
2486
+ background: var(--accent-success);
2487
+ transition: width 200ms ease, background 200ms ease;
2488
+ }
2489
+ .caps-gauge[data-state="warn"] .caps-gauge-fill { background: var(--accent-warning); }
2490
+ .caps-gauge[data-state="over"] .caps-gauge-fill { background: var(--accent-error); }
2491
+ .caps-gauge-numbers {
2492
+ font-size: 11px;
2493
+ font-family: var(--font-mono);
2494
+ color: var(--text-primary);
2495
+ }
2496
+
2497
+ /* Advanced overrides */
2498
+ .caps-advanced > summary,
2499
+ .caps-inspector-block > summary {
2500
+ cursor: pointer;
2501
+ display: flex;
2502
+ flex-direction: column;
2503
+ gap: 2px;
2504
+ list-style: none;
2505
+ padding: 0;
2506
+ margin: 0 0 var(--space-2);
2507
+ }
2508
+ .caps-advanced > summary::-webkit-details-marker,
2509
+ .caps-inspector-block > summary::-webkit-details-marker { display: none; }
2510
+ .caps-advanced > summary::before,
2511
+ .caps-inspector-block > summary::before {
2512
+ content: '▸';
2513
+ display: inline-block;
2514
+ margin-right: var(--space-1);
2515
+ color: var(--text-muted);
2516
+ transition: transform 120ms ease;
2517
+ }
2518
+ .caps-advanced[open] > summary::before,
2519
+ .caps-inspector-block[open] > summary::before { transform: rotate(90deg); }
2520
+ .caps-override-grid input {
2521
+ width: 100%;
2522
+ padding: var(--space-1) var(--space-2);
2523
+ border: 1px solid var(--border-default);
2524
+ background: var(--bg-base);
2525
+ color: var(--text-primary);
2526
+ border-radius: var(--radius-sm);
2527
+ font-size: var(--text-xs);
2528
+ font-family: var(--font-mono);
2529
+ }
2530
+ .caps-override-grid input::placeholder {
2531
+ color: var(--text-muted);
2532
+ opacity: 0.7;
2533
+ font-style: italic;
2534
+ }
2535
+
2536
+ /* Safety subsection */
2537
+ .safety-strict-block {
2538
+ margin-top: var(--space-3);
2539
+ padding-top: var(--space-2);
2540
+ border-top: 1px dashed var(--border-default);
2541
+ }
2542
+ .safety-strict-title {
2543
+ font-size: var(--text-xs);
2544
+ color: var(--text-muted);
2545
+ margin-bottom: var(--space-1);
2546
+ text-transform: uppercase;
2547
+ letter-spacing: 0.04em;
2548
+ }
2549
+ .safety-strict-note {
2550
+ font-size: var(--text-xs);
2551
+ color: var(--text-muted);
2552
+ margin-top: var(--space-2);
2553
+ line-height: 1.5;
2554
+ }
2555
+
2556
+ /* ─── Caps history chart + inspector + refusals ──────────────── */
2557
+ .caps-history-block { margin-top: var(--space-3); }
2558
+ .caps-history-title {
2559
+ font-size: var(--text-xs);
2560
+ color: var(--text-muted);
2561
+ margin-bottom: var(--space-1);
2562
+ }
2563
+ .caps-history-chart {
2564
+ border: 1px solid var(--border-default);
2565
+ background: var(--bg-base);
2566
+ border-radius: var(--radius-sm);
2567
+ padding: var(--space-2);
2568
+ min-height: 100px;
2569
+ font-size: var(--text-xs);
2570
+ color: var(--text-muted);
2571
+ }
2572
+ .caps-history-chart svg { width: 100%; height: 100px; display: block; }
2573
+ .caps-history-chart .axis { stroke: var(--border-default); stroke-width: 0.5; }
2574
+ .caps-history-chart .line { stroke: var(--accent-primary); stroke-width: 1.5; fill: none; }
2575
+ .caps-history-chart .point { fill: var(--accent-primary); }
2576
+ .caps-history-chart .label { fill: var(--text-muted); font-size: 9px; }
2577
+
2578
+ .caps-inspector { margin-top: var(--space-3); }
2579
+ .caps-inspector-row {
2580
+ display: flex;
2581
+ gap: var(--space-2);
2582
+ align-items: end;
2583
+ flex-wrap: wrap;
2584
+ }
2585
+ .caps-inspector-row label { flex: 1 1 200px; }
2586
+ .caps-inspector-row input {
2587
+ width: 100%;
2588
+ padding: var(--space-1) var(--space-2);
2589
+ border: 1px solid var(--border-default);
2590
+ background: var(--bg-base);
2591
+ color: var(--text-primary);
2592
+ border-radius: var(--radius-sm);
2593
+ font-size: var(--text-xs);
2594
+ font-family: var(--font-mono);
2595
+ }
2596
+ .caps-inspect-result {
2597
+ margin-top: var(--space-2);
2598
+ font-size: var(--text-xs);
2599
+ font-family: var(--font-mono);
2600
+ padding: var(--space-2);
2601
+ background: var(--bg-base);
2602
+ border: 1px solid var(--border-default);
2603
+ border-radius: var(--radius-sm);
2604
+ min-height: 30px;
2605
+ color: var(--text-muted);
2606
+ }
2607
+ .caps-inspect-result.has-data { color: var(--text-primary); }
2608
+
2609
+ .caps-refusals { margin-top: var(--space-3); }
2610
+ .caps-refusals summary {
2611
+ cursor: pointer;
2612
+ font-size: var(--text-sm);
2613
+ color: var(--text-muted);
2614
+ padding: var(--space-1) 0;
2615
+ }
2616
+ .caps-refusals-list {
2617
+ font-size: var(--text-xs);
2618
+ font-family: var(--font-mono);
2619
+ padding: var(--space-2);
2620
+ background: var(--bg-base);
2621
+ border: 1px solid var(--border-default);
2622
+ border-radius: var(--radius-sm);
2623
+ max-height: 240px;
2624
+ overflow-y: auto;
2625
+ }
2626
+ .caps-refusal-row {
2627
+ display: grid;
2628
+ grid-template-columns: auto 1fr auto;
2629
+ gap: var(--space-2);
2630
+ padding: var(--space-1) 0;
2631
+ border-bottom: 1px solid var(--border-default);
2632
+ }
2633
+ .caps-refusal-row:last-child { border-bottom: 0; }
2634
+ .caps-refusal-row .reason { color: var(--accent-error); }
2635
+ .caps-refusal-row .when { color: var(--text-muted); }
2636
+
2637
+ /* ─── Safety subpage ────────────────────────────────────────── */
2638
+ .checkbox-row {
2639
+ display: flex;
2640
+ flex-direction: column;
2641
+ gap: var(--space-1);
2642
+ }
2643
+ .checkbox-row .hint {
2644
+ font-size: var(--text-xs);
2645
+ color: var(--text-muted);
2646
+ margin-left: 24px;
2647
+ }
2648
+ .strict-actions-list {
2649
+ list-style: none;
2650
+ padding: 0;
2651
+ margin: var(--space-2) 0;
2652
+ }
2653
+ .strict-actions-list li {
2654
+ padding: var(--space-1) var(--space-2);
2655
+ background: var(--bg-base);
2656
+ border-left: 3px solid var(--accent-warning);
2657
+ border-radius: var(--radius-sm);
2658
+ margin-bottom: var(--space-1);
2659
+ font-family: var(--font-mono);
2660
+ font-size: var(--text-xs);
2661
+ }
2662
+
2663
+ /* ─── Updates subpage rebuild ───────────────────────────────── */
2664
+ .restart-banner {
2665
+ display: flex;
2666
+ justify-content: space-between;
2667
+ align-items: center;
2668
+ gap: var(--space-3);
2669
+ padding: var(--space-2) var(--space-3);
2670
+ background: var(--accent-warning-muted);
2671
+ color: var(--accent-warning);
2672
+ border: 1px solid var(--accent-warning);
2673
+ border-radius: var(--radius-sm);
2674
+ margin-bottom: var(--space-3);
2675
+ }
2676
+ .restart-banner-text strong { display: block; }
2677
+ .restart-banner-text span {
2678
+ font-size: var(--text-xs);
2679
+ opacity: 0.8;
2680
+ }
2681
+ .update-actions-block { margin-top: var(--space-2); }
2682
+ .update-status-badge {
2683
+ display: inline-block;
2684
+ padding: var(--space-1) var(--space-2);
2685
+ background: var(--bg-muted);
2686
+ border-radius: var(--radius-sm);
2687
+ font-size: var(--text-xs);
2688
+ margin-bottom: var(--space-2);
2689
+ }
2690
+ .update-status-badge[data-status="update_available"] {
2691
+ background: var(--accent-warning-muted);
2692
+ color: var(--accent-warning);
2693
+ }
2694
+ .update-status-badge[data-status="up_to_date"] {
2695
+ background: var(--accent-success-muted);
2696
+ color: var(--accent-success);
2697
+ }
2698
+ .update-action-row {
2699
+ display: flex;
2700
+ gap: var(--space-2);
2701
+ align-items: center;
2702
+ flex-wrap: wrap;
2703
+ }
2704
+ .checkbox-inline {
2705
+ display: inline-flex;
2706
+ gap: var(--space-1);
2707
+ align-items: center;
2708
+ font-size: var(--text-sm);
2709
+ }
2710
+ .update-result {
2711
+ margin-top: var(--space-2);
2712
+ padding: var(--space-2);
2713
+ font-size: var(--text-xs);
2714
+ font-family: var(--font-mono);
2715
+ background: var(--bg-base);
2716
+ border: 1px solid var(--border-default);
2717
+ border-radius: var(--radius-sm);
2718
+ max-height: 240px;
2719
+ overflow-y: auto;
2720
+ white-space: pre-wrap;
2721
+ }
2722
+ .update-result:empty { display: none; }
2723
+ .update-history-table {
2724
+ font-size: var(--text-xs);
2725
+ border: 1px solid var(--border-default);
2726
+ border-radius: var(--radius-sm);
2727
+ overflow: hidden;
2728
+ }
2729
+ .update-history-row {
2730
+ display: grid;
2731
+ grid-template-columns: 140px 90px 1fr 1fr 100px auto;
2732
+ gap: var(--space-2);
2733
+ padding: var(--space-1) var(--space-2);
2734
+ border-bottom: 1px solid var(--border-default);
2735
+ align-items: center;
2736
+ }
2737
+ .update-history-row:last-child { border-bottom: 0; }
2738
+ .update-history-row.header {
2739
+ background: var(--bg-muted);
2740
+ color: var(--text-muted);
2741
+ text-transform: uppercase;
2742
+ font-size: 10px;
2743
+ letter-spacing: 0.04em;
2744
+ }
2745
+ .update-history-row .kind { font-family: var(--font-mono); }
2746
+ .update-history-row .status-ok { color: var(--accent-success); }
2747
+ .update-history-row .status-fail { color: var(--accent-error); }
2748
+ .update-history-row .integrity-ok { color: var(--accent-success); }
2749
+ .update-history-row .integrity-bad { color: var(--accent-error); }
2750
+ .update-history-row .integrity-unknown { color: var(--text-muted); }
2751
+
2752
+ /* ─── Run scoping bar ──────────────────────────────────────── */
2753
+ .run-scope-bar {
2754
+ display: flex;
2755
+ justify-content: space-between;
2756
+ align-items: center;
2757
+ gap: var(--space-3);
2758
+ padding: var(--space-2);
2759
+ background: var(--lab-panel-elevated);
2760
+ border: 1px solid var(--border-default);
2761
+ border-radius: var(--radius-sm);
2762
+ margin-bottom: var(--space-2);
2763
+ }
2764
+ .run-scope-status {
2765
+ display: flex;
2766
+ gap: var(--space-2);
2767
+ align-items: center;
2768
+ font-size: var(--text-sm);
2769
+ }
2770
+ .run-scope-indicator {
2771
+ font-family: var(--font-mono);
2772
+ padding: 2px var(--space-2);
2773
+ border-radius: var(--radius-sm);
2774
+ background: var(--bg-muted);
2775
+ color: var(--text-muted);
2776
+ }
2777
+ .run-scope-indicator.active {
2778
+ background: var(--accent-success-muted);
2779
+ color: var(--accent-success);
2780
+ }
2781
+ .run-scope-actions {
2782
+ display: flex;
2783
+ gap: var(--space-2);
2784
+ align-items: center;
2785
+ }
2786
+ .run-scope-actions input {
2787
+ padding: var(--space-1) var(--space-2);
2788
+ border: 1px solid var(--border-default);
2789
+ background: var(--bg-base);
2790
+ color: var(--text-primary);
2791
+ border-radius: var(--radius-sm);
2792
+ font-size: var(--text-xs);
2793
+ min-width: 220px;
2794
+ }
2795
+ .history-sidebar-section {
2796
+ margin-top: var(--space-2);
2797
+ padding-top: var(--space-2);
2798
+ border-top: 1px solid var(--border-default);
2799
+ }
2800
+ .history-recent-runs {
2801
+ font-size: var(--text-xs);
2802
+ color: var(--text-muted);
2803
+ max-height: 200px;
2804
+ overflow-y: auto;
2805
+ }
2806
+ .history-recent-run-row {
2807
+ padding: var(--space-1) var(--space-2);
2808
+ margin-bottom: 2px;
2809
+ background: var(--bg-base);
2810
+ border-radius: var(--radius-sm);
2811
+ }
2812
+
2813
+ /* ─── History diff view ────────────────────────────────────── */
2814
+ .history-diff-view {
2815
+ margin-top: var(--space-3);
2816
+ padding: var(--space-3);
2817
+ background: var(--lab-panel-elevated);
2818
+ border: 1px solid var(--border-default);
2819
+ border-radius: var(--radius-sm);
2820
+ }
2821
+ .history-diff-header {
2822
+ display: flex;
2823
+ justify-content: space-between;
2824
+ align-items: center;
2825
+ margin-bottom: var(--space-2);
2826
+ }
2827
+ .history-diff-section {
2828
+ margin-top: var(--space-2);
2829
+ padding: var(--space-2);
2830
+ background: var(--bg-base);
2831
+ border-radius: var(--radius-sm);
2832
+ }
2833
+ .history-diff-section.added { border-left: 3px solid var(--accent-success); }
2834
+ .history-diff-section.removed { border-left: 3px solid var(--accent-error); }
2835
+ .history-diff-section.moved { border-left: 3px solid var(--accent-warning); }
2836
+ .history-diff-section h4 {
2837
+ margin: 0 0 var(--space-1) 0;
2838
+ font-size: var(--text-sm);
2839
+ }
2840
+ .history-diff-section ul {
2841
+ font-family: var(--font-mono);
2842
+ font-size: var(--text-xs);
2843
+ margin: 0;
2844
+ padding-left: var(--space-3);
2845
+ }
2846
+
2847
+ .history-version-card .diff-against {
2848
+ font-size: var(--text-xs);
2849
+ padding: var(--space-1);
2850
+ border: 1px solid var(--border-default);
2851
+ background: var(--bg-base);
2852
+ border-radius: var(--radius-sm);
2853
+ }
2854
+
2855
+ /* ─── Media Pool History (was: Provenance) ─────────────────── */
2856
+ .mpc-toolbar {
2857
+ display: flex;
2858
+ flex-wrap: wrap;
2859
+ align-items: end;
2860
+ gap: var(--space-2);
2861
+ margin-top: var(--space-2);
2862
+ margin-bottom: var(--space-2);
2863
+ }
2864
+ .mpc-toolbar-field {
2865
+ display: flex;
2866
+ flex-direction: column;
2867
+ gap: 2px;
2868
+ font-size: var(--text-xs);
2869
+ color: var(--text-muted);
2870
+ }
2871
+ .mpc-toolbar-field select,
2872
+ .mpc-toolbar-field input {
2873
+ padding: var(--space-1) var(--space-2);
2874
+ border: 1px solid var(--border-default);
2875
+ background: var(--bg-base);
2876
+ color: var(--text-primary);
2877
+ border-radius: var(--radius-sm);
2878
+ font-size: var(--text-xs);
2879
+ min-width: 160px;
2880
+ }
2881
+ .mpc-toolbar-field input { font-family: var(--font-mono); min-width: 100px; }
2882
+ .mpc-toolbar-toggle {
2883
+ display: flex;
2884
+ align-items: center;
2885
+ gap: var(--space-1);
2886
+ font-size: var(--text-xs);
2887
+ color: var(--text-primary);
2888
+ padding-bottom: 4px;
2889
+ }
2890
+ .mpc-meta {
2891
+ flex: 1;
2892
+ text-align: right;
2893
+ font-size: var(--text-xs);
2894
+ color: var(--text-muted);
2895
+ padding-bottom: 4px;
2896
+ }
2897
+ .mpc-table {
2898
+ font-size: var(--text-xs);
2899
+ border: 1px solid var(--border-default);
2900
+ border-radius: var(--radius-sm);
2901
+ overflow: hidden;
2902
+ margin-top: var(--space-2);
2903
+ }
2904
+ .mpc-row {
2905
+ display: grid;
2906
+ grid-template-columns: 150px 170px 1fr 120px 110px;
2907
+ gap: var(--space-2);
2908
+ padding: var(--space-1) var(--space-2);
2909
+ border-bottom: 1px solid var(--border-default);
2910
+ align-items: center;
2911
+ }
2912
+ .mpc-row:last-child { border-bottom: 0; }
2913
+ .mpc-row:hover:not(.header):not(.group) { background: var(--bg-muted); }
2914
+ .mpc-row.header {
2915
+ background: var(--bg-muted);
2916
+ color: var(--text-muted);
2917
+ text-transform: uppercase;
2918
+ font-size: 10px;
2919
+ letter-spacing: 0.04em;
2920
+ }
2921
+ .mpc-row.group {
2922
+ grid-template-columns: 1fr auto;
2923
+ background: var(--bg-muted);
2924
+ font-weight: 600;
2925
+ color: var(--text-primary);
2926
+ font-size: var(--text-xs);
2927
+ }
2928
+ .mpc-row.group .group-count {
2929
+ font-size: 10px;
2930
+ color: var(--text-muted);
2931
+ text-transform: uppercase;
2932
+ letter-spacing: 0.04em;
2933
+ }
2934
+ .mpc-row .action-name {
2935
+ font-family: var(--font-mono);
2936
+ color: var(--accent-primary);
2937
+ }
2938
+ .mpc-row .target-cell {
2939
+ display: flex;
2940
+ flex-direction: column;
2941
+ gap: 2px;
2942
+ min-width: 0;
2943
+ }
2944
+ .mpc-row .target-cell .target-name {
2945
+ overflow: hidden;
2946
+ text-overflow: ellipsis;
2947
+ white-space: nowrap;
2948
+ }
2949
+ .mpc-row .target-cell .target-id {
2950
+ font-family: var(--font-mono);
2951
+ font-size: 10px;
2952
+ color: var(--text-muted);
2953
+ overflow: hidden;
2954
+ text-overflow: ellipsis;
2955
+ white-space: nowrap;
2956
+ }
2957
+ .mpc-row .clip-link {
2958
+ color: var(--accent-primary);
2959
+ cursor: pointer;
2960
+ text-decoration: none;
2961
+ }
2962
+ .mpc-row .clip-link:hover { text-decoration: underline; }
2963
+ .mpc-row .run-id {
2964
+ font-family: var(--font-mono);
2965
+ color: var(--text-muted);
2966
+ font-size: 10px;
2967
+ overflow: hidden;
2968
+ text-overflow: ellipsis;
2969
+ white-space: nowrap;
2970
+ }
2971
+
2090
2972
  .review-search-results {
2091
2973
  display: flex;
2092
2974
  flex-direction: column;
@@ -2895,6 +3777,7 @@ HTML = r"""<!doctype html>
2895
3777
  <button class="nav-dropdown-item" data-panel-target="diagnostics" data-subpage-target="mcp" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7v10l8 5 8-5V7l-8-5z"></path><path d="m4 7 8 5 8-5"></path><path d="M12 12v10"></path></svg></span>MCP</button>
2896
3778
  <button class="nav-dropdown-item" data-panel-target="diagnostics" data-subpage-target="storage" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"></path><path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"></path></svg></span>Storage</button>
2897
3779
  <button class="nav-dropdown-item" data-panel-target="diagnostics" data-subpage-target="tools" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l2.1-2.1a6 6 0 0 1-7.6 7.6l-4 4a2.1 2.1 0 0 1-3-3l4-4a6 6 0 0 1 7.6-7.6z"></path></svg></span>Tools</button>
3780
+ <button class="nav-dropdown-item" data-panel-target="diagnostics" data-subpage-target="media-pool-history" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18"></path><path d="M3 6h18"></path><path d="M3 18h18"></path></svg></span>Media Pool History</button>
2898
3781
  </div>
2899
3782
  </div>
2900
3783
  <div class="control-nav-item">
@@ -2910,6 +3793,7 @@ HTML = r"""<!doctype html>
2910
3793
  <button class="control-tab has-menu" data-panel-target="preferences">Preferences <span class="tab-chevron" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"></path></svg></span></button>
2911
3794
  <div class="nav-dropdown" role="menu" aria-label="Preference pages">
2912
3795
  <button class="nav-dropdown-item" data-panel-target="preferences" data-subpage-target="analysis" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"></path><path d="m19 9-5 5-4-4-3 3"></path></svg></span>Analysis</button>
3796
+ <button class="nav-dropdown-item" data-panel-target="preferences" data-subpage-target="caps" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"></circle><path d="M12 3v9l6 3"></path></svg></span>Caps + Safety</button>
2913
3797
  <button class="nav-dropdown-item" data-panel-target="preferences" data-subpage-target="metadata" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 7h-9"></path><path d="M14 17H5"></path><circle cx="17" cy="17" r="3"></circle><circle cx="7" cy="7" r="3"></circle></svg></span>Metadata And Markers</button>
2914
3798
  <button class="nav-dropdown-item" data-panel-target="preferences" data-subpage-target="paths" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h5l2 3h11v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg></span>Paths And Workflow</button>
2915
3799
  <button class="nav-dropdown-item" data-panel-target="preferences" data-subpage-target="updates" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-2.64-6.36"></path><path d="M21 3v6h-6"></path></svg></span>MCP Updates</button>
@@ -3110,10 +3994,20 @@ HTML = r"""<!doctype html>
3110
3994
  </div>
3111
3995
  <div class="controls" id="reviewControls">
3112
3996
  <button class="secondary" id="reviewRefreshBtn">Refresh</button>
3997
+ <button class="secondary" id="reviewHistoryBtn" title="Timeline edit history (C6)">History</button>
3113
3998
  <button class="secondary" id="reviewBackBtn" style="display:none">← Back</button>
3114
3999
  </div>
3115
4000
  </div>
3116
4001
  <div id="reviewBinView">
4002
+ <div id="reviewReadinessCard" class="readiness-card" hidden>
4003
+ <div class="readiness-card-header">
4004
+ <span class="readiness-title">Project readiness</span>
4005
+ <span id="readinessEvidenceBase" class="readiness-evidence"></span>
4006
+ <button id="readinessRefreshBtn" class="secondary" type="button" title="Refresh readiness summary">Refresh</button>
4007
+ </div>
4008
+ <div id="readinessSummaryRow" class="readiness-summary-row"></div>
4009
+ <div id="readinessDetails" class="readiness-details"></div>
4010
+ </div>
3117
4011
  <div class="review-bin-filters">
3118
4012
  <input id="reviewSearchInput" type="search" placeholder="Search clips, summaries, tags, transcripts…" autocomplete="off">
3119
4013
  <select id="reviewBinFilter" aria-label="Filter by bin">
@@ -3170,6 +4064,51 @@ HTML = r"""<!doctype html>
3170
4064
  <div class="empty">Loading combined review…</div>
3171
4065
  </div>
3172
4066
  </div>
4067
+ <div id="reviewHistoryView" style="display:none">
4068
+ <div id="runScopeBar" class="run-scope-bar">
4069
+ <div class="run-scope-status">
4070
+ <span class="run-scope-label">Active run:</span>
4071
+ <span id="runScopeIndicator" class="run-scope-indicator none">none</span>
4072
+ </div>
4073
+ <div class="run-scope-actions">
4074
+ <input id="runScopeLabel" type="text" placeholder="run label (e.g. 'rough cut tighten')">
4075
+ <button id="runBeginBtn" type="button">Begin run</button>
4076
+ <button id="runEndBtn" class="secondary" type="button">End run</button>
4077
+ </div>
4078
+ </div>
4079
+ <div class="history-layout">
4080
+ <aside class="history-sidebar">
4081
+ <div class="history-sidebar-header">
4082
+ <strong>Timelines</strong>
4083
+ <button id="historyRefreshBtn" class="secondary" type="button">Refresh</button>
4084
+ </div>
4085
+ <div id="historyTimelineList" class="history-timeline-list">
4086
+ <div class="empty">Loading…</div>
4087
+ </div>
4088
+ <div class="history-archive-now">
4089
+ <button id="historyArchiveCurrentBtn" type="button">Archive current timeline</button>
4090
+ <input id="historyArchiveReason" type="text" placeholder="reason (optional)">
4091
+ </div>
4092
+ <div class="history-sidebar-section">
4093
+ <strong>Recent runs</strong>
4094
+ <div id="historyRecentRuns" class="history-recent-runs">loading…</div>
4095
+ </div>
4096
+ </aside>
4097
+ <section class="history-detail">
4098
+ <div id="historyDetailHeader" class="history-detail-header">
4099
+ <span class="empty">Select a timeline.</span>
4100
+ </div>
4101
+ <div id="historyDetailBody" class="history-detail-body"></div>
4102
+ <div id="historyDiffView" class="history-diff-view" hidden>
4103
+ <div class="history-diff-header">
4104
+ <strong>Structural diff</strong>
4105
+ <button id="historyDiffCloseBtn" class="secondary" type="button">Close</button>
4106
+ </div>
4107
+ <div id="historyDiffBody"></div>
4108
+ </div>
4109
+ </section>
4110
+ </div>
4111
+ </div>
3173
4112
  </section>
3174
4113
  </main>
3175
4114
 
@@ -3217,6 +4156,28 @@ HTML = r"""<!doctype html>
3217
4156
  <div class="empty">Tool diagnostics pending.</div>
3218
4157
  </div>
3219
4158
  </section>
4159
+
4160
+ <section class="span-12 subpage" data-subpage-scope="diagnostics" data-subpage="media-pool-history">
4161
+ <h2>Media Pool History</h2>
4162
+ <p class="section-copy">Provenance log for destructive media-pool operations — deletes, replaces, relinks. Each row captures the action, the target clip or folder, the analysis run that triggered it, and the initiator. Kept separate from timeline <code>brain_edits</code> because the addressable entity is a media pool item, not a timeline.</p>
4163
+ <div class="mpc-toolbar">
4164
+ <label class="mpc-toolbar-field">Action
4165
+ <select id="mpcActionFilter">
4166
+ <option value="">all actions</option>
4167
+ </select>
4168
+ </label>
4169
+ <label class="mpc-toolbar-field">Limit
4170
+ <input id="mpcLimit" type="number" min="10" max="500" value="50">
4171
+ </label>
4172
+ <label class="mpc-toolbar-toggle">
4173
+ <input id="mpcGroupByClip" type="checkbox">
4174
+ <span>Group by target</span>
4175
+ </label>
4176
+ <button id="mpcRefreshBtn" class="secondary" type="button">Refresh</button>
4177
+ <span id="mpcMeta" class="mpc-meta"></span>
4178
+ </div>
4179
+ <div id="mpcTable" class="mpc-table">loading…</div>
4180
+ </section>
3220
4181
  </main>
3221
4182
 
3222
4183
  <main id="panel-docs" class="panel control-grid">
@@ -3336,6 +4297,117 @@ HTML = r"""<!doctype html>
3336
4297
  </div>
3337
4298
  </section>
3338
4299
 
4300
+ <section class="span-12 subpage" data-subpage-scope="preferences" data-subpage="caps">
4301
+ <div class="settings-subhead">Caps + Safety</div>
4302
+ <p class="settings-subtitle">Token + frame budgets for analysis, plus safety rails for destructive edits. Usage is tracked per project in <code>_soul/timeline_brain.sqlite</code>.</p>
4303
+
4304
+ <div class="caps-section">
4305
+ <div class="caps-section-head">
4306
+ <div class="caps-section-title">Budget preset</div>
4307
+ <div class="caps-section-hint">Pick the preset that matches the job. Override individual fields below if needed.</div>
4308
+ </div>
4309
+ <div id="capsPresetCards" class="caps-preset-cards" role="radiogroup" aria-label="Analysis caps preset">
4310
+ <!-- cards rendered by JS -->
4311
+ </div>
4312
+ <input id="prefCapsPreset" type="hidden" value="standard">
4313
+ </div>
4314
+
4315
+ <div class="caps-section">
4316
+ <div class="caps-section-head">
4317
+ <div class="caps-section-title">Vision token usage</div>
4318
+ <div class="caps-section-hint">Live consumption against the active caps. Green = comfortable, amber = approaching, red = at or over budget.</div>
4319
+ </div>
4320
+ <div id="capsUsageBlock" class="caps-usage-block">
4321
+ <div id="capsUsageGauges" class="caps-usage-gauges">
4322
+ <div class="caps-gauge" data-scope="clip">
4323
+ <div class="caps-gauge-row">
4324
+ <span class="caps-gauge-label">Per&#8209;clip</span>
4325
+ <span class="caps-gauge-numbers">—</span>
4326
+ </div>
4327
+ <div class="caps-gauge-bar"><span class="caps-gauge-fill"></span></div>
4328
+ </div>
4329
+ <div class="caps-gauge" data-scope="job">
4330
+ <div class="caps-gauge-row">
4331
+ <span class="caps-gauge-label">Per&#8209;job</span>
4332
+ <span class="caps-gauge-numbers">—</span>
4333
+ </div>
4334
+ <div class="caps-gauge-bar"><span class="caps-gauge-fill"></span></div>
4335
+ </div>
4336
+ <div class="caps-gauge" data-scope="day">
4337
+ <div class="caps-gauge-row">
4338
+ <span class="caps-gauge-label">Today</span>
4339
+ <span class="caps-gauge-numbers">—</span>
4340
+ </div>
4341
+ <div class="caps-gauge-bar"><span class="caps-gauge-fill"></span></div>
4342
+ </div>
4343
+ </div>
4344
+ <div class="caps-history-block">
4345
+ <div class="caps-history-title">Daily vision-token usage (last 30 days)</div>
4346
+ <div id="capsHistoryChart" class="caps-history-chart">loading…</div>
4347
+ </div>
4348
+ </div>
4349
+ </div>
4350
+
4351
+ <div class="caps-section">
4352
+ <div class="caps-section-head">
4353
+ <div class="caps-section-title">Safety</div>
4354
+ <div class="caps-section-hint">Auto-archive timelines before destructive edits and refuse high-blast-radius ops when archive fails.</div>
4355
+ </div>
4356
+ <div class="settings-grid">
4357
+ <label class="checkbox-row">
4358
+ <input id="prefAutoSaveAfterArchive" type="checkbox">
4359
+ <span>Auto-save Resolve project after each archive</span>
4360
+ <small class="hint">When on, <code>project.SaveProject()</code> fires after every version-on-mutate archive. Protects against Resolve crashes losing history.</small>
4361
+ </label>
4362
+ </div>
4363
+ <div class="safety-strict-block">
4364
+ <div class="safety-strict-title">Strict mode — always on for:</div>
4365
+ <ul class="strict-actions-list">
4366
+ <li><code>timeline.delete_timelines</code></li>
4367
+ <li><code>timeline.delete_track</code></li>
4368
+ <li><code>timeline.delete_clips</code> with <code>ripple=true</code></li>
4369
+ </ul>
4370
+ <p class="safety-strict-note">Strict mode REFUSES the underlying call if the pre-mutation archive can't be created. Other destructive ops degrade silently — pass <code>strict=true</code> in any action's params to opt in to refusal for that call.</p>
4371
+ </div>
4372
+ </div>
4373
+
4374
+ <details class="caps-section caps-advanced">
4375
+ <summary>
4376
+ <span class="caps-section-title">Advanced — per-field overrides</span>
4377
+ <span class="caps-section-hint">Leave blank to use the preset value shown in the placeholder. Type a number or <code>unlimited</code> to override.</span>
4378
+ </summary>
4379
+ <div class="settings-grid caps-override-grid">
4380
+ <label>Response chars <input id="capsOvResponseChars" type="text" placeholder="(preset)" data-cap-key="response_chars"></label>
4381
+ <label>Vision tokens / clip <input id="capsOvVisionClip" type="text" placeholder="(preset)" data-cap-key="vision_tokens_per_clip"></label>
4382
+ <label>Frames / clip <input id="capsOvFramesClip" type="text" placeholder="(preset)" data-cap-key="frames_per_clip"></label>
4383
+ <label>Vision tokens / job <input id="capsOvVisionJob" type="text" placeholder="(preset)" data-cap-key="vision_tokens_per_job"></label>
4384
+ <label>Vision tokens / day <input id="capsOvVisionDay" type="text" placeholder="(preset)" data-cap-key="vision_tokens_per_day"></label>
4385
+ <label>Wall clock seconds / call <input id="capsOvWallClock" type="text" placeholder="(preset)" data-cap-key="wall_clock_seconds_per_call"></label>
4386
+ <label>Max frame dimension (px) <input id="capsOvFrameDim" type="text" placeholder="(preset)" data-cap-key="max_frame_dim_pixels"></label>
4387
+ </div>
4388
+ </details>
4389
+
4390
+ <details class="caps-section caps-inspector-block">
4391
+ <summary>
4392
+ <span class="caps-section-title">Inspector &amp; debug</span>
4393
+ <span class="caps-section-hint">Look up usage for a specific clip or batch, browse refusals, or reset today's day-scope rollup.</span>
4394
+ </summary>
4395
+ <div class="caps-inspector">
4396
+ <div class="caps-inspector-row">
4397
+ <label>Clip id <input id="capsInspectClipId" type="text" placeholder="abc123"></label>
4398
+ <label>or Job id <input id="capsInspectJobId" type="text" placeholder="batch_xyz"></label>
4399
+ <button id="capsInspectBtn" type="button">Look up</button>
4400
+ <button id="capsResetDayBtn" class="secondary" type="button" title="Delete today's day-scope usage rows (admin)">Reset today's usage</button>
4401
+ </div>
4402
+ <div id="capsInspectResult" class="caps-inspect-result">enter a clip_id or job_id and press Look up</div>
4403
+ </div>
4404
+ <div class="caps-refusals">
4405
+ <div class="caps-section-subtitle">Recent caps refusals</div>
4406
+ <div id="capsRefusalsList" class="caps-refusals-list">loading…</div>
4407
+ </div>
4408
+ </details>
4409
+ </section>
4410
+
3339
4411
  <section class="span-12 subpage" data-subpage-scope="preferences" data-subpage="metadata">
3340
4412
  <div class="settings-subhead">Metadata And Markers</div>
3341
4413
  <p class="settings-subtitle">Controls for Resolve metadata writes and source-time marker suggestions. Source media remains read-only.</p>
@@ -3430,6 +4502,15 @@ HTML = r"""<!doctype html>
3430
4502
  </section>
3431
4503
 
3432
4504
  <section class="span-12 subpage" data-subpage-scope="preferences" data-subpage="updates">
4505
+ <div id="restartNeededBanner" class="restart-banner" hidden>
4506
+ <div class="restart-banner-text">
4507
+ <strong>MCP server restart needed</strong>
4508
+ <span id="restartBannerDetail"></span>
4509
+ </div>
4510
+ <div class="restart-banner-actions">
4511
+ <button id="restartBannerAck" class="secondary" type="button">Got it</button>
4512
+ </div>
4513
+ </div>
3433
4514
  <div class="settings-subhead">MCP Updates</div>
3434
4515
  <p class="settings-subtitle">Server-wide update-check behavior for the MCP package. Checks are best-effort and should not block startup.</p>
3435
4516
  <div class="settings-grid">
@@ -3441,9 +4522,38 @@ HTML = r"""<!doctype html>
3441
4522
  <option value="never">never</option>
3442
4523
  </select>
3443
4524
  </label>
4525
+ <label>Channel
4526
+ <select id="prefUpdateChannel">
4527
+ <option value="stable">stable</option>
4528
+ <option value="beta">beta</option>
4529
+ <option value="dev">dev</option>
4530
+ </select>
4531
+ </label>
3444
4532
  <label>Check interval hours <input id="prefUpdateIntervalHours" type="number" min="0.1" step="0.1" value="24"></label>
3445
4533
  <label>Snooze hours <input id="prefUpdateSnoozeHours" type="number" min="0.1" step="0.1" value="24"></label>
3446
4534
  </div>
4535
+
4536
+ <div class="update-actions-block">
4537
+ <div class="settings-subhead" style="margin-top:24px">Apply update</div>
4538
+ <div id="updateStatusBadge" class="update-status-badge">Loading status…</div>
4539
+ <div class="update-action-row">
4540
+ <button id="updatePreviewBtn" class="secondary" type="button">Preview release notes</button>
4541
+ <label class="checkbox-inline">
4542
+ <input id="updateStashCheckbox" type="checkbox">
4543
+ <span>Stash local changes if dirty</span>
4544
+ </label>
4545
+ <label class="checkbox-inline">
4546
+ <input id="updateForceJobsCheckbox" type="checkbox">
4547
+ <span>Override active-job lock</span>
4548
+ </label>
4549
+ <button id="updateApplyBtn" type="button">Apply update</button>
4550
+ <button id="updateRollbackBtn" class="secondary" type="button">Rollback last update</button>
4551
+ </div>
4552
+ <div id="updateActionResult" class="update-result"></div>
4553
+ </div>
4554
+
4555
+ <div class="settings-subhead" style="margin-top:24px">Update history</div>
4556
+ <div id="updateHistoryTable" class="update-history-table">loading…</div>
3447
4557
  </section>
3448
4558
 
3449
4559
  </main>
@@ -3589,10 +4699,12 @@ HTML = r"""<!doctype html>
3589
4699
  mcp: 'MCP',
3590
4700
  storage: 'Storage',
3591
4701
  tools: 'Tools',
4702
+ 'media-pool-history': 'Media Pool History',
3592
4703
  },
3593
4704
  docs: DOC_LABELS,
3594
4705
  preferences: {
3595
4706
  analysis: 'Analysis',
4707
+ caps: 'Caps + Safety',
3596
4708
  metadata: 'Metadata And Markers',
3597
4709
  paths: 'Paths And Workflow',
3598
4710
  updates: 'MCP Updates',
@@ -5422,46 +6534,816 @@ HTML = r"""<!doctype html>
5422
6534
  el.textContent = `${prefix} · last ${last} · ${visible}${extra ? ` · ${extra}` : ''}`;
5423
6535
  }
5424
6536
 
5425
- function rerenderResolveMedia() {
5426
- if (state.resolveMedia) {
5427
- renderResolveMedia(state.resolveMedia);
6537
+ function rerenderResolveMedia() {
6538
+ if (state.resolveMedia) {
6539
+ renderResolveMedia(state.resolveMedia);
6540
+ } else {
6541
+ updateMediaPollStatus();
6542
+ }
6543
+ }
6544
+
6545
+ async function refreshIndex() {
6546
+ const status = await api('/api/index/status');
6547
+ state.indexStatus = status;
6548
+ renderControlPanels();
6549
+ }
6550
+
6551
+ async function buildIndex() {
6552
+ const built = await api('/api/index/build', { method: 'POST' });
6553
+ state.indexStatus = { ...built, exists: true };
6554
+ renderControlPanels();
6555
+ }
6556
+
6557
+ async function refreshAll() {
6558
+ await refreshIndex();
6559
+ await refreshResolveMedia();
6560
+ }
6561
+
6562
+ // ─── V2 Review surface ──────────────────────────────────────────────
6563
+ function formatDuration(seconds) {
6564
+ if (seconds == null || !isFinite(seconds)) return '—';
6565
+ const s = Math.max(0, Math.round(Number(seconds)));
6566
+ const m = Math.floor(s / 60);
6567
+ const r = s % 60;
6568
+ return `${m}:${String(r).padStart(2, '0')}`;
6569
+ }
6570
+
6571
+ function selectChipClass(selectPotential) {
6572
+ const v = String(selectPotential || '').toLowerCase();
6573
+ if (v === 'high') return 'select-high';
6574
+ if (v === 'low') return 'select-low';
6575
+ if (v === 'medium' || v === 'med') return 'select-medium';
6576
+ return '';
6577
+ }
6578
+
6579
+ // ─── C6 timeline-history view ────────────────────────────────────────
6580
+ state.history = state.history || { timelines: [], selectedTimeline: null, payload: null };
6581
+
6582
+ function escapeHtml(s) {
6583
+ return String(s == null ? '' : s)
6584
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
6585
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
6586
+ }
6587
+
6588
+ function formatDelta(edit) {
6589
+ const d = edit && edit.delta;
6590
+ if (d === null || d === undefined) return '<span class="delta-zero">—</span>';
6591
+ const cls = d > 0 ? 'delta-pos' : (d < 0 ? 'delta-neg' : 'delta-zero');
6592
+ const sign = d > 0 ? '+' : '';
6593
+ return `<span class="${cls}">${sign}${d}</span>`;
6594
+ }
6595
+
6596
+ function formatVersionDate(iso) {
6597
+ if (!iso) return '';
6598
+ try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
6599
+ }
6600
+
6601
+ async function refreshHistoryTimelines() {
6602
+ const list = $('historyTimelineList');
6603
+ if (list) list.innerHTML = '<div class="empty">Loading…</div>';
6604
+ const data = await api('/api/timeline_versions').catch(err => ({ success: false, error: String(err) }));
6605
+ if (!data || !data.success) {
6606
+ if (list) list.innerHTML = `<div class="empty">Error: ${escapeHtml(data?.error || 'unknown')}</div>`;
6607
+ return;
6608
+ }
6609
+ state.history.timelines = data.timelines || [];
6610
+ renderHistoryTimelineList();
6611
+ // Auto-select first timeline if nothing's selected yet.
6612
+ if (!state.history.selectedTimeline && state.history.timelines.length > 0) {
6613
+ await selectHistoryTimeline(state.history.timelines[0].timeline_name);
6614
+ } else if (state.history.selectedTimeline) {
6615
+ await loadHistoryDetail(state.history.selectedTimeline);
6616
+ } else {
6617
+ renderHistoryDetailEmpty();
6618
+ }
6619
+ }
6620
+
6621
+ function renderHistoryTimelineList() {
6622
+ const list = $('historyTimelineList');
6623
+ if (!list) return;
6624
+ if (!state.history.timelines.length) {
6625
+ list.innerHTML = '<div class="empty">No archived timelines yet. Make a destructive edit (or click Archive current).</div>';
6626
+ return;
6627
+ }
6628
+ list.innerHTML = state.history.timelines.map(tl => {
6629
+ const selected = tl.timeline_name === state.history.selectedTimeline ? ' is-selected' : '';
6630
+ return `
6631
+ <div class="history-timeline-row${selected}" data-tl-name="${escapeHtml(tl.timeline_name)}">
6632
+ <span class="name" title="${escapeHtml(tl.timeline_name)}">${escapeHtml(tl.timeline_name)}</span>
6633
+ <span class="count">v${tl.latest_version} · ${tl.version_count}×</span>
6634
+ </div>`;
6635
+ }).join('');
6636
+ list.querySelectorAll('.history-timeline-row').forEach(row => {
6637
+ row.addEventListener('click', () => {
6638
+ selectHistoryTimeline(row.dataset.tlName).catch(alertError);
6639
+ });
6640
+ });
6641
+ }
6642
+
6643
+ async function selectHistoryTimeline(timelineName) {
6644
+ state.history.selectedTimeline = timelineName;
6645
+ renderHistoryTimelineList();
6646
+ await loadHistoryDetail(timelineName);
6647
+ }
6648
+
6649
+ async function loadHistoryDetail(timelineName) {
6650
+ const body = $('historyDetailBody');
6651
+ const header = $('historyDetailHeader');
6652
+ if (body) body.innerHTML = '<div class="empty">Loading…</div>';
6653
+ if (header) header.innerHTML = `<span class="timeline-name">${escapeHtml(timelineName)}</span>`;
6654
+ const data = await api(`/api/timeline_versions/${encodeURIComponent(timelineName)}`)
6655
+ .catch(err => ({ success: false, error: String(err) }));
6656
+ if (!data || !data.success) {
6657
+ if (body) body.innerHTML = `<div class="empty">Error: ${escapeHtml(data?.error || 'unknown')}</div>`;
6658
+ return;
6659
+ }
6660
+ state.history.payload = data;
6661
+ renderHistoryDetail(data);
6662
+ }
6663
+
6664
+ function renderHistoryDetailEmpty() {
6665
+ const body = $('historyDetailBody');
6666
+ const header = $('historyDetailHeader');
6667
+ if (header) header.innerHTML = '<span class="empty">Select a timeline.</span>';
6668
+ if (body) body.innerHTML = '';
6669
+ }
6670
+
6671
+ function renderHistoryDetail(data) {
6672
+ const body = $('historyDetailBody');
6673
+ if (!body) return;
6674
+ const versions = (data.versions || []).slice().reverse(); // newest first
6675
+ const edits = data.edits || [];
6676
+
6677
+ // Bucket edits by archived_timeline_name (timeline_after for the version
6678
+ // they produced). Edits with no matching archive are shown in an "Unbucketed
6679
+ // edits" section at the bottom.
6680
+ const editsByArchive = {};
6681
+ const unbucketed = [];
6682
+ edits.forEach(edit => {
6683
+ const key = edit.timeline_after || edit.timeline_before;
6684
+ if (key && versions.some(v => v.archived_timeline_name === key)) {
6685
+ (editsByArchive[key] = editsByArchive[key] || []).push(edit);
6686
+ } else {
6687
+ unbucketed.push(edit);
6688
+ }
6689
+ });
6690
+
6691
+ const sections = [];
6692
+ if (!versions.length) {
6693
+ sections.push('<div class="empty">No archived versions yet.</div>');
6694
+ }
6695
+ versions.forEach(v => {
6696
+ const versionEdits = editsByArchive[v.archived_timeline_name] || [];
6697
+ const collapsed = v.drt_export_path
6698
+ ? `<span class="drt-collapsed">retention-collapsed → ${escapeHtml(v.drt_export_path)}</span>`
6699
+ : '';
6700
+ const editsTable = versionEdits.length ? `
6701
+ <table class="history-edits-table">
6702
+ <thead>
6703
+ <tr><th>Edit</th><th>Metric</th><th>Before</th><th>After</th><th>Δ</th><th>Rationale</th></tr>
6704
+ </thead>
6705
+ <tbody>
6706
+ ${versionEdits.map(e => `
6707
+ <tr>
6708
+ <td class="edit-type">${escapeHtml(e.edit_type || '')}</td>
6709
+ <td class="metric">${escapeHtml(e.target_metric || '—')}</td>
6710
+ <td>${e.before_value ?? '—'}</td>
6711
+ <td>${e.after_value ?? '—'}</td>
6712
+ <td>${formatDelta(e)}</td>
6713
+ <td>${escapeHtml(e.rationale || '')}</td>
6714
+ </tr>`).join('')}
6715
+ </tbody>
6716
+ </table>` : '<div class="empty">No declared brain edits recorded.</div>';
6717
+ const thumbUrl = v.thumbnail_path
6718
+ ? `/api/timeline_thumbnail/${encodeURIComponent(v.thumbnail_path.split('/_soul/timeline_versions/').pop() || '')}`
6719
+ : null;
6720
+ const thumb = thumbUrl
6721
+ ? `<div class="thumb"><img src="${thumbUrl}" alt="v${v.version} thumbnail" loading="lazy"></div>`
6722
+ : `<div class="thumb">no thumbnail</div>`;
6723
+ const diffOptions = versions
6724
+ .filter(other => other.version !== v.version)
6725
+ .map(other => `<option value="${other.version}">v${other.version}</option>`).join('');
6726
+ sections.push(`
6727
+ <div class="history-version-card">
6728
+ ${thumb}
6729
+ <div class="body">
6730
+ <div class="version-row">
6731
+ <span class="version-label">v${v.version}</span>
6732
+ <select class="diff-against" data-diff-against-base="${v.version}" data-diff-timeline="${escapeHtml(data.timeline_name)}">
6733
+ <option value="">Diff against…</option>
6734
+ ${diffOptions}
6735
+ </select>
6736
+ <button class="history-rollback-btn" data-rollback-version="${v.version}" data-rollback-timeline="${escapeHtml(data.timeline_name)}">Rollback to v${v.version}</button>
6737
+ </div>
6738
+ <div class="archived-name">${escapeHtml(v.archived_timeline_name)} ${collapsed}</div>
6739
+ <div class="timestamp">${escapeHtml(formatVersionDate(v.created_at))} · run=${escapeHtml(v.analysis_run_id || '—')}</div>
6740
+ ${v.reason ? `<div class="reason">${escapeHtml(v.reason)}</div>` : ''}
6741
+ ${editsTable}
6742
+ </div>
6743
+ </div>`);
6744
+ });
6745
+ if (unbucketed.length) {
6746
+ sections.push(`
6747
+ <div class="history-version-card">
6748
+ <div class="version-label">Recent edits (no matching archive)</div>
6749
+ <table class="history-edits-table">
6750
+ <thead>
6751
+ <tr><th>Edit</th><th>Metric</th><th>Before</th><th>After</th><th>Δ</th><th>Created</th></tr>
6752
+ </thead>
6753
+ <tbody>
6754
+ ${unbucketed.map(e => `
6755
+ <tr>
6756
+ <td class="edit-type">${escapeHtml(e.edit_type || '')}</td>
6757
+ <td class="metric">${escapeHtml(e.target_metric || '—')}</td>
6758
+ <td>${e.before_value ?? '—'}</td>
6759
+ <td>${e.after_value ?? '—'}</td>
6760
+ <td>${formatDelta(e)}</td>
6761
+ <td>${escapeHtml(formatVersionDate(e.created_at))}</td>
6762
+ </tr>`).join('')}
6763
+ </tbody>
6764
+ </table>
6765
+ </div>`);
6766
+ }
6767
+ body.innerHTML = sections.join('');
6768
+ body.querySelectorAll('.history-rollback-btn').forEach(btn => {
6769
+ btn.addEventListener('click', () => {
6770
+ const version = Number(btn.dataset.rollbackVersion);
6771
+ const timelineName = btn.dataset.rollbackTimeline;
6772
+ if (!confirm(`Rollback "${timelineName}" to v${version}? This archives the current state first, then duplicates the archived version back into the project.`)) return;
6773
+ rollbackHistoryVersion(timelineName, version).catch(alertError);
6774
+ });
6775
+ });
6776
+ body.querySelectorAll('select.diff-against').forEach(sel => {
6777
+ sel.addEventListener('change', () => {
6778
+ const against = Number(sel.value);
6779
+ if (!against) return;
6780
+ const base = Number(sel.dataset.diffAgainstBase);
6781
+ const timeline = sel.dataset.diffTimeline;
6782
+ const from = Math.min(base, against);
6783
+ const to = Math.max(base, against);
6784
+ showDiffBetween(timeline, from, to).catch(alertError);
6785
+ sel.value = '';
6786
+ });
6787
+ });
6788
+ }
6789
+
6790
+ async function rollbackHistoryVersion(timelineName, version) {
6791
+ const data = await api('/api/timeline_versions/action', {
6792
+ method: 'POST',
6793
+ body: JSON.stringify({ action: 'rollback', timeline_name: timelineName, version }),
6794
+ }).catch(err => ({ success: false, error: String(err) }));
6795
+ if (!data || !data.success) {
6796
+ alert(`Rollback failed: ${data?.error || 'unknown'}`);
6797
+ return;
6798
+ }
6799
+ alert(`Restored as "${data.restored_timeline_name}". Switch to it in Resolve to inspect.`);
6800
+ await refreshHistoryTimelines();
6801
+ }
6802
+
6803
+ async function archiveCurrentTimelineFromUI(reason) {
6804
+ const data = await api('/api/timeline_versions/action', {
6805
+ method: 'POST',
6806
+ body: JSON.stringify({ action: 'archive_current', reason: reason || undefined }),
6807
+ }).catch(err => ({ success: false, error: String(err) }));
6808
+ if (!data || !data.success) {
6809
+ alert(`Archive failed: ${data?.error || 'unknown'}`);
6810
+ return;
6811
+ }
6812
+ const reasonInput = $('historyArchiveReason');
6813
+ if (reasonInput) reasonInput.value = '';
6814
+ await refreshHistoryTimelines();
6815
+ }
6816
+
6817
+ // ─── Run scoping controls ──────────────────────────────────────────
6818
+ state.runScope = state.runScope || { current: null, recent: [] };
6819
+
6820
+ async function refreshRunScope() {
6821
+ const data = await api('/api/runs').catch(() => ({ success: false }));
6822
+ if (!data || !data.success) return;
6823
+ state.runScope.current = data.current_run_id || null;
6824
+ state.runScope.recent = data.runs || [];
6825
+ const indicator = $('runScopeIndicator');
6826
+ if (indicator) {
6827
+ if (state.runScope.current) {
6828
+ indicator.textContent = state.runScope.current;
6829
+ indicator.classList.add('active');
6830
+ indicator.classList.remove('none');
6831
+ } else {
6832
+ indicator.textContent = 'none';
6833
+ indicator.classList.remove('active');
6834
+ indicator.classList.add('none');
6835
+ }
6836
+ }
6837
+ const recent = $('historyRecentRuns');
6838
+ if (recent) {
6839
+ if (!state.runScope.recent.length) {
6840
+ recent.innerHTML = '<div class="empty">no runs yet</div>';
6841
+ } else {
6842
+ recent.innerHTML = state.runScope.recent.slice(0, 8).map(r => `
6843
+ <div class="history-recent-run-row">
6844
+ <div><strong>${escapeHtml(r.label || '(no label)')}</strong></div>
6845
+ <div>${escapeHtml(r.id || '')}</div>
6846
+ <div>${escapeHtml(r.ended_at ? 'ended ' + formatVersionDate(r.ended_at) : 'open')}</div>
6847
+ </div>
6848
+ `).join('');
6849
+ }
6850
+ }
6851
+ }
6852
+
6853
+ async function beginRunFromUI() {
6854
+ const label = ($('runScopeLabel')?.value || '').trim();
6855
+ const data = await api('/api/runs/begin', {
6856
+ method: 'POST',
6857
+ body: JSON.stringify({ label: label || undefined }),
6858
+ }).catch(err => ({ success: false, error: String(err) }));
6859
+ if (!data || !data.success) {
6860
+ alert(`Begin run failed: ${data?.error || 'unknown'}`);
6861
+ return;
6862
+ }
6863
+ const labelInput = $('runScopeLabel');
6864
+ if (labelInput) labelInput.value = '';
6865
+ await refreshRunScope();
6866
+ }
6867
+
6868
+ async function endRunFromUI() {
6869
+ const data = await api('/api/runs/end', {
6870
+ method: 'POST',
6871
+ body: JSON.stringify({}),
6872
+ }).catch(err => ({ success: false, error: String(err) }));
6873
+ if (!data || !data.success) {
6874
+ alert(`End run failed: ${data?.error || 'unknown'}`);
6875
+ return;
6876
+ }
6877
+ await refreshRunScope();
6878
+ }
6879
+
6880
+ // ─── Structural diff between versions ─────────────────────────────
6881
+ async function showDiffBetween(timelineName, fromVersion, toVersion) {
6882
+ const view = $('historyDiffView');
6883
+ const body = $('historyDiffBody');
6884
+ if (!view || !body) return;
6885
+ view.hidden = false;
6886
+ body.innerHTML = 'loading…';
6887
+ const data = await api('/api/timeline_versions/action', {
6888
+ method: 'POST',
6889
+ body: JSON.stringify({
6890
+ action: 'diff_versions', timeline_name: timelineName,
6891
+ from_version: fromVersion, to_version: toVersion,
6892
+ }),
6893
+ }).catch(err => ({ success: false, error: String(err) }));
6894
+ if (!data || !data.success) {
6895
+ body.innerHTML = `<div class="empty">diff failed: ${escapeHtml(data?.error || 'unknown')}</div>`;
6896
+ return;
6897
+ }
6898
+ const section = (kind, rows) => `
6899
+ <div class="history-diff-section ${kind}">
6900
+ <h4>${kind} (${rows.length})</h4>
6901
+ ${rows.length
6902
+ ? `<ul>${rows.slice(0, 30).map(r => `<li>${escapeHtml(r.media_pool_item_id)} @ ${escapeHtml(r.track_type)}:${r.track_index} [${r.in_frame}–${r.out_frame}]</li>`).join('')}</ul>`
6903
+ : '<div class="empty">none</div>'}
6904
+ </div>`;
6905
+ body.innerHTML = `
6906
+ <div>v${fromVersion} → v${toVersion}</div>
6907
+ ${section('added', data.added || [])}
6908
+ ${section('removed', data.removed || [])}
6909
+ ${section('moved', data.moved || [])}
6910
+ `;
6911
+ }
6912
+
6913
+ // ─── Caps history chart ───────────────────────────────────────────
6914
+ async function refreshCapsHistory() {
6915
+ const el = $('capsHistoryChart');
6916
+ if (!el) return;
6917
+ const data = await api('/api/caps/history?days=30').catch(() => ({ success: false }));
6918
+ if (!data || !data.success || !(data.history || []).length) {
6919
+ el.innerHTML = '<div class="empty">no usage recorded yet</div>';
6920
+ return;
6921
+ }
6922
+ const rows = (data.history || []).slice().reverse(); // oldest → newest
6923
+ const maxV = Math.max(1, ...rows.map(r => r.vision_tokens || 0));
6924
+ const w = 100, h = 90;
6925
+ const points = rows.map((r, i) => {
6926
+ const x = rows.length === 1 ? 0 : (i / (rows.length - 1)) * w;
6927
+ const y = h - ((r.vision_tokens || 0) / maxV) * (h - 12);
6928
+ return { x, y, value: r.vision_tokens, day: r.day_bucket };
6929
+ });
6930
+ const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
6931
+ el.innerHTML = `
6932
+ <svg viewBox="0 0 ${w} ${h + 10}" preserveAspectRatio="none">
6933
+ <line class="axis" x1="0" y1="${h}" x2="${w}" y2="${h}"></line>
6934
+ <path class="line" d="${path}"></path>
6935
+ ${points.map(p => `<circle class="point" cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="1.4"><title>${p.day}: ${p.value}</title></circle>`).join('')}
6936
+ <text class="label" x="0" y="${h + 9}">${rows[0].day_bucket}</text>
6937
+ <text class="label" x="${w}" y="${h + 9}" text-anchor="end">${rows[rows.length - 1].day_bucket}</text>
6938
+ <text class="label" x="0" y="9">max ${maxV}</text>
6939
+ </svg>`;
6940
+ }
6941
+
6942
+ // ─── Caps inspector + refusals + reset ──────────────────────────
6943
+ async function inspectCapsFromUI() {
6944
+ const clipId = ($('capsInspectClipId')?.value || '').trim();
6945
+ const jobId = ($('capsInspectJobId')?.value || '').trim();
6946
+ const out = $('capsInspectResult');
6947
+ if (!out) return;
6948
+ if (!clipId && !jobId) {
6949
+ out.textContent = 'enter a clip_id or job_id';
6950
+ out.classList.remove('has-data');
6951
+ return;
6952
+ }
6953
+ const qs = new URLSearchParams();
6954
+ if (clipId) qs.set('clip_id', clipId);
6955
+ if (jobId) qs.set('job_id', jobId);
6956
+ const data = await api(`/api/caps?${qs}`).catch(err => ({ success: false, error: String(err) }));
6957
+ if (!data || !data.success) {
6958
+ out.textContent = `lookup failed: ${data?.error || 'unknown'}`;
6959
+ return;
6960
+ }
6961
+ const usage = data.usage || {};
6962
+ out.classList.add('has-data');
6963
+ out.textContent = JSON.stringify(usage, null, 2);
6964
+ }
6965
+
6966
+ async function resetDayUsageFromUI() {
6967
+ if (!confirm('Delete today\'s day-scope usage rows? This is for testing / circuit-breaker reset only.')) return;
6968
+ const data = await api('/api/caps/reset_day', {
6969
+ method: 'POST',
6970
+ body: JSON.stringify({}),
6971
+ }).catch(err => ({ success: false, error: String(err) }));
6972
+ if (!data || !data.success) {
6973
+ alert(`Reset failed: ${data?.error || 'unknown'}`);
6974
+ return;
6975
+ }
6976
+ alert(`Deleted ${data.deleted} row(s) for ${data.day_bucket}.`);
6977
+ await refreshCapsWidget();
6978
+ await refreshCapsHistory();
6979
+ }
6980
+
6981
+ async function refreshCapsRefusals() {
6982
+ const el = $('capsRefusalsList');
6983
+ if (!el) return;
6984
+ const data = await api('/api/caps/refusals?limit=20').catch(() => ({ success: false }));
6985
+ if (!data || !data.success || !(data.events || []).length) {
6986
+ el.innerHTML = '<div class="empty">no refusals recorded</div>';
6987
+ return;
6988
+ }
6989
+ el.innerHTML = (data.events || []).map(e => `
6990
+ <div class="caps-refusal-row">
6991
+ <span class="reason">${escapeHtml(e.reason || '?')}</span>
6992
+ <span>clip=${escapeHtml(e.clip_id || '—')} job=${escapeHtml(e.job_id || '—')} est=${e.estimated_vision_tokens}</span>
6993
+ <span class="when">${escapeHtml(formatVersionDate(e.occurred_at))}</span>
6994
+ </div>
6995
+ `).join('');
6996
+ }
6997
+
6998
+ // ─── Updates page wiring ──────────────────────────────────────────
6999
+ state.updates = state.updates || { restartTimer: null };
7000
+
7001
+ async function refreshUpdateStatus() {
7002
+ const el = $('updateStatusBadge');
7003
+ if (!el) return;
7004
+ const data = await api('/api/update/status').catch(() => ({ success: false }));
7005
+ if (!data || !data.success) { el.textContent = 'status unknown'; el.dataset.status = ''; return; }
7006
+ el.dataset.status = data.status || '';
7007
+ const cur = data.current_version || '?';
7008
+ const latest = data.latest_version || '?';
7009
+ el.textContent = data.status === 'update_available'
7010
+ ? `update available: ${cur} → ${latest}`
7011
+ : (data.status === 'up_to_date' ? `up to date (${cur})` : `${data.status || 'unknown'} — ${cur}`);
7012
+ }
7013
+
7014
+ async function refreshUpdateHistory() {
7015
+ const el = $('updateHistoryTable');
7016
+ if (!el) return;
7017
+ const data = await api('/api/update/history?limit=20').catch(() => ({ success: false }));
7018
+ if (!data || !data.success || !(data.entries || []).length) {
7019
+ el.innerHTML = '<div class="empty">no update history yet</div>';
7020
+ return;
7021
+ }
7022
+ const header = `
7023
+ <div class="update-history-row header">
7024
+ <span>timestamp</span><span>kind</span><span>versions</span><span>reason / msg</span><span>integrity</span><span>by</span>
7025
+ </div>`;
7026
+ const rows = (data.entries || []).map(e => {
7027
+ const v = (e.from_version || '?') + ' → ' + (e.to_version || '?');
7028
+ const statusCls = e.success ? 'status-ok' : 'status-fail';
7029
+ const reason = e.reason || (e.message || '').slice(0, 120);
7030
+ const integ = (e.integrity || {}).verified;
7031
+ const integHtml = integ === true
7032
+ ? '<span class="integrity-ok">verified</span>'
7033
+ : integ === false ? '<span class="integrity-bad">MISMATCH</span>'
7034
+ : '<span class="integrity-unknown">—</span>';
7035
+ return `
7036
+ <div class="update-history-row">
7037
+ <span class="${statusCls}">${escapeHtml(e.timestamp || '')}</span>
7038
+ <span class="kind">${escapeHtml(e.kind || '')}</span>
7039
+ <span>${escapeHtml(v)}</span>
7040
+ <span title="${escapeHtml(reason)}">${escapeHtml(reason || '')}</span>
7041
+ <span>${integHtml}</span>
7042
+ <span>${escapeHtml(e.initiator || '')}</span>
7043
+ </div>`;
7044
+ }).join('');
7045
+ el.innerHTML = header + rows;
7046
+ }
7047
+
7048
+ async function previewUpdateFromUI() {
7049
+ const result = $('updateActionResult');
7050
+ if (result) result.textContent = 'fetching preview…';
7051
+ const data = await api('/api/update/preview').catch(err => ({ success: false, error: String(err) }));
7052
+ if (!data || !data.success) {
7053
+ if (result) result.textContent = `preview failed: ${data?.error || 'unknown'}`;
7054
+ return;
7055
+ }
7056
+ const breaking = (data.breaking_changes || []).length
7057
+ ? `⚠️ Breaking changes:\n${(data.breaking_changes || []).map(b => ' - ' + b).join('\n')}\n\n`
7058
+ : '';
7059
+ const body = (data.release_notes || '').slice(0, 4000);
7060
+ if (result) {
7061
+ result.textContent = `current: ${data.current_version} → ${data.latest_version} (${data.channel}${data.prerelease ? ', prerelease' : ''})\n\n${breaking}${body}`;
7062
+ }
7063
+ }
7064
+
7065
+ async function applyUpdateFromUI() {
7066
+ const stash = !!$('updateStashCheckbox')?.checked;
7067
+ const force = !!$('updateForceJobsCheckbox')?.checked;
7068
+ const result = $('updateActionResult');
7069
+ if (result) result.textContent = 'applying…';
7070
+ const data = await api('/api/update/apply', {
7071
+ method: 'POST',
7072
+ body: JSON.stringify({
7073
+ strategy: stash ? 'stash_if_needed' : 'refuse_on_dirty',
7074
+ force_active_jobs: force,
7075
+ }),
7076
+ }).catch(err => ({ success: false, error: String(err) }));
7077
+ if (result) result.textContent = JSON.stringify(data, null, 2);
7078
+ await refreshUpdateStatus();
7079
+ await refreshUpdateHistory();
7080
+ await refreshRestartBanner();
7081
+ }
7082
+
7083
+ async function rollbackUpdateFromUI() {
7084
+ if (!confirm('Rollback to the previous build? This runs git reset --hard to the prior SHA. Requires a clean working tree.')) return;
7085
+ const result = $('updateActionResult');
7086
+ if (result) result.textContent = 'rolling back…';
7087
+ const data = await api('/api/update/rollback', {
7088
+ method: 'POST', body: JSON.stringify({}),
7089
+ }).catch(err => ({ success: false, error: String(err) }));
7090
+ if (result) result.textContent = JSON.stringify(data, null, 2);
7091
+ await refreshUpdateHistory();
7092
+ await refreshUpdateStatus();
7093
+ }
7094
+
7095
+ async function refreshRestartBanner() {
7096
+ const banner = $('restartNeededBanner');
7097
+ if (!banner) return;
7098
+ const data = await api('/api/restart_needed').catch(() => ({ needed: false }));
7099
+ if (data && data.needed) {
7100
+ banner.hidden = false;
7101
+ const detail = $('restartBannerDetail');
7102
+ if (detail) {
7103
+ detail.textContent = `from ${data.from_version || '?'} → ${data.to_version || '?'} at ${data.applied_at || ''}`;
7104
+ }
5428
7105
  } else {
5429
- updateMediaPollStatus();
7106
+ banner.hidden = true;
5430
7107
  }
5431
7108
  }
5432
7109
 
5433
- async function refreshIndex() {
5434
- const status = await api('/api/index/status');
5435
- state.indexStatus = status;
5436
- renderControlPanels();
7110
+ async function ackRestart() {
7111
+ const data = await api('/api/restart_needed/clear', {
7112
+ method: 'POST', body: JSON.stringify({}),
7113
+ }).catch(() => ({ success: false }));
7114
+ await refreshRestartBanner();
5437
7115
  }
5438
7116
 
5439
- async function buildIndex() {
5440
- const built = await api('/api/index/build', { method: 'POST' });
5441
- state.indexStatus = { ...built, exists: true };
5442
- renderControlPanels();
7117
+ async function setUpdateChannel(value) {
7118
+ await api('/api/update/channel', {
7119
+ method: 'POST',
7120
+ body: JSON.stringify({ channel: value }),
7121
+ }).catch(() => null);
7122
+ await refreshUpdateStatus();
5443
7123
  }
5444
7124
 
5445
- async function refreshAll() {
5446
- await refreshIndex();
5447
- await refreshResolveMedia();
7125
+ // ─── Media Pool History table (was: Provenance) ─────────────────
7126
+ state.mpc = state.mpc || { allChanges: [], actions: new Set() };
7127
+
7128
+ function populateMpcActionFilter(actions) {
7129
+ const sel = $('mpcActionFilter');
7130
+ if (!sel) return;
7131
+ const prev = sel.value;
7132
+ const sorted = Array.from(actions).sort();
7133
+ sel.innerHTML = '<option value="">all actions</option>' +
7134
+ sorted.map(a => `<option value="${escapeHtml(a)}">${escapeHtml(a)}</option>`).join('');
7135
+ if (sorted.includes(prev)) sel.value = prev;
5448
7136
  }
5449
7137
 
5450
- // ─── V2 Review surface ──────────────────────────────────────────────
5451
- function formatDuration(seconds) {
5452
- if (seconds == null || !isFinite(seconds)) return '—';
5453
- const s = Math.max(0, Math.round(Number(seconds)));
5454
- const m = Math.floor(s / 60);
5455
- const r = s % 60;
5456
- return `${m}:${String(r).padStart(2, '0')}`;
7138
+ function renderMpcRows(rows) {
7139
+ const el = $('mpcTable');
7140
+ if (!el) return;
7141
+ const meta = $('mpcMeta');
7142
+ if (!rows.length) {
7143
+ el.innerHTML = '<div class="empty">no media-pool changes recorded</div>';
7144
+ if (meta) meta.textContent = '';
7145
+ return;
7146
+ }
7147
+ const header = `
7148
+ <div class="mpc-row header">
7149
+ <span>timestamp</span><span>action</span><span>target</span><span>initiator</span><span>run id</span>
7150
+ </div>`;
7151
+ const groupBy = !!$('mpcGroupByClip')?.checked;
7152
+ const renderRow = (c) => {
7153
+ const ts = escapeHtml(formatVersionDate(c.created_at));
7154
+ const action = escapeHtml(c.action || '');
7155
+ const tname = c.target_name || '';
7156
+ const tid = c.target_id || '';
7157
+ const clipId = (c.params && (c.params.clip_id || c.params.id || c.params.target_id)) || null;
7158
+ const targetCell = clipId
7159
+ ? `<div class="target-cell"><a class="clip-link" data-clip-jump="${escapeHtml(clipId)}">${escapeHtml(tname || clipId)}</a><span class="target-id">${escapeHtml(tid || clipId)}</span></div>`
7160
+ : `<div class="target-cell"><span class="target-name">${escapeHtml(tname || tid || '—')}</span>${tid && tname ? `<span class="target-id">${escapeHtml(tid)}</span>` : ''}</div>`;
7161
+ return `
7162
+ <div class="mpc-row">
7163
+ <span>${ts}</span>
7164
+ <span class="action-name">${action}</span>
7165
+ ${targetCell}
7166
+ <span>${escapeHtml(c.initiator || '')}</span>
7167
+ <span class="run-id" title="${escapeHtml(c.analysis_run_id || '')}">${escapeHtml((c.analysis_run_id || '—').slice(0, 18))}</span>
7168
+ </div>`;
7169
+ };
7170
+ let body = '';
7171
+ if (groupBy) {
7172
+ const groups = new Map();
7173
+ for (const c of rows) {
7174
+ const key = (c.target_name || c.target_id || '—');
7175
+ if (!groups.has(key)) groups.set(key, []);
7176
+ groups.get(key).push(c);
7177
+ }
7178
+ for (const [key, items] of groups) {
7179
+ body += `<div class="mpc-row group"><span>${escapeHtml(key)}</span><span class="group-count">${items.length} change${items.length === 1 ? '' : 's'}</span></div>`;
7180
+ body += items.map(renderRow).join('');
7181
+ }
7182
+ } else {
7183
+ body = rows.map(renderRow).join('');
7184
+ }
7185
+ el.innerHTML = header + body;
7186
+ if (meta) meta.textContent = `${rows.length} record${rows.length === 1 ? '' : 's'}`;
5457
7187
  }
5458
7188
 
5459
- function selectChipClass(selectPotential) {
5460
- const v = String(selectPotential || '').toLowerCase();
5461
- if (v === 'high') return 'select-high';
5462
- if (v === 'low') return 'select-low';
5463
- if (v === 'medium' || v === 'med') return 'select-medium';
5464
- return '';
7189
+ async function refreshMpcTable() {
7190
+ const limit = parseInt(($('mpcLimit')?.value || '50'), 10) || 50;
7191
+ const data = await api(`/api/media_pool_changes?limit=${limit}`).catch(() => ({ success: false }));
7192
+ if (!data || !data.success) {
7193
+ renderMpcRows([]);
7194
+ return;
7195
+ }
7196
+ const changes = data.changes || [];
7197
+ state.mpc.allChanges = changes;
7198
+ const actions = new Set(changes.map(c => c.action).filter(Boolean));
7199
+ state.mpc.actions = actions;
7200
+ populateMpcActionFilter(actions);
7201
+ applyMpcFilter();
7202
+ }
7203
+
7204
+ function applyMpcFilter() {
7205
+ const filter = ($('mpcActionFilter')?.value || '').trim();
7206
+ const rows = filter
7207
+ ? state.mpc.allChanges.filter(c => c.action === filter)
7208
+ : state.mpc.allChanges;
7209
+ renderMpcRows(rows);
7210
+ }
7211
+
7212
+ // ─── Analysis caps widget ────────────────────────────────────────────
7213
+ state.caps = state.caps || { preset: 'standard', usage: null, debounce: null, presetsAvailable: null };
7214
+
7215
+ const CAPS_OVERRIDE_FIELDS = [
7216
+ ['capsOvResponseChars', 'response_chars'],
7217
+ ['capsOvVisionClip', 'vision_tokens_per_clip'],
7218
+ ['capsOvFramesClip', 'frames_per_clip'],
7219
+ ['capsOvVisionJob', 'vision_tokens_per_job'],
7220
+ ['capsOvVisionDay', 'vision_tokens_per_day'],
7221
+ ['capsOvWallClock', 'wall_clock_seconds_per_call'],
7222
+ ['capsOvFrameDim', 'max_frame_dim_pixels'],
7223
+ ];
7224
+
7225
+ const CAPS_PRESET_TAGS = {
7226
+ minimal: 'Preview / triage',
7227
+ standard: 'Realistic default',
7228
+ generous: 'High-fidelity, few clips',
7229
+ unlimited: 'Guards off — use with care',
7230
+ };
7231
+
7232
+ const CAPS_PRESET_STAT_ORDER = [
7233
+ ['response_chars', 'response chars'],
7234
+ ['vision_tokens_per_clip', 'tokens / clip'],
7235
+ ['frames_per_clip', 'frames / clip'],
7236
+ ['vision_tokens_per_job', 'tokens / job'],
7237
+ ['vision_tokens_per_day', 'tokens / day'],
7238
+ ['wall_clock_seconds_per_call', 'wall clock (s)'],
7239
+ ['max_frame_dim_pixels', 'frame dim px'],
7240
+ ];
7241
+
7242
+ function formatCapValue(v) {
7243
+ if (v === null || v === undefined) return '∞';
7244
+ if (typeof v === 'number') {
7245
+ if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1).replace(/\.0$/, '') + 'k';
7246
+ return String(v);
7247
+ }
7248
+ return String(v);
7249
+ }
7250
+
7251
+ function renderCapsPresetCards() {
7252
+ const container = $('capsPresetCards');
7253
+ if (!container) return;
7254
+ const presets = state.caps.presetsAvailable || {};
7255
+ const order = ['minimal', 'standard', 'generous', 'unlimited'];
7256
+ const active = state.caps.preset || 'standard';
7257
+ container.innerHTML = order.filter(k => presets[k]).map(key => {
7258
+ const p = presets[key];
7259
+ const stats = CAPS_PRESET_STAT_ORDER.map(([field, label]) => `
7260
+ <span class="stat-label">${escapeHtml(label)}</span>
7261
+ <span class="stat-value">${escapeHtml(formatCapValue(p[field]))}</span>
7262
+ `).join('');
7263
+ return `
7264
+ <button type="button" class="caps-preset-card${key === active ? ' is-active' : ''}" data-preset-card="${escapeHtml(key)}" role="radio" aria-checked="${key === active ? 'true' : 'false'}">
7265
+ <div class="caps-preset-card-head">
7266
+ <span class="caps-preset-card-name">${escapeHtml(key)}</span>
7267
+ ${key === active ? '<span class="caps-preset-card-badge">Active</span>' : ''}
7268
+ </div>
7269
+ <div class="caps-preset-card-tag">${escapeHtml(CAPS_PRESET_TAGS[key] || '')}</div>
7270
+ <div class="caps-preset-card-stats">${stats}</div>
7271
+ </button>
7272
+ `;
7273
+ }).join('');
7274
+ }
7275
+
7276
+ function applyCapsOverridePlaceholders(presetKey) {
7277
+ const presets = state.caps.presetsAvailable || {};
7278
+ const p = presets[presetKey] || presets[state.caps.preset] || {};
7279
+ for (const [domId, key] of CAPS_OVERRIDE_FIELDS) {
7280
+ const el = $(domId);
7281
+ if (!el) continue;
7282
+ const val = p[key];
7283
+ el.placeholder = (val === null || val === undefined) ? '∞' : `${presetKey}: ${val}`;
7284
+ }
7285
+ }
7286
+
7287
+ async function refreshCapsWidget() {
7288
+ const data = await api('/api/caps').catch(err => ({ success: false, error: String(err) }));
7289
+ if (!data || !data.success) return;
7290
+ state.caps.preset = data.preset;
7291
+ state.caps.usage = data.usage || null;
7292
+ state.caps.presetsAvailable = data.presets_available || state.caps.presetsAvailable;
7293
+ const presetEl = $('prefCapsPreset');
7294
+ if (presetEl) presetEl.value = data.preset;
7295
+ renderCapsPresetCards();
7296
+ applyCapsOverridePlaceholders(data.preset);
7297
+ // Render usage gauges (only if a project is open, otherwise the usage
7298
+ // block stays at its placeholder).
7299
+ const usage = data.usage;
7300
+ if (usage) {
7301
+ renderCapsGauge('clip', usage.usage?.clip?.vision_tokens, usage.caps?.vision_tokens_per_clip, usage.percent_consumed?.clip);
7302
+ renderCapsGauge('job', usage.usage?.job?.vision_tokens, usage.caps?.vision_tokens_per_job, usage.percent_consumed?.job);
7303
+ renderCapsGauge('day', usage.usage?.day?.vision_tokens, usage.caps?.vision_tokens_per_day, usage.percent_consumed?.day);
7304
+ }
7305
+ }
7306
+
7307
+ function renderCapsGauge(scope, used, cap, percent) {
7308
+ const gauge = document.querySelector(`.caps-gauge[data-scope="${scope}"]`);
7309
+ if (!gauge) return;
7310
+ const fill = gauge.querySelector('.caps-gauge-fill');
7311
+ const numbers = gauge.querySelector('.caps-gauge-numbers');
7312
+ const u = used ?? 0;
7313
+ if (cap === null || cap === undefined) {
7314
+ fill.style.width = '0%';
7315
+ numbers.textContent = `${formatCapValue(u)} / ∞`;
7316
+ gauge.dataset.state = '';
7317
+ return;
7318
+ }
7319
+ const p = percent != null ? percent : (cap > 0 ? Math.min(100, 100 * u / cap) : 0);
7320
+ fill.style.width = `${p}%`;
7321
+ numbers.textContent = `${formatCapValue(u)} / ${formatCapValue(cap)} · ${p.toFixed(0)}%`;
7322
+ gauge.dataset.state = p >= 90 ? 'over' : (p >= 70 ? 'warn' : '');
7323
+ }
7324
+
7325
+ function persistCapsFromUI() {
7326
+ const presetEl = $('prefCapsPreset');
7327
+ const preset = (presetEl && presetEl.value) || 'standard';
7328
+ const overrides = {};
7329
+ for (const [domId, key] of CAPS_OVERRIDE_FIELDS) {
7330
+ const el = $(domId);
7331
+ if (!el) continue;
7332
+ const raw = (el.value || '').trim();
7333
+ if (!raw) continue;
7334
+ overrides[key] = raw === 'unlimited' ? null : (Number.isFinite(+raw) ? +raw : raw);
7335
+ }
7336
+ clearTimeout(state.caps.debounce);
7337
+ state.caps.debounce = setTimeout(async () => {
7338
+ const result = await api('/api/caps', {
7339
+ method: 'POST',
7340
+ body: JSON.stringify({ preset, overrides }),
7341
+ }).catch(err => ({ success: false, error: String(err) }));
7342
+ if (!result || !result.success) {
7343
+ console.warn('caps save failed:', result?.error);
7344
+ }
7345
+ await refreshCapsWidget();
7346
+ }, 300);
5465
7347
  }
5466
7348
 
5467
7349
  function reviewSetView(view, opts = {}) {
@@ -5473,6 +7355,8 @@ HTML = r"""<!doctype html>
5473
7355
  if (transcriptEl) transcriptEl.style.display = view === 'transcript' ? '' : 'none';
5474
7356
  const combinedEl = $('reviewCombinedView');
5475
7357
  if (combinedEl) combinedEl.style.display = view === 'combined' ? '' : 'none';
7358
+ const historyEl = $('reviewHistoryView');
7359
+ if (historyEl) historyEl.style.display = view === 'history' ? '' : 'none';
5476
7360
  const back = $('reviewBackBtn');
5477
7361
  if (back) back.style.display = view === 'bin' ? 'none' : '';
5478
7362
  const meta = $('reviewMeta');
@@ -5482,6 +7366,7 @@ HTML = r"""<!doctype html>
5482
7366
  else if (view === 'shot') meta.textContent = `Shot ${state.review.currentShotIndex} of ${state.review.currentClipData?.card?.clip_name || 'clip'}`;
5483
7367
  else if (view === 'transcript') meta.textContent = `Transcript · ${state.review.currentClipData?.card?.clip_name || 'clip'}`;
5484
7368
  else if (view === 'combined') meta.textContent = `Combined review · ${state.review.combinedData?.clip_count || '?'} clips`;
7369
+ else if (view === 'history') meta.textContent = 'Timeline history · versions and brain edits per timeline (C6)';
5485
7370
  }
5486
7371
  if (opts.writePanelState !== false) {
5487
7372
  writePanelStateAsync({
@@ -5500,8 +7385,63 @@ HTML = r"""<!doctype html>
5500
7385
  state.review.clipList = data;
5501
7386
  populateBinFilter();
5502
7387
  renderReviewBin();
7388
+ // Coverage runs in parallel — failures here must not block the clip grid.
7389
+ refreshReadinessCard().catch(() => {});
5503
7390
  }
5504
7391
 
7392
+ async function refreshReadinessCard() {
7393
+ const card = $('reviewReadinessCard');
7394
+ if (!card) return;
7395
+ const data = await api('/api/coverage').catch(err => ({ success: false, error: String(err) }));
7396
+ if (!data || !data.success) {
7397
+ card.hidden = true;
7398
+ return;
7399
+ }
7400
+ card.hidden = false;
7401
+ const summary = data.summary || {};
7402
+ const evidence = $('readinessEvidenceBase');
7403
+ const row = $('readinessSummaryRow');
7404
+ const details = $('readinessDetails');
7405
+ const total = summary.clips_total_with_reports || 0;
7406
+ const signed = summary.clips_signed || 0;
7407
+ const superseded = summary.clips_superseded_by_relink || 0;
7408
+ const visionPending = summary.clips_vision_pending || 0;
7409
+ const warnings = summary.technical_warning_count || 0;
7410
+ const pct = total ? Math.round((signed / total) * 100) : 0;
7411
+ const trustDist = summary.source_trust_distribution || {};
7412
+ const layers = summary.layer_coverage || {};
7413
+ const fragments = [`${signed}/${total} clips analyzed (${pct}%)`];
7414
+ if (superseded) fragments.push(`${superseded} relink-superseded`);
7415
+ if (visionPending) fragments.push(`${visionPending} vision pending`);
7416
+ if (warnings) fragments.push(`${warnings} warnings`);
7417
+ if (evidence) evidence.textContent = 'evidence base: ' + fragments.join(', ') + '.';
7418
+ if (row) {
7419
+ row.innerHTML = [
7420
+ { label: 'Analyzed', value: signed, kind: 'good' },
7421
+ { label: 'Superseded', value: superseded, kind: superseded ? 'danger' : '' },
7422
+ { label: 'Vision pending', value: visionPending, kind: visionPending ? 'warn' : '' },
7423
+ { label: 'Warnings', value: warnings, kind: warnings ? 'warn' : '' },
7424
+ ].map(stat => `<div class="readiness-stat ${stat.kind}"><span class="stat-value">${stat.value}</span><span class="stat-label">${escapeHtml(stat.label)}</span></div>`).join('');
7425
+ }
7426
+ if (details) {
7427
+ const trustChips = Object.entries(trustDist)
7428
+ .sort((a, b) => b[1] - a[1])
7429
+ .map(([k, v]) => `<span class="chip" title="source_trust">${escapeHtml(k)}: ${v}</span>`)
7430
+ .join('');
7431
+ const layerChips = ['technical', 'motion', 'transcription', 'vision', 'cut_analysis', 'readthrough']
7432
+ .map(layer => `<span class="chip" title="layer present count">${escapeHtml(layer)}: ${layers[layer] || 0}</span>`)
7433
+ .join('');
7434
+ details.innerHTML = `<span class="chip" style="background:none">source_trust:</span>${trustChips}<span class="chip" style="background:none">layers:</span>${layerChips}`;
7435
+ }
7436
+ }
7437
+
7438
+ document.addEventListener('click', (e) => {
7439
+ const target = e.target;
7440
+ if (target && target.id === 'readinessRefreshBtn') {
7441
+ refreshReadinessCard().catch(() => {});
7442
+ }
7443
+ });
7444
+
5505
7445
  function populateBinFilter() {
5506
7446
  const data = state.review.clipList;
5507
7447
  const select = $('reviewBinFilter');
@@ -7791,6 +9731,109 @@ HTML = r"""<!doctype html>
7791
9731
  if (state.review && state.review.panelStateTimer) clearInterval(state.review.panelStateTimer);
7792
9732
  });
7793
9733
  $('reviewRefreshBtn').onclick = () => refreshReviewBin().catch(alertError);
9734
+ const historyBtnEl = $('reviewHistoryBtn');
9735
+ if (historyBtnEl) {
9736
+ historyBtnEl.onclick = () => {
9737
+ reviewSetView('history');
9738
+ refreshHistoryTimelines().catch(alertError);
9739
+ };
9740
+ }
9741
+ const historyRefreshEl = $('historyRefreshBtn');
9742
+ if (historyRefreshEl) {
9743
+ historyRefreshEl.onclick = () => refreshHistoryTimelines().catch(alertError);
9744
+ }
9745
+ const historyArchiveBtnEl = $('historyArchiveCurrentBtn');
9746
+ if (historyArchiveBtnEl) {
9747
+ historyArchiveBtnEl.onclick = () => {
9748
+ const reason = ($('historyArchiveReason')?.value || '').trim();
9749
+ archiveCurrentTimelineFromUI(reason).catch(alertError);
9750
+ };
9751
+ }
9752
+ // Caps widget bootstrap + listeners
9753
+ const capsPresetCardsEl = $('capsPresetCards');
9754
+ if (capsPresetCardsEl) {
9755
+ capsPresetCardsEl.addEventListener('click', (ev) => {
9756
+ const card = ev.target.closest('[data-preset-card]');
9757
+ if (!card) return;
9758
+ const preset = card.dataset.presetCard;
9759
+ if (!preset) return;
9760
+ const hidden = $('prefCapsPreset');
9761
+ if (hidden) hidden.value = preset;
9762
+ state.caps.preset = preset;
9763
+ // Optimistic UI: re-render cards + override placeholders immediately.
9764
+ renderCapsPresetCards();
9765
+ applyCapsOverridePlaceholders(preset);
9766
+ persistCapsFromUI();
9767
+ });
9768
+ }
9769
+ for (const [domId] of CAPS_OVERRIDE_FIELDS) {
9770
+ const el = $(domId);
9771
+ if (el) el.addEventListener('input', persistCapsFromUI);
9772
+ }
9773
+ refreshCapsWidget().catch(() => {});
9774
+ refreshCapsHistory().catch(() => {});
9775
+ refreshCapsRefusals().catch(() => {});
9776
+
9777
+ // Caps inspector + reset
9778
+ $('capsInspectBtn')?.addEventListener('click', () => inspectCapsFromUI().catch(alertError));
9779
+ $('capsResetDayBtn')?.addEventListener('click', () => resetDayUsageFromUI().catch(alertError));
9780
+
9781
+ // Run scoping bar
9782
+ $('runBeginBtn')?.addEventListener('click', () => beginRunFromUI().catch(alertError));
9783
+ $('runEndBtn')?.addEventListener('click', () => endRunFromUI().catch(alertError));
9784
+ refreshRunScope().catch(() => {});
9785
+
9786
+ // History diff close
9787
+ $('historyDiffCloseBtn')?.addEventListener('click', () => {
9788
+ const view = $('historyDiffView');
9789
+ if (view) view.hidden = true;
9790
+ });
9791
+
9792
+ // Updates page wiring
9793
+ $('updatePreviewBtn')?.addEventListener('click', () => previewUpdateFromUI().catch(alertError));
9794
+ $('updateApplyBtn')?.addEventListener('click', () => applyUpdateFromUI().catch(alertError));
9795
+ $('updateRollbackBtn')?.addEventListener('click', () => rollbackUpdateFromUI().catch(alertError));
9796
+ $('prefUpdateChannel')?.addEventListener('change', e => setUpdateChannel(e.target.value).catch(() => {}));
9797
+ $('restartBannerAck')?.addEventListener('click', () => ackRestart().catch(() => {}));
9798
+ refreshUpdateStatus().catch(() => {});
9799
+ refreshUpdateHistory().catch(() => {});
9800
+ refreshRestartBanner().catch(() => {});
9801
+ // Poll for restart marker every 30s
9802
+ if (state.updates.restartTimer) clearInterval(state.updates.restartTimer);
9803
+ state.updates.restartTimer = setInterval(() => refreshRestartBanner().catch(() => {}), 30000);
9804
+
9805
+ // Auto-save preference (server-side preference; reads via /api/setup/defaults)
9806
+ $('prefAutoSaveAfterArchive')?.addEventListener('change', async e => {
9807
+ const data = await api('/api/setup/defaults', {
9808
+ method: 'POST',
9809
+ body: JSON.stringify({ timeline_versioning_auto_save_after_archive: !!e.target.checked }),
9810
+ }).catch(err => ({ success: false, error: String(err) }));
9811
+ if (!data || !data.success) {
9812
+ alert(`Save failed: ${data?.error || 'unknown'}`);
9813
+ }
9814
+ });
9815
+
9816
+ // Media Pool History (Diagnostics)
9817
+ $('mpcRefreshBtn')?.addEventListener('click', () => refreshMpcTable().catch(alertError));
9818
+ $('mpcLimit')?.addEventListener('change', () => refreshMpcTable().catch(alertError));
9819
+ $('mpcActionFilter')?.addEventListener('change', () => applyMpcFilter());
9820
+ $('mpcGroupByClip')?.addEventListener('change', () => applyMpcFilter());
9821
+ $('mpcTable')?.addEventListener('click', (ev) => {
9822
+ const link = ev.target.closest('[data-clip-jump]');
9823
+ if (!link) return;
9824
+ ev.preventDefault();
9825
+ const clipId = link.dataset.clipJump;
9826
+ if (!clipId) return;
9827
+ try {
9828
+ setPanel('analysis', { subpage: 'review' });
9829
+ if (typeof openClipDetail === 'function') {
9830
+ openClipDetail(clipId).catch(err => console.warn('clip-jump failed', err));
9831
+ }
9832
+ } catch (err) {
9833
+ console.warn('clip-jump failed', err);
9834
+ }
9835
+ });
9836
+ refreshMpcTable().catch(() => {});
7794
9837
  $('reviewBackBtn').onclick = () => {
7795
9838
  if (state.review.view === 'shot') {
7796
9839
  state.review.currentShotIndex = null;
@@ -7815,6 +9858,8 @@ HTML = r"""<!doctype html>
7815
9858
  state.review.currentClipId = null;
7816
9859
  state.review.currentClipData = null;
7817
9860
  reviewSetView('bin');
9861
+ } else if (state.review.view === 'history') {
9862
+ reviewSetView('bin');
7818
9863
  }
7819
9864
  };
7820
9865
  $('reviewBinGrid').addEventListener('click', event => {
@@ -10007,6 +12052,68 @@ def _v2_open_clip_in_resolve(body: Dict[str, Any]) -> Dict[str, Any]:
10007
12052
  return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
10008
12053
 
10009
12054
 
12055
+ # ─── C6: timeline version chain + brain-edit history helpers ─────────────────
12056
+
12057
+
12058
+ def list_timelines_with_versions(project_root: str) -> Dict[str, Any]:
12059
+ """Every timeline that has at least one archived version, with counts."""
12060
+ try:
12061
+ conn = _timeline_brain_db.connect(project_root)
12062
+ except Exception as exc:
12063
+ return {"success": False, "error": f"{type(exc).__name__}: {exc}", "timelines": []}
12064
+ rows = conn.execute(
12065
+ """
12066
+ SELECT timeline_name,
12067
+ COUNT(*) AS version_count,
12068
+ MAX(version) AS latest_version,
12069
+ MAX(created_at) AS most_recent
12070
+ FROM timeline_versions
12071
+ GROUP BY timeline_name
12072
+ ORDER BY most_recent DESC NULLS LAST
12073
+ """
12074
+ ).fetchall()
12075
+ return {
12076
+ "success": True,
12077
+ "timelines": [dict(r) for r in rows],
12078
+ }
12079
+
12080
+
12081
+ def get_timeline_history_payload(
12082
+ project_root: str, timeline_name: str, *, history_limit: int = 200,
12083
+ ) -> Dict[str, Any]:
12084
+ """Combined payload: version chain + brain edits for a single timeline."""
12085
+ versions = _timeline_versioning.list_timeline_versions(
12086
+ project_root=project_root, timeline_name=timeline_name,
12087
+ )
12088
+ edits = _brain_edits.get_brain_edit_history(
12089
+ project_root=project_root, timeline_name=timeline_name, limit=history_limit,
12090
+ )
12091
+ return {
12092
+ "success": True,
12093
+ "timeline_name": timeline_name,
12094
+ "versions": versions,
12095
+ "edits": edits,
12096
+ }
12097
+
12098
+
12099
+ def proxy_timeline_versioning_action(body: Dict[str, Any]) -> Dict[str, Any]:
12100
+ """Bridge dashboard → MCP server timeline_versioning tool.
12101
+
12102
+ Body shape: {action, ...params}. Used for write actions (archive, rollback,
12103
+ prune) that need a live Resolve connection.
12104
+ """
12105
+ action = (body.get("action") or "").strip()
12106
+ if not action:
12107
+ return {"success": False, "error": "action required"}
12108
+ try:
12109
+ from src.server import timeline_versioning as _tv_tool
12110
+ params = {k: v for k, v in body.items() if k != "action"}
12111
+ return _tv_tool(action, params=params)
12112
+ except Exception as exc: # noqa: BLE001
12113
+ return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
12114
+
12115
+
12116
+
10010
12117
  def _v2_enrich_search_results(project_root: str, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
10011
12118
  """Augment /api/index/query results with fps, shot_index, and a thumbnail frame.
10012
12119
 
@@ -10491,17 +12598,62 @@ def _mcp_uninstall_payload(client_id: str) -> Dict[str, Any]:
10491
12598
  return {"success": True, "message": f"Removed davinci-resolve from {config_path}", "client_id": client_id}
10492
12599
 
10493
12600
 
10494
- def _update_apply_payload() -> Dict[str, Any]:
12601
+ def _list_active_batch_jobs(project_root: str) -> List[Dict[str, Any]]:
12602
+ """Across the analysis base root, find batch jobs with status='running'.
12603
+
12604
+ Used by the update apply path: refuse to update mid-job because it would
12605
+ corrupt in-flight clip analysis state (the new build's schema may not match
12606
+ what the running batch was started against).
12607
+ """
12608
+ out: List[Dict[str, Any]] = []
12609
+ if not project_root:
12610
+ return out
12611
+ base = os.path.dirname(os.path.normpath(project_root))
12612
+ if not os.path.isdir(base):
12613
+ return out
12614
+ for entry in sorted(os.listdir(base)):
12615
+ candidate = os.path.join(base, entry)
12616
+ if not os.path.isdir(candidate):
12617
+ continue
12618
+ try:
12619
+ payload = list_batch_jobs(candidate, limit=200)
12620
+ except Exception:
12621
+ continue
12622
+ for job in payload.get("jobs") or []:
12623
+ if (job.get("status") or "").lower() == "running":
12624
+ out.append({
12625
+ "project_root": candidate,
12626
+ "job_id": job.get("job_id"),
12627
+ "status": job.get("status"),
12628
+ "started_at": job.get("started_at"),
12629
+ })
12630
+ return out
12631
+
12632
+
12633
+ def _update_apply_payload(*, strategy: str = "refuse_on_dirty", force_active_jobs: bool = False,
12634
+ project_root: Optional[str] = None) -> Dict[str, Any]:
10495
12635
  """Apply a guarded git fast-forward update by delegating to install.py's
10496
12636
  existing apply_safe_self_update. Returns a structured result for the UI.
10497
12637
  """
12638
+ # Active-job lock — refuse to update if a batch analysis job is mid-flight.
12639
+ # Pass force_active_jobs=true to override (the user explicitly accepts the
12640
+ # risk of in-flight state being inconsistent with the new build).
12641
+ if not force_active_jobs and project_root:
12642
+ active = _list_active_batch_jobs(project_root)
12643
+ if active:
12644
+ return {
12645
+ "success": False,
12646
+ "reason": "active_jobs",
12647
+ "message": f"{len(active)} batch analysis job(s) are currently running. Cancel them or pass force=true to override.",
12648
+ "active_jobs": active,
12649
+ }
10498
12650
  try:
10499
12651
  sys.path.insert(0, _repo_root())
10500
12652
  from install import apply_safe_self_update # type: ignore
10501
12653
  except Exception as exc:
10502
12654
  return {"success": False, "error": f"update helper unavailable: {exc}"}
10503
12655
  try:
10504
- result = apply_safe_self_update(_repo_root(), dry_run=False)
12656
+ result = apply_safe_self_update(_repo_root(), dry_run=False, initiator="dashboard", strategy=strategy)
10505
12657
  except Exception as exc:
10506
12658
  return {"success": False, "error": str(exc)}
10507
12659
  out: Dict[str, Any] = {
@@ -10510,12 +12662,148 @@ def _update_apply_payload() -> Dict[str, Any]:
10510
12662
  "reason": result.get("reason"),
10511
12663
  "message": result.get("message"),
10512
12664
  "current_version": _mcp_version(),
12665
+ "from_version": result.get("from_version"),
12666
+ "to_version": result.get("to_version"),
12667
+ "from_sha": result.get("from_sha"),
12668
+ "to_sha": result.get("to_sha"),
10513
12669
  }
10514
12670
  if result.get("success") and result.get("changed"):
10515
12671
  out["restart_required"] = True
12672
+ # Eagerly migrate per-project DBs so schema bumps in the new build
12673
+ # surface immediately instead of waiting for the next analysis call.
12674
+ out["db_migrations"] = _eager_migrate_after_update(project_root)
12675
+ # Drop a restart-needed marker the host / dashboard can poll for.
12676
+ _write_restart_marker(_repo_root(), result)
12677
+ # Surface stash status on the result.
12678
+ for k in ("stash_ref", "stash_pop_conflict", "remediation"):
12679
+ if k in result and result[k] is not None:
12680
+ out[k] = result[k]
12681
+ return out
12682
+
12683
+
12684
+ def _write_restart_marker(repo_root: str, update_result: Dict[str, Any]) -> None:
12685
+ """Drop a `.mcp_restart_needed` marker file with update metadata.
12686
+
12687
+ The MCP server is a child process of the host (Claude Code, etc.) so we
12688
+ can't restart it ourselves. The marker is a hint the host can poll via
12689
+ `/api/restart_needed` or by reading the file directly.
12690
+ """
12691
+ log_dir = os.path.join(repo_root, "logs")
12692
+ try:
12693
+ os.makedirs(log_dir, exist_ok=True)
12694
+ marker = {
12695
+ "needed": True,
12696
+ "from_version": update_result.get("from_version"),
12697
+ "to_version": update_result.get("to_version"),
12698
+ "from_sha": update_result.get("from_sha"),
12699
+ "to_sha": update_result.get("to_sha"),
12700
+ "applied_at": _now_iso_safe(),
12701
+ }
12702
+ with open(os.path.join(log_dir, ".mcp_restart_needed"), "w", encoding="utf-8") as fh:
12703
+ json.dump(marker, fh, indent=2)
12704
+ except OSError:
12705
+ pass
12706
+
12707
+
12708
+ def _now_iso_safe() -> str:
12709
+ import time as _time
12710
+ return _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime())
12711
+
12712
+
12713
+ def _read_restart_marker(repo_root: str) -> Dict[str, Any]:
12714
+ path = os.path.join(repo_root, "logs", ".mcp_restart_needed")
12715
+ if not os.path.isfile(path):
12716
+ return {"needed": False}
12717
+ try:
12718
+ with open(path, "r", encoding="utf-8") as fh:
12719
+ payload = json.load(fh)
12720
+ if isinstance(payload, dict):
12721
+ payload.setdefault("needed", True)
12722
+ return payload
12723
+ except (OSError, json.JSONDecodeError):
12724
+ pass
12725
+ return {"needed": True, "marker_path": path}
12726
+
12727
+
12728
+ def _clear_restart_marker(repo_root: str) -> Dict[str, Any]:
12729
+ path = os.path.join(repo_root, "logs", ".mcp_restart_needed")
12730
+ try:
12731
+ os.remove(path)
12732
+ except OSError:
12733
+ return {"success": False, "error": "marker not present or unreadable"}
12734
+ return {"success": True}
12735
+
12736
+
12737
+ def _eager_migrate_after_update(project_root: Optional[str] = None) -> Dict[str, Any]:
12738
+ """Walk every project under the analysis base root and open + migrate its
12739
+ timeline_brain.sqlite. Surfaces schema bumps from the new build right after
12740
+ `git pull` instead of on next per-project work."""
12741
+ if project_root:
12742
+ base = os.path.dirname(os.path.normpath(project_root))
12743
+ else:
12744
+ base = os.path.expanduser("~/Documents/davinci-resolve-mcp-analysis")
12745
+ migrated: List[Dict[str, Any]] = []
12746
+ if not os.path.isdir(base):
12747
+ return {"success": True, "migrated": migrated, "note": "no base root found"}
12748
+ for entry in sorted(os.listdir(base)):
12749
+ candidate = os.path.join(base, entry)
12750
+ if not os.path.isdir(os.path.join(candidate, "_soul")):
12751
+ continue
12752
+ try:
12753
+ _timeline_brain_db.connect(candidate)
12754
+ migrated.append({"project_root": candidate, "ok": True})
12755
+ except Exception as exc:
12756
+ migrated.append({"project_root": candidate, "ok": False, "error": str(exc)})
12757
+ return {"success": True, "migrated": migrated}
12758
+
12759
+
12760
+ def _update_rollback_payload() -> Dict[str, Any]:
12761
+ try:
12762
+ sys.path.insert(0, _repo_root())
12763
+ from install import rollback_to_previous_build # type: ignore
12764
+ except Exception as exc:
12765
+ return {"success": False, "error": f"rollback helper unavailable: {exc}"}
12766
+ try:
12767
+ result = rollback_to_previous_build(_repo_root(), initiator="dashboard")
12768
+ except Exception as exc:
12769
+ return {"success": False, "error": str(exc)}
12770
+ out: Dict[str, Any] = dict(result)
12771
+ out["current_version"] = _mcp_version()
12772
+ if result.get("success"):
12773
+ out["restart_required"] = True
10516
12774
  return out
10517
12775
 
10518
12776
 
12777
+ def _update_history_payload(limit: int = 20) -> Dict[str, Any]:
12778
+ try:
12779
+ sys.path.insert(0, _repo_root())
12780
+ from install import read_update_history # type: ignore
12781
+ except Exception as exc:
12782
+ return {"success": False, "error": f"history helper unavailable: {exc}", "entries": []}
12783
+ try:
12784
+ return read_update_history(_repo_root(), limit=limit)
12785
+ except Exception as exc:
12786
+ return {"success": False, "error": str(exc), "entries": []}
12787
+
12788
+
12789
+ def _update_preview_payload() -> Dict[str, Any]:
12790
+ """Render the about-to-apply update for user confirmation.
12791
+
12792
+ Returns release notes, flagged breaking changes, channel, prerelease flag,
12793
+ and the target SHA so the dashboard can show a meaningful modal before
12794
+ `git pull` actually runs.
12795
+ """
12796
+ try:
12797
+ sys.path.insert(0, _repo_root())
12798
+ from install import preview_update # type: ignore
12799
+ except Exception as exc:
12800
+ return {"success": False, "error": f"preview helper unavailable: {exc}"}
12801
+ try:
12802
+ return preview_update(_repo_root())
12803
+ except Exception as exc:
12804
+ return {"success": False, "error": str(exc)}
12805
+
12806
+
10519
12807
  def _update_status_payload(project_root: Optional[str], *, force: bool = False) -> Dict[str, Any]:
10520
12808
  current = _mcp_version()
10521
12809
  base = {
@@ -10706,6 +12994,19 @@ class Handler(BaseHTTPRequestHandler):
10706
12994
  force = (query.get("force") or ["0"])[0].lower() in {"1", "true", "yes"}
10707
12995
  self._json(_update_status_payload(self.state.project_root, force=force))
10708
12996
  return
12997
+ if path == "/api/update/history":
12998
+ try:
12999
+ limit = int((query.get("limit") or ["20"])[0])
13000
+ except (TypeError, ValueError):
13001
+ limit = 20
13002
+ self._json(_update_history_payload(limit=limit))
13003
+ return
13004
+ if path == "/api/restart_needed":
13005
+ self._json(_read_restart_marker(_repo_root()))
13006
+ return
13007
+ if path == "/api/update/preview":
13008
+ self._json(_update_preview_payload())
13009
+ return
10709
13010
  if path == "/api/mcp/status":
10710
13011
  self._json(_mcp_status_payload())
10711
13012
  return
@@ -10742,6 +13043,12 @@ class Handler(BaseHTTPRequestHandler):
10742
13043
  if path == "/api/index/status":
10743
13044
  self._json(analysis_index_status(self.state.project_root))
10744
13045
  return
13046
+ if path == "/api/coverage":
13047
+ # Standalone readiness rollup — no live Resolve required. The
13048
+ # `coverage_report` action gives target-vs-records detail; this
13049
+ # endpoint summarizes what the analysis directory already knows.
13050
+ self._json(analysis_root_coverage(self.state.project_root))
13051
+ return
10745
13052
  if path == "/api/index/query":
10746
13053
  q = (query.get("q") or [""])[0]
10747
13054
  payload = query_analysis_index(self.state.project_root, q, limit=(query.get("limit") or [20])[0])
@@ -10749,6 +13056,105 @@ class Handler(BaseHTTPRequestHandler):
10749
13056
  payload["results"] = _v2_enrich_search_results(self.state.project_root, payload["results"])
10750
13057
  self._json(payload)
10751
13058
  return
13059
+ # ─── C6 timeline-history surface ───────────────────────────────
13060
+ if path == "/api/timeline_versions":
13061
+ self._json(list_timelines_with_versions(self.state.project_root))
13062
+ return
13063
+ if path.startswith("/api/timeline_versions/"):
13064
+ timeline_name = unquote(path[len("/api/timeline_versions/"):])
13065
+ if not timeline_name:
13066
+ self._json({"success": False, "error": "timeline_name required"}, HTTPStatus.BAD_REQUEST)
13067
+ return
13068
+ self._json(get_timeline_history_payload(self.state.project_root, timeline_name))
13069
+ return
13070
+ if path == "/api/brain_edits/registry":
13071
+ self._json({"success": True, **_brain_edits.read_brain_edits_registry(self.state.project_root)})
13072
+ return
13073
+ if path == "/api/caps/history":
13074
+ try:
13075
+ from src.utils import analysis_caps as _ac
13076
+ days = int((query.get("days") or ["30"])[0])
13077
+ self._json({
13078
+ "success": True,
13079
+ "history": _ac.get_usage_history(project_root=self.state.project_root, days=days),
13080
+ })
13081
+ except Exception as exc:
13082
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13083
+ return
13084
+ if path == "/api/caps/refusals":
13085
+ try:
13086
+ from src.utils import analysis_caps as _ac
13087
+ limit = int((query.get("limit") or ["20"])[0])
13088
+ self._json({
13089
+ "success": True,
13090
+ "events": _ac.get_caps_events(
13091
+ project_root=self.state.project_root,
13092
+ event_type=(query.get("event_type") or ["refusal"])[0] or "refusal",
13093
+ limit=limit,
13094
+ ),
13095
+ })
13096
+ except Exception as exc:
13097
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13098
+ return
13099
+ if path == "/api/media_pool_changes":
13100
+ try:
13101
+ from src.utils import media_pool_changes as _mpc
13102
+ limit = int((query.get("limit") or ["50"])[0])
13103
+ self._json({
13104
+ "success": True,
13105
+ "changes": _mpc.get_media_pool_change_history(
13106
+ project_root=self.state.project_root, limit=limit,
13107
+ ),
13108
+ })
13109
+ except Exception as exc:
13110
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13111
+ return
13112
+ if path == "/api/runs":
13113
+ try:
13114
+ from src.utils import analysis_runs as _ar
13115
+ limit = int((query.get("limit") or ["50"])[0])
13116
+ self._json({
13117
+ "success": True,
13118
+ "runs": _ar.list_runs(project_root=self.state.project_root, limit=limit),
13119
+ "current_run_id": _ar.current_run_id(),
13120
+ })
13121
+ except Exception as exc:
13122
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13123
+ return
13124
+ if path == "/api/caps":
13125
+ # Effective caps + per-project usage rollup. Proxies into the
13126
+ # media_analysis tool's get_caps action which already does the
13127
+ # preference lookup + DB rollup.
13128
+ try:
13129
+ from src.server import media_analysis as _ma_tool
13130
+ import asyncio
13131
+ result = asyncio.run(_ma_tool("get_caps", params={}))
13132
+ self._json(result)
13133
+ except Exception as exc:
13134
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13135
+ return
13136
+ if path.startswith("/api/timeline_thumbnail/"):
13137
+ rel = unquote(path[len("/api/timeline_thumbnail/"):])
13138
+ # Path is <slug>/<vNN.png>; constrain it to live under _soul/timeline_versions
13139
+ base = os.path.join(self.state.project_root, "_soul", "timeline_versions")
13140
+ full = os.path.realpath(os.path.join(base, rel))
13141
+ if not full.startswith(os.path.realpath(base) + os.sep) and full != os.path.realpath(base):
13142
+ self._json({"success": False, "error": "path escape"}, HTTPStatus.FORBIDDEN)
13143
+ return
13144
+ if not os.path.isfile(full):
13145
+ self._json({"success": False, "error": "not found"}, HTTPStatus.NOT_FOUND)
13146
+ return
13147
+ try:
13148
+ with open(full, "rb") as fh:
13149
+ data = fh.read()
13150
+ self.send_response(200)
13151
+ self.send_header("Content-Type", "image/png")
13152
+ self.send_header("Content-Length", str(len(data)))
13153
+ self.end_headers()
13154
+ self.wfile.write(data)
13155
+ except OSError as exc:
13156
+ self._json({"success": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
13157
+ return
10752
13158
  # ─── V2 Review API ──────────────────────────────────────────────
10753
13159
  if path == "/api/clips":
10754
13160
  self._json(list_analyzed_clips(self.state.project_root))
@@ -10818,7 +13224,27 @@ class Handler(BaseHTTPRequestHandler):
10818
13224
  if not _request_is_loopback(self):
10819
13225
  self._json({"success": False, "error": "Self-update is only available to loopback clients."}, HTTPStatus.FORBIDDEN)
10820
13226
  return
10821
- self._json(_update_apply_payload())
13227
+ strategy = (body.get("strategy") or "refuse_on_dirty").strip().lower()
13228
+ if strategy not in {"refuse_on_dirty", "stash_if_needed"}:
13229
+ strategy = "refuse_on_dirty"
13230
+ force_active_jobs = bool(body.get("force_active_jobs") or body.get("force"))
13231
+ self._json(_update_apply_payload(
13232
+ strategy=strategy,
13233
+ force_active_jobs=force_active_jobs,
13234
+ project_root=self.state.project_root,
13235
+ ))
13236
+ return
13237
+ if path == "/api/restart_needed/clear":
13238
+ if not _request_is_loopback(self):
13239
+ self._json({"success": False, "error": "Loopback only."}, HTTPStatus.FORBIDDEN)
13240
+ return
13241
+ self._json(_clear_restart_marker(_repo_root()))
13242
+ return
13243
+ if path == "/api/update/rollback":
13244
+ if not _request_is_loopback(self):
13245
+ self._json({"success": False, "error": "Rollback is only available to loopback clients."}, HTTPStatus.FORBIDDEN)
13246
+ return
13247
+ self._json(_update_rollback_payload())
10822
13248
  return
10823
13249
  if path.startswith("/api/clips/") and path.endswith("/transcript/regenerate"):
10824
13250
  if not _request_is_loopback(self):
@@ -10921,6 +13347,80 @@ class Handler(BaseHTTPRequestHandler):
10921
13347
  payload = self.state.set_context(body)
10922
13348
  self._json(payload, 200 if payload.get("success") else 400)
10923
13349
  return
13350
+ # ─── C6 timeline-history write actions (loopback only) ─────────
13351
+ if path == "/api/timeline_versions/action":
13352
+ if not _request_is_loopback(self):
13353
+ self._json({"success": False, "error": "Timeline versioning writes are loopback-only."}, HTTPStatus.FORBIDDEN)
13354
+ return
13355
+ self._json(proxy_timeline_versioning_action(body))
13356
+ return
13357
+ if path == "/api/caps":
13358
+ if not _request_is_loopback(self):
13359
+ self._json({"success": False, "error": "Caps writes are loopback-only."}, HTTPStatus.FORBIDDEN)
13360
+ return
13361
+ try:
13362
+ from src.server import media_analysis as _ma_tool
13363
+ import asyncio
13364
+ result = asyncio.run(_ma_tool("set_caps_preset", params=body))
13365
+ self._json(result, 200 if result.get("success") else 400)
13366
+ except Exception as exc:
13367
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13368
+ return
13369
+ if path == "/api/caps/reset_day":
13370
+ if not _request_is_loopback(self):
13371
+ self._json({"success": False, "error": "Loopback only."}, HTTPStatus.FORBIDDEN)
13372
+ return
13373
+ try:
13374
+ from src.utils import analysis_caps as _ac
13375
+ result = _ac.reset_day_usage(
13376
+ project_root=self.state.project_root,
13377
+ day_bucket=body.get("day_bucket"),
13378
+ )
13379
+ self._json(result)
13380
+ except Exception as exc:
13381
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13382
+ return
13383
+ if path == "/api/runs/begin":
13384
+ if not _request_is_loopback(self):
13385
+ self._json({"success": False, "error": "Loopback only."}, HTTPStatus.FORBIDDEN)
13386
+ return
13387
+ try:
13388
+ from src.utils import analysis_runs as _ar
13389
+ result = _ar.begin_run(
13390
+ project_root=self.state.project_root,
13391
+ label=body.get("label"),
13392
+ initiator=body.get("initiator") or "dashboard",
13393
+ )
13394
+ self._json(result)
13395
+ except Exception as exc:
13396
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13397
+ return
13398
+ if path == "/api/runs/end":
13399
+ if not _request_is_loopback(self):
13400
+ self._json({"success": False, "error": "Loopback only."}, HTTPStatus.FORBIDDEN)
13401
+ return
13402
+ try:
13403
+ from src.utils import analysis_runs as _ar
13404
+ result = _ar.end_run(
13405
+ project_root=self.state.project_root,
13406
+ analysis_run_id=body.get("analysis_run_id"),
13407
+ )
13408
+ self._json(result)
13409
+ except Exception as exc:
13410
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13411
+ return
13412
+ if path == "/api/update/channel":
13413
+ if not _request_is_loopback(self):
13414
+ self._json({"success": False, "error": "Loopback only."}, HTTPStatus.FORBIDDEN)
13415
+ return
13416
+ channel = (body.get("channel") or "stable").strip().lower()
13417
+ if channel not in {"stable", "beta", "dev"}:
13418
+ self._json({"success": False, "error": "channel must be stable | beta | dev"}, HTTPStatus.BAD_REQUEST)
13419
+ return
13420
+ os.environ["DAVINCI_RESOLVE_MCP_UPDATE_CHANNEL"] = channel
13421
+ self._json({"success": True, "channel": channel,
13422
+ "note": "Set for this process; persist via env var to survive restart."})
13423
+ return
10924
13424
  # ─── V2 Review API (writes) ─────────────────────────────────────
10925
13425
  if path == "/api/panel_state":
10926
13426
  merge = body.pop("__merge__", True)