personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(browser): render browser "pages" stream in timeline + transcripts

- Transcripts segment_content merges browser_*.jsonl chunks into the unified timeline by epoch-ms, reports data_state.browser (presence-based "analyzed"), and routes per-file parse failures into the existing warnings channel.
- Transcripts date-nav counts browser-only days: new browser_segments day-stat (journal-stats owns stats.json), counted in _day_range_count on both the cache and raw-scan paths; stats schema bumped v7→v8.
- Timeline _build_day buckets carry has_browser + a browser_origin for mixed-bucket reachability, browser ranked below audio/screen; _load_segment returns a per-site browser field with visible per-file degrade.
- Client (both apps): browser river marks, per-site detail pane, "pages" labels/footer, transcripts browser tab/chunk rendering. Owner-facing modality name is "pages".
- Fixtures + server-side tests for the API-observable behavior; corrupt-file content seeded at test time so committed fixtures stay contract-valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

+831 -50
+1 -1
solstone/apps/stats/static/dashboard.js
··· 5 5 const Dashboard = (function() { 6 6 'use strict'; 7 7 8 - const EXPECTED_SCHEMA_VERSION = 7; 8 + const EXPECTED_SCHEMA_VERSION = 8; 9 9 const DISPLAY_LABELS = { transcript: 'audio', percept: 'screen' }; 10 10 11 11 // DOM element factory
+93 -1
solstone/apps/timeline/routes.py
··· 22 22 TIMELINE_MONTH_NOT_FOUND, 23 23 ) 24 24 from solstone.convey.utils import error_response 25 + from solstone.think.browser_formatter import format_browser 25 26 from solstone.think.utils import DEFAULT_STREAM, iter_segments, segment_key 26 27 27 28 timeline_bp = Blueprint( ··· 182 183 buckets[hh][bucket].append( 183 184 { 184 185 "origin": _segment_origin(day, stream, seg), 186 + "stream": stream, 187 + "key": seg, 185 188 "has_audio": (seg_path / "audio.jsonl").is_file(), 186 189 "has_screen": any(seg_path.glob("*screen.jsonl")), 190 + "has_browser": any(seg_path.glob("browser_*.jsonl")), 187 191 } 188 192 ) 189 193 ··· 194 198 return 1 195 199 if seg["has_audio"]: 196 200 return 2 197 - return 3 201 + if seg["has_browser"]: 202 + return 3 203 + return 4 198 204 199 205 hours_avail: dict[str, dict[str, Any]] = {} 200 206 for hh in range(24): ··· 205 211 if segs: 206 212 segs.sort(key=rank) 207 213 best = segs[0] 214 + browser_candidates = [seg for seg in segs if seg["has_browser"]] 215 + browser = ( 216 + min( 217 + browser_candidates, 218 + key=lambda seg: (seg["key"], seg["stream"], seg["origin"]), 219 + ) 220 + if browser_candidates 221 + else None 222 + ) 208 223 bucket_list.append( 209 224 { 210 225 "minute": minute, 211 226 "best_origin": best["origin"], 212 227 "has_audio": best["has_audio"], 213 228 "has_screen": best["has_screen"], 229 + "has_browser": any(seg["has_browser"] for seg in segs), 230 + "browser_origin": browser["origin"] if browser else None, 214 231 "segment_count": len(segs), 215 232 } 216 233 ) ··· 221 238 "best_origin": None, 222 239 "has_audio": False, 223 240 "has_screen": False, 241 + "has_browser": False, 242 + "browser_origin": None, 224 243 "segment_count": 0, 225 244 } 226 245 ) ··· 259 278 return None 260 279 261 280 281 + def _read_browser_jsonl(path: Path) -> list[dict[str, Any]]: 282 + rows: list[dict[str, Any]] = [] 283 + for line in path.read_text(encoding="utf-8").splitlines(): 284 + if line.strip(): 285 + rows.append(json.loads(line)) 286 + return rows 287 + 288 + 289 + def _first_browser_segment_start(rows: list[dict[str, Any]]) -> dict[str, Any] | None: 290 + for row in rows: 291 + if row.get("t") == "segment_start": 292 + return row 293 + return None 294 + 295 + 296 + def _browser_site_name(filename: str, segment_start: dict[str, Any] | None) -> str: 297 + segment_start = segment_start or {} 298 + adapter = str(segment_start.get("adapter") or "").strip() 299 + if adapter: 300 + return adapter.title() 301 + site = str(segment_start.get("site") or "").strip() 302 + if site: 303 + return site 304 + stem = filename 305 + if stem.startswith("browser_"): 306 + stem = stem[len("browser_") :] 307 + if stem.endswith(".jsonl"): 308 + stem = stem[: -len(".jsonl")] 309 + return stem.replace("-", ".") or filename 310 + 311 + 312 + def _load_browser_file(path: Path) -> dict[str, Any]: 313 + filename = path.name 314 + try: 315 + rows = _read_browser_jsonl(path) 316 + segment_start = _first_browser_segment_start(rows) 317 + chunks, meta = format_browser(rows, {"file_path": path}) 318 + if meta.get("error") and not chunks: 319 + raise ValueError(str(meta["error"])) 320 + return { 321 + "file": filename, 322 + "site_name": _browser_site_name(filename, segment_start), 323 + "site": str((segment_start or {}).get("site") or ""), 324 + "title": str((segment_start or {}).get("title") or ""), 325 + "entries": [ 326 + { 327 + "ts": int(chunk.get("timestamp") or 0), 328 + "kind": ( 329 + "snapshot" 330 + if chunk.get("source", {}).get("t") == "segment_start" 331 + else "change" 332 + ), 333 + "markdown": chunk.get("markdown", ""), 334 + } 335 + for chunk in chunks 336 + ], 337 + "error": None, 338 + } 339 + except Exception: 340 + return { 341 + "file": filename, 342 + "site_name": _browser_site_name(filename, None), 343 + "site": "", 344 + "title": "", 345 + "entries": [], 346 + "error": "couldn't read this file", 347 + } 348 + 349 + 262 350 def _load_segment(day: str, stream: str, seg: str) -> dict[str, Any]: 263 351 key = (str(_journal_root()), day, stream, seg) 264 352 if key in _seg_cache: ··· 272 360 "segment": seg, 273 361 "audio": None, 274 362 "screen": None, 363 + "browser": [], 275 364 } 276 365 277 366 if seg_dir.is_dir(): ··· 292 381 "frames": frames, 293 382 "filename": screen_files[0].name, 294 383 } 384 + 385 + browser_files = sorted(seg_dir.glob("browser_*.jsonl")) 386 + out["browser"] = [_load_browser_file(path) for path in browser_files] 295 387 else: 296 388 out["error"] = f"segment dir not found: {seg_dir}" 297 389
+119
solstone/apps/timeline/static/timeline.css
··· 891 891 border-color: color-mix(in srgb, var(--coral), white 65%); 892 892 color: color-mix(in srgb, var(--coral), var(--ink) 50%); 893 893 } 894 + .timeline-shell .segment-cell.avail-browser:not(.timeline-focus) { 895 + background: color-mix(in srgb, var(--blue), white 94%); 896 + border-color: color-mix(in srgb, var(--blue), white 68%); 897 + color: color-mix(in srgb, var(--blue), var(--ink) 45%); 898 + } 894 899 .timeline-shell .segment-cell.avail-both:not(.timeline-focus) { 895 900 background: color-mix(in srgb, var(--accent), white 90%); 896 901 border-color: color-mix(in srgb, var(--accent), white 60%); ··· 1132 1137 transform: translate(-50%, -6px) scale(1.4); 1133 1138 } 1134 1139 1140 + .timeline-shell .river-browser { 1141 + position: absolute; 1142 + top: 50%; 1143 + left: 0; 1144 + right: 0; 1145 + height: 34px; 1146 + transform: translateY(-50%); 1147 + z-index: 3; 1148 + pointer-events: none; 1149 + } 1150 + 1151 + .timeline-shell .river-browser-mark { 1152 + position: absolute; 1153 + top: 50%; 1154 + width: 9px; 1155 + height: 9px; 1156 + padding: 0; 1157 + border: 1px solid color-mix(in srgb, var(--blue), var(--ink) 20%); 1158 + border-radius: 2px; 1159 + background: color-mix(in srgb, var(--blue), white 82%); 1160 + opacity: 0.76; 1161 + cursor: pointer; 1162 + pointer-events: auto; 1163 + transform: translate(-50%, -50%) rotate(45deg); 1164 + transition: opacity 140ms ease, transform 140ms ease, background 140ms ease; 1165 + } 1166 + 1167 + .timeline-shell .river-browser-mark:hover, 1168 + .timeline-shell .river-browser-mark:focus-visible, 1169 + .timeline-shell .river-browser-mark.is-active { 1170 + opacity: 1; 1171 + background: color-mix(in srgb, var(--blue), white 65%); 1172 + transform: translate(-50%, -50%) rotate(45deg) scale(1.45); 1173 + } 1174 + 1135 1175 .timeline-shell .river-audio { 1136 1176 position: absolute; 1137 1177 bottom: 0; ··· 1289 1329 color: var(--muted); 1290 1330 opacity: 0.7; 1291 1331 text-transform: lowercase; 1332 + } 1333 + 1334 + .timeline-shell .seg-browser-section { 1335 + display: grid; 1336 + gap: 8px; 1337 + } 1338 + 1339 + .timeline-shell .seg-browser-section + .seg-browser-section { 1340 + margin-top: 14px; 1341 + padding-top: 12px; 1342 + border-top: 1px dashed var(--soft-line); 1343 + } 1344 + 1345 + .timeline-shell .seg-browser-header { 1346 + display: grid; 1347 + gap: 2px; 1348 + } 1349 + 1350 + .timeline-shell .seg-browser-header h3 { 1351 + margin: 0; 1352 + color: var(--ink); 1353 + font-size: 0.92rem; 1354 + line-height: 1.2; 1355 + } 1356 + 1357 + .timeline-shell .seg-browser-title { 1358 + color: var(--muted); 1359 + font-size: 0.74rem; 1360 + } 1361 + 1362 + .timeline-shell .seg-browser-error { 1363 + padding: 7px 9px; 1364 + border-left: 3px solid var(--coral); 1365 + background: color-mix(in srgb, var(--coral), white 90%); 1366 + color: color-mix(in srgb, var(--coral), var(--ink) 40%); 1367 + font-size: 0.78rem; 1368 + } 1369 + 1370 + .timeline-shell .seg-browser-entry { 1371 + padding: 8px 10px; 1372 + border-left: 2px solid color-mix(in srgb, var(--blue), white 55%); 1373 + background: color-mix(in srgb, var(--blue), white 96%); 1374 + } 1375 + 1376 + .timeline-shell .seg-browser-entry.is-focus { 1377 + border-left-color: var(--blue); 1378 + background: color-mix(in srgb, var(--blue), white 90%); 1379 + } 1380 + 1381 + .timeline-shell .seg-browser-entry-meta { 1382 + display: flex; 1383 + align-items: center; 1384 + gap: 8px; 1385 + margin-bottom: 5px; 1386 + } 1387 + 1388 + .timeline-shell .seg-browser-markdown { 1389 + color: var(--ink); 1390 + font-size: 0.84rem; 1391 + line-height: 1.45; 1392 + } 1393 + 1394 + .timeline-shell .seg-browser-markdown p { 1395 + margin: 0 0 6px; 1396 + } 1397 + 1398 + .timeline-shell .seg-browser-markdown p:last-child { 1399 + margin-bottom: 0; 1400 + } 1401 + 1402 + .timeline-shell .seg-browser-markdown pre, 1403 + .timeline-shell .seg-browser-pre { 1404 + margin: 6px 0 0; 1405 + padding: 8px 10px; 1406 + overflow: auto; 1407 + background: rgba(255, 255, 255, 0.72); 1408 + border: 1px solid var(--soft-line); 1409 + border-radius: 4px; 1410 + white-space: pre-wrap; 1292 1411 } 1293 1412 1294 1413 .timeline-shell .segment-footer {
+142 -21
solstone/apps/timeline/static/timeline.js
··· 60 60 String(ss).padStart(2, "0"); 61 61 } 62 62 63 + function segmentEpochBaseMs(meta) { 64 + return new Date(`${meta.day}T00:00:00`).getTime() + meta.startSec * 1000; 65 + } 66 + 67 + function browserTimeLabel(epochMs) { 68 + const dt = new Date(Number(epochMs) || 0); 69 + return String(dt.getHours()).padStart(2, "0") + ":" + 70 + String(dt.getMinutes()).padStart(2, "0") + ":" + 71 + String(dt.getSeconds()).padStart(2, "0"); 72 + } 73 + 74 + function browserOffsetSeconds(meta, epochMs) { 75 + return (Number(epochMs) - segmentEpochBaseMs(meta)) / 1000; 76 + } 77 + 63 78 // ── Data loaders (lazy, cached) ────────────────────────────────────── 64 79 65 80 async function loadIndex() { ··· 353 368 } 354 369 355 370 function clearActiveMarks() { 356 - for (const el of document.querySelectorAll(".river-tick.is-active, .river-audio-dot.is-active")) { 371 + for (const el of document.querySelectorAll(".river-tick.is-active, .river-audio-dot.is-active, .river-browser-mark.is-active")) { 357 372 el.classList.remove("is-active"); 358 373 } 359 374 } ··· 363 378 // without re-fetching. 364 379 let _activeSegment = null; 365 380 let _activeMeta = null; 381 + let _activeBrowserFiles = []; 382 + 383 + function renderTimelineMarkdown(value) { 384 + const markdown = String(value || ""); 385 + if (window.AppServices?.renderMarkdown) { 386 + return window.AppServices.renderMarkdown(markdown); 387 + } 388 + return `<pre class="seg-browser-pre">${escapeHtml(markdown)}</pre>`; 389 + } 390 + 391 + function hasBrowserContent(browserFiles) { 392 + return (browserFiles || []).some((site) => site && (site.error || (site.entries || []).length)); 393 + } 394 + 395 + function browserChangeCount(browserFiles) { 396 + return (browserFiles || []).reduce((total, site) => { 397 + return total + (site.entries || []).filter((entry) => entry.kind === "change").length; 398 + }, 0); 399 + } 400 + 401 + function renderBrowserSections(browserFiles, focusSiteIndex = null, focusEntryIndex = null) { 402 + const sections = (browserFiles || []).map((site, siteIndex) => { 403 + if (!site || (!site.error && !(site.entries || []).length)) return ""; 404 + const siteName = site.site_name || site.site || site.file || "pages"; 405 + const title = site.title ? `<div class="seg-browser-title">${escapeHtml(site.title)}</div>` : ""; 406 + const error = site.error ? `<div class="seg-browser-error">${escapeHtml(site.error)}</div>` : ""; 407 + const rows = (site.entries || []).map((entry, entryIndex) => { 408 + const isFocus = siteIndex === focusSiteIndex && entryIndex === focusEntryIndex; 409 + const kind = entry.kind === "snapshot" ? "snapshot" : "change"; 410 + return ` 411 + <article class="seg-browser-entry ${isFocus ? "is-focus" : ""}"> 412 + <div class="seg-browser-entry-meta"> 413 + <span class="seg-detail-time">${browserTimeLabel(entry.ts)}</span> 414 + <span class="seg-detail-cat" style="--cat:var(--teal)">${kind}</span> 415 + </div> 416 + <div class="seg-browser-markdown">${renderTimelineMarkdown(entry.markdown)}</div> 417 + </article> 418 + `; 419 + }).join(""); 420 + return ` 421 + <section class="seg-browser-section"> 422 + <header class="seg-browser-header"> 423 + <h3>${escapeHtml(siteName)}</h3> 424 + ${title} 425 + </header> 426 + ${error} 427 + ${rows} 428 + </section> 429 + `; 430 + }).join(""); 431 + return sections || `<div class="seg-detail-empty">no page content in this slice</div>`; 432 + } 433 + 434 + function showBrowserDetail(siteIndex, entryIndex) { 435 + const detail = document.getElementById("segment-detail"); 436 + if (!detail || !_activeBrowserFiles.length) return; 437 + detail.innerHTML = renderBrowserSections(_activeBrowserFiles, siteIndex, entryIndex); 438 + clearActiveMarks(); 439 + const active = document.querySelector(`.river-browser-mark[data-browser-site="${siteIndex}"][data-browser-entry="${entryIndex}"]`); 440 + if (active) active.classList.add("is-active"); 441 + } 366 442 367 443 function showSegmentDetail(frameId) { 368 444 const detail = document.getElementById("segment-detail"); ··· 427 503 428 504 function clearSegmentDetail() { 429 505 const detail = document.getElementById("segment-detail"); 430 - if (detail) detail.innerHTML = `<div class="seg-detail-empty">click a tick or audio dot on the river to see what sol observed at that moment</div>`; 506 + if (detail) { 507 + detail.innerHTML = hasBrowserContent(_activeBrowserFiles) 508 + ? renderBrowserSections(_activeBrowserFiles) 509 + : `<div class="seg-detail-empty">click a tick, audio dot, or page mark to inspect that moment</div>`; 510 + } 431 511 clearActiveMarks(); 432 512 } 433 513 ··· 625 705 async function prefetchSegmentForMinute(hour, minute) { 626 706 const buckets = segmentAvail[`${selectedMonth}:${selectedDay}:${hour}`] || []; 627 707 const bucket = buckets[Math.floor(minute / 5)] || null; 628 - if (bucket && bucket.best_origin) { 629 - await loadSegment(bucket.best_origin); 630 - } 708 + const origins = []; 709 + if (bucket?.best_origin) origins.push(bucket.best_origin); 710 + if (bucket?.browser_origin && bucket.browser_origin !== bucket.best_origin) origins.push(bucket.browser_origin); 711 + await Promise.all(origins.map((origin) => loadSegment(origin))); 631 712 } 632 713 633 714 async function applyHash(hash) { ··· 1040 1121 const yyyymmdd = isoDay(monthIndex, day); 1041 1122 if (yyyymmdd) await loadDay(yyyymmdd); 1042 1123 const buckets = segmentAvail[`${monthIndex}:${day}:${hour}`] || []; 1043 - if (!buckets.some((bucket) => bucket && bucket.best_origin)) { 1124 + if (!buckets.some((bucket) => bucket && (bucket.best_origin || bucket.browser_origin))) { 1044 1125 timeline.innerHTML = renderEmptyState( 1045 1126 "nothing observed in this hour", 1046 1127 `there are no segment observations for ${formatTime(hour, 0)}.`, ··· 1077 1158 const minute = segmentIndex * 5; 1078 1159 const event = eventMinutes.get(minute); 1079 1160 const bucket = buckets[segmentIndex] || null; 1080 - const hasData = !!(bucket && bucket.best_origin); 1161 + const hasData = !!(bucket && (bucket.best_origin || bucket.browser_origin)); 1081 1162 // Availability tint: both = accent, screen-only = teal, 1082 - // audio-only = coral, none = grey/disabled. 1163 + // audio-only = coral, pages-only = blue, none = grey/disabled. 1083 1164 let availClass = "avail-none"; 1084 1165 if (hasData && bucket.has_audio && bucket.has_screen) availClass = "avail-both"; 1085 1166 else if (hasData && bucket.has_screen) availClass = "avail-screen"; 1086 1167 else if (hasData && bucket.has_audio) availClass = "avail-audio"; 1168 + else if (hasData && bucket.has_browser) availClass = "avail-browser"; 1087 1169 const classes = ["segment-cell", event ? `timeline-focus timeline-${event.side}` : "", availClass].filter(Boolean).join(" "); 1088 1170 const availLabel = hasData 1089 1171 ? (bucket.has_audio && bucket.has_screen ? "audio + screen" 1090 1172 : bucket.has_screen ? "screen only" 1091 - : bucket.has_audio ? "audio only" : "metadata only") 1173 + : bucket.has_audio ? "audio only" 1174 + : bucket.has_browser ? "pages" : "metadata only") 1092 1175 : "no observation"; 1093 1176 const label = `${formatTime(hour, minute)} · ${availLabel}${event ? `, ${event.title}` : ""}`; 1094 1177 const disabled = hasData ? "" : "disabled aria-disabled=\"true\""; ··· 1149 1232 const bucketIdx = Math.floor(minute / 5); 1150 1233 const bucket = buckets[bucketIdx] || null; 1151 1234 const origin = bucket && bucket.best_origin ? bucket.best_origin : null; 1235 + const browserOrigin = bucket && bucket.browser_origin ? bucket.browser_origin : null; 1236 + const metaOrigin = origin || browserOrigin; 1152 1237 1153 1238 // No data → render an empty-state river. (Cell shouldn't have been 1154 1239 // clickable in the first place; this is a defensive fallback.) 1155 - if (!origin) { 1240 + if (!metaOrigin) { 1156 1241 return renderEmptySegment(monthIndex, day, hour, minute, focusLabel); 1157 1242 } 1158 1243 1159 1244 // Derive the segment's wall-clock start from its segment name's HHMMSS. 1160 1245 // origin format: "YYYYMMDD/<stream>/<HHMMSS_LEN>" or "YYYYMMDD/<HHMMSS_LEN>" 1161 - const parts = origin.split("/"); 1246 + const parts = metaOrigin.split("/"); 1162 1247 const segName = parts[parts.length - 1]; 1163 1248 const segMatch = /^(\d{2})(\d{2})(\d{2})_(\d{1,6})$/.exec(segName); 1164 1249 const startSec = segMatch ? parseInt(segMatch[1],10)*3600 + parseInt(segMatch[2],10)*60 + parseInt(segMatch[3],10) : (hour*3600 + minute*60); ··· 1167 1252 const dayStr = `${parts[0].slice(0,4)}-${parts[0].slice(4,6)}-${parts[0].slice(6,8)}`; 1168 1253 const meta = { day: dayStr, startSec, durationSec: dur, stream }; 1169 1254 1170 - const sample = await loadSegment(origin); 1171 - if (!sample) { 1255 + const sample = origin ? await loadSegment(origin) : null; 1256 + const browserSample = browserOrigin && browserOrigin !== origin 1257 + ? await loadSegment(browserOrigin) 1258 + : sample; 1259 + const primarySample = sample || browserSample; 1260 + if (!primarySample) { 1172 1261 return renderEmptySegment(monthIndex, day, hour, minute, focusLabel); 1173 1262 } 1174 1263 1175 1264 // Stash for the click-driven detail handlers. 1176 - _activeSegment = sample; 1265 + _activeSegment = primarySample; 1177 1266 _activeMeta = meta; 1267 + _activeBrowserFiles = browserSample?.browser || []; 1178 1268 1179 - const audioHeader = sample.audio?.header || {}; 1180 - const screenHeader = sample.screen?.header || {}; 1181 - const audioLines = sample.audio?.lines || []; 1182 - const screenFrames = sample.screen?.frames || []; 1269 + const audioHeader = primarySample.audio?.header || {}; 1270 + const screenHeader = primarySample.screen?.header || {}; 1271 + const audioLines = primarySample.audio?.lines || []; 1272 + const screenFrames = primarySample.screen?.frames || []; 1273 + const browserFiles = _activeBrowserFiles; 1274 + const browserHasContent = hasBrowserContent(browserFiles); 1275 + const pageUpdateCount = browserChangeCount(browserFiles); 1183 1276 1184 1277 const setting = audioHeader.setting || screenHeader.setting || "—"; 1185 1278 const rawTopics = audioHeader.topics ?? ""; ··· 1224 1317 aria-label="${escapeHtml(tipText)}" 1225 1318 type="button"></button>`; 1226 1319 }).join("") 1227 - : `<div class="river-empty">no microphone input in this slice</div>`; 1320 + : (browserHasContent ? "" : `<div class="river-empty">no microphone input in this slice</div>`); 1321 + 1322 + const browserMarks = browserFiles.map((site, siteIndex) => { 1323 + return (site.entries || []).map((entry, entryIndex) => { 1324 + if (entry.kind !== "change") return ""; 1325 + const offset = Math.max(0, Math.min(dur, browserOffsetSeconds(meta, entry.ts))); 1326 + const tipText = `${browserTimeLabel(entry.ts)} · ${site.site_name || site.site || "pages"}\n${(entry.markdown || "").slice(0, 200)}`; 1327 + return `<button class="river-browser-mark" 1328 + data-browser-site="${siteIndex}" 1329 + data-browser-entry="${entryIndex}" 1330 + style="left:${fmtPct(offset)};" 1331 + title="${escapeHtml(tipText)}" 1332 + aria-label="${escapeHtml(tipText)}" 1333 + type="button"></button>`; 1334 + }).join(""); 1335 + }).join(""); 1228 1336 1229 1337 // Minute markers along the axis: 0, 60, 120, 180, 240, (the right edge is the segment end) 1230 1338 const axisMarks = [0, 60, 120, 180, 240].map((s) => ··· 1252 1360 <header class="segment-header"> 1253 1361 <div class="seg-header-row"> 1254 1362 <span class="seg-header-time">${meta.day} · ${startHHMM} → ${endHHMM} · ${minutesStr}</span> 1255 - <span class="seg-header-mid">${escapeHtml(meta.stream || "—")} observer</span> 1363 + <span class="seg-header-mid">${escapeHtml(meta.stream || "—")}</span> 1256 1364 <span class="seg-header-end">${escapeHtml(setting)} setting</span> 1257 1365 </div> 1258 1366 ${topics.length ? `<div class="seg-topics">${topics.map((t) => `<span class="topic-chip">${escapeHtml(t)}</span>`).join("")}</div>` : ""} ··· 1265 1373 <div class="river-axis"> 1266 1374 ${axisMarks} 1267 1375 </div> 1376 + <div class="river-browser" aria-label="page updates"> 1377 + ${browserMarks} 1378 + </div> 1268 1379 <div class="river-audio" aria-label="microphone input"> 1269 1380 ${audioMarks} 1270 1381 </div> 1271 1382 </div> 1272 1383 1273 1384 <div class="segment-detail" id="segment-detail"> 1274 - <div class="seg-detail-empty">click a tick or audio dot on the river to see what sol observed at that moment</div> 1385 + ${browserHasContent ? renderBrowserSections(browserFiles) : `<div class="seg-detail-empty">click a tick, audio dot, or page mark to inspect that moment</div>`} 1275 1386 </div> 1276 1387 1277 1388 <footer class="segment-footer"> 1278 1389 ${screenFrames.length} frames analyzed 1279 1390 · ${audioLines.length} transcript line${audioLines.length === 1 ? "" : "s"} 1280 1391 · ${featuredCount} frames with extracted text 1392 + · ${pageUpdateCount} page update${pageUpdateCount === 1 ? "" : "s"} 1281 1393 </footer> 1282 1394 </section> 1283 1395 </div> ··· 1298 1410 const idx = parseInt(dot.getAttribute("data-audio-index"), 10); 1299 1411 if (dot.classList.contains("is-active")) clearSegmentDetail(); 1300 1412 else showSegmentAudioDetail(idx); 1413 + }); 1414 + } 1415 + for (const mark of document.querySelectorAll(".river-browser-mark[data-browser-site]")) { 1416 + mark.addEventListener("click", (e) => { 1417 + e.stopPropagation(); 1418 + const siteIndex = parseInt(mark.getAttribute("data-browser-site"), 10); 1419 + const entryIndex = parseInt(mark.getAttribute("data-browser-entry"), 10); 1420 + if (mark.classList.contains("is-active")) clearSegmentDetail(); 1421 + else showBrowserDetail(siteIndex, entryIndex); 1301 1422 }); 1302 1423 } 1303 1424 }
+3
solstone/apps/timeline/tests/fixtures/chronicle_layout/20260510/default/150000_300/audio.jsonl
··· 1 + {"setting":"desk","topics":["timeline","browser"]} 2 + {"start":"15:00:01","source":"mic","speaker":1,"text":"Reviewed browser timeline data.","corrected":"Reviewed browser timeline data."} 3 + {"start":"15:00:45","source":"mic","speaker":1,"text":"Checked browser origin loading.","corrected":"Checked browser origin loading."}
+3
solstone/apps/timeline/tests/fixtures/chronicle_layout/20260510/default/150000_300/desktop.screen.jsonl
··· 1 + {"setting":"desktop","raw":"screen.webm"} 2 + {"frame_id":1,"timestamp":1,"analysis":{"primary":"browser","visual_description":"Timeline browser fixture is open."},"content":{"text":"browser fixture"}} 3 + {"frame_id":2,"timestamp":120,"analysis":{"primary":"code","visual_description":"Browser origin test is visible."},"content":{}}
+2
solstone/apps/timeline/tests/fixtures/chronicle_layout/20260510/workstation.browser/140000_300/browser_docs-example-com.jsonl
··· 1 + {"t":"segment_start","ts":1778443200000,"rel":0,"site":"docs.example.com","url":"https://docs.example.com/timeline-browser-only","title":"Timeline Browser Only","adapter":"docs","ctx":"doc","n":2,"blocks":[{"type":"heading","text":"Timeline Browser Only"},{"type":"text","text":"Browser-only bucket."}]} 2 + {"t":"delta","ts":1778443215000,"rel":15000,"site":"docs.example.com","adapter":"docs","ctx":"doc","op":"add","block":{"type":"text","text":"Browser-only update."}}
+1
solstone/apps/timeline/tests/fixtures/chronicle_layout/20260510/workstation.browser/140000_300/stream.json
··· 1 + {"stream": "workstation.browser", "seq": 1}
+2
solstone/apps/timeline/tests/fixtures/chronicle_layout/20260510/workstation.browser/150000_300/browser_mail-example-com.jsonl
··· 1 + {"t":"segment_start","ts":1778446800000,"rel":0,"site":"mail.example.com","url":"https://mail.example.com/inbox","title":"Timeline Mixed Browser","adapter":"mail","ctx":"inbox","n":2,"blocks":[{"type":"heading","text":"Timeline Mixed Browser"},{"type":"text","text":"Sibling browser stream."}]} 2 + {"t":"delta","ts":1778446810000,"rel":10000,"site":"mail.example.com","adapter":"mail","ctx":"inbox","op":"add","block":{"type":"text","text":"Mixed bucket update."}}
+1
solstone/apps/timeline/tests/fixtures/chronicle_layout/20260510/workstation.browser/150000_300/stream.json
··· 1 + {"stream": "workstation.browser", "seq": 1}
+73
solstone/apps/timeline/tests/test_routes.py
··· 304 304 "best_origin": "20260510/100000_300", 305 305 "has_audio": True, 306 306 "has_screen": True, 307 + "has_browser": False, 308 + "browser_origin": None, 307 309 "segment_count": 1, 308 310 } 309 311 ··· 323 325 assert hour13["has_screen"] is True 324 326 325 327 assert payload["hours_avail"]["10"]["buckets"][1]["best_origin"] is None 328 + 329 + 330 + def test_day_browser_only_bucket_and_segment_payload(client, timeline_env): 331 + corrupt = ( 332 + timeline_env 333 + / "chronicle" 334 + / "20260510" 335 + / "workstation.browser" 336 + / "140000_300" 337 + / "browser_corrupt-example-com.jsonl" 338 + ) 339 + corrupt.write_text("{bad json\n", encoding="utf-8") 340 + 341 + day_response = client.get(f"/app/timeline/api/day/{DAY}") 342 + 343 + assert day_response.status_code == 200 344 + payload = day_response.get_json() 345 + bucket = payload["hours_avail"]["14"]["buckets"][0] 346 + assert bucket["has_browser"] is True 347 + assert bucket["has_audio"] is False 348 + assert bucket["has_screen"] is False 349 + assert bucket["best_origin"] == "20260510/workstation.browser/140000_300" 350 + assert bucket["browser_origin"] == "20260510/workstation.browser/140000_300" 351 + 352 + segment_response = client.get( 353 + f"/app/timeline/api/segment/{DAY}/workstation.browser/140000_300" 354 + ) 355 + assert segment_response.status_code == 200 356 + segment = segment_response.get_json() 357 + browser_files = {item["file"]: item for item in segment["browser"]} 358 + 359 + docs = browser_files["browser_docs-example-com.jsonl"] 360 + assert docs["site_name"] == "Docs" 361 + assert docs["site"] == "docs.example.com" 362 + assert docs["title"] == "Timeline Browser Only" 363 + assert [entry["kind"] for entry in docs["entries"]] == ["snapshot", "change"] 364 + assert [entry["ts"] for entry in docs["entries"]] == [ 365 + 1778443200000, 366 + 1778443215000, 367 + ] 368 + assert docs["error"] is None 369 + 370 + corrupt = browser_files["browser_corrupt-example-com.jsonl"] 371 + assert corrupt["site_name"] == "corrupt.example.com" 372 + assert corrupt["entries"] == [] 373 + assert corrupt["error"] == "couldn't read this file" 374 + 375 + 376 + def test_day_mixed_bucket_keeps_best_origin_and_loads_browser_origin(client): 377 + day_response = client.get(f"/app/timeline/api/day/{DAY}") 378 + 379 + assert day_response.status_code == 200 380 + payload = day_response.get_json() 381 + bucket = payload["hours_avail"]["15"]["buckets"][0] 382 + assert bucket["has_browser"] is True 383 + assert bucket["has_audio"] is True 384 + assert bucket["has_screen"] is True 385 + assert bucket["best_origin"] == "20260510/default/150000_300" 386 + assert bucket["browser_origin"] == "20260510/workstation.browser/150000_300" 387 + 388 + browser_response = client.get( 389 + f"/app/timeline/api/segment/{DAY}/workstation.browser/150000_300" 390 + ) 391 + assert browser_response.status_code == 200 392 + browser_payload = browser_response.get_json() 393 + assert browser_payload["browser"][0]["site_name"] == "Mail" 394 + assert browser_payload["browser"][0]["title"] == "Timeline Mixed Browser" 395 + assert [entry["kind"] for entry in browser_payload["browser"][0]["entries"]] == [ 396 + "snapshot", 397 + "change", 398 + ] 326 399 327 400 328 401 def test_day_bad_input_returns_400(client):
+85 -4
solstone/apps/transcripts/routes.py
··· 53 53 from solstone.observe.hear import format_audio 54 54 from solstone.observe.screen import format_screen 55 55 from solstone.observe.utils import AUDIO_EXTENSIONS, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS 56 + from solstone.think.browser_formatter import format_browser 56 57 from solstone.think.cluster import ( 57 58 cluster, 58 59 cluster_period, 59 60 cluster_range, 60 - cluster_scan, 61 61 cluster_segments, 62 62 cluster_span, 63 63 scan_day, ··· 116 116 payload = load_fresh_day_cache(day_dir) 117 117 if payload is not None: 118 118 day_stats = payload["stats"] 119 - return int(day_stats["transcript_ranges"]) + int(day_stats["percept_ranges"]) 120 - audio_ranges, screen_ranges = cluster_scan(day) 121 - return len(audio_ranges) + len(screen_ranges) 119 + return ( 120 + int(day_stats["transcript_ranges"]) 121 + + int(day_stats["percept_ranges"]) 122 + + int(day_stats["browser_segments"]) 123 + ) 124 + audio_ranges, screen_ranges, segments = scan_day(day) 125 + browser_segments = sum(1 for s in segments if "browser" in s.get("types", ())) 126 + return len(audio_ranges) + len(screen_ranges) + browser_segments 122 127 123 128 124 129 def _attach_think_to_segments(segments: list[dict[str, Any]], day: str) -> None: ··· 495 500 if timestamp_ms <= 0: 496 501 return "" 497 502 return datetime.fromtimestamp(timestamp_ms / 1000).strftime("%H:%M:%S") 503 + 504 + 505 + def _first_browser_segment_start(entries: list[dict]) -> dict[str, Any] | None: 506 + for entry in entries: 507 + if entry.get("t") == "segment_start": 508 + return entry 509 + return None 510 + 511 + 512 + def _browser_site_name(filename: str, segment_start: dict[str, Any] | None) -> str: 513 + segment_start = segment_start or {} 514 + adapter = str(segment_start.get("adapter") or "").strip() 515 + if adapter: 516 + return adapter.title() 517 + site = str(segment_start.get("site") or "").strip() 518 + if site: 519 + return site 520 + stem = filename 521 + if stem.startswith("browser_"): 522 + stem = stem[len("browser_") :] 523 + if stem.endswith(".jsonl"): 524 + stem = stem[: -len(".jsonl")] 525 + return stem.replace("-", ".") or filename 498 526 499 527 500 528 def _load_segment_signals(segment_dir_path: Path) -> dict[str, Any]: ··· 1210 1238 ) 1211 1239 continue 1212 1240 1241 + # Process browser files. Browser timestamps are absolute epoch-ms values. 1242 + browser_files = glob(os.path.join(segment_dir, "browser_*.jsonl")) 1243 + for browser_path in sorted(browser_files): 1244 + try: 1245 + entries = _load_jsonl(browser_path) 1246 + formatted_chunks, meta = format_browser( 1247 + entries, {"file_path": browser_path} 1248 + ) 1249 + if meta.get("error") and not formatted_chunks: 1250 + raise ValueError(str(meta["error"])) 1251 + 1252 + filename = os.path.basename(browser_path) 1253 + segment_start = _first_browser_segment_start(entries) 1254 + site = str((segment_start or {}).get("site") or "") 1255 + title = str((segment_start or {}).get("title") or "") 1256 + adapter = str((segment_start or {}).get("adapter") or "") 1257 + site_name = _browser_site_name(filename, segment_start) 1258 + 1259 + for chunk in formatted_chunks: 1260 + source = chunk.get("source", {}) 1261 + timestamp = int(chunk.get("timestamp") or 0) 1262 + chunks.append( 1263 + { 1264 + "type": "browser", 1265 + "time": _local_time_from_timestamp(timestamp), 1266 + "timestamp": timestamp, 1267 + "markdown": chunk.get("markdown", ""), 1268 + "source_ref": { 1269 + "site": site, 1270 + "title": title, 1271 + "adapter": adapter, 1272 + "site_name": site_name, 1273 + "file": filename, 1274 + "op": source.get("op") or source.get("t"), 1275 + }, 1276 + } 1277 + ) 1278 + except Exception as exc: 1279 + logger.warning( 1280 + "Failed to parse browser segment %s", browser_path, exc_info=True 1281 + ) 1282 + warning_details.append( 1283 + { 1284 + "type": "browser", 1285 + "file": str(browser_path), 1286 + "message": str(exc), 1287 + "ts": datetime.utcnow().isoformat(timespec="seconds") + "Z", 1288 + } 1289 + ) 1290 + continue 1291 + 1213 1292 markdown_chunks_added = False 1214 1293 if _is_markdown_only_segment(segment_dir_path, stream): 1215 1294 time_str = _format_time_from_offset(segment_key, 0) ··· 1283 1362 data_state[modality] = state 1284 1363 if markdown_chunks_added: 1285 1364 data_state["markdown"] = DataState.ANALYZED.value 1365 + if any(chunk["type"] == "browser" for chunk in chunks): 1366 + data_state["browser"] = DataState.ANALYZED.value 1286 1367 # Get cost data for this segment 1287 1368 cost_data = get_usage_cost(day, segment=segment_key) 1288 1369
+44
solstone/apps/transcripts/tests/conftest.py
··· 4 4 """Fixtures for transcripts app tests.""" 5 5 6 6 import os 7 + import shutil 7 8 import sys 8 9 from pathlib import Path 9 10 ··· 54 55 copytree_tracked(src, dst) 55 56 monkeypatch.setenv("SOLSTONE_JOURNAL", str(dst.resolve())) 56 57 return dst 58 + 59 + 60 + @pytest.fixture 61 + def seed_browser_fixture_inventory(journal_copy): 62 + """Overlay browser fixture files that are new to this lode into journal_copy.""" 63 + 64 + source_chronicle = ROOT / "tests" / "fixtures" / "journal" / "chronicle" 65 + target_chronicle = journal_copy / "chronicle" 66 + 67 + def seed() -> None: 68 + for day in ("20260701", "20260702"): 69 + shutil.copytree( 70 + source_chronicle / day, 71 + target_chronicle / day, 72 + dirs_exist_ok=True, 73 + ) 74 + docs_file = ( 75 + source_chronicle 76 + / "20260703" 77 + / "suze.browser" 78 + / "000141_317" 79 + / "browser_docs-google-com.jsonl" 80 + ) 81 + target_file = ( 82 + target_chronicle 83 + / "20260703" 84 + / "suze.browser" 85 + / "000141_317" 86 + / "browser_docs-google-com.jsonl" 87 + ) 88 + target_file.parent.mkdir(parents=True, exist_ok=True) 89 + shutil.copy2(docs_file, target_file) 90 + corrupt = ( 91 + target_chronicle 92 + / "20260701" 93 + / "workstation.browser" 94 + / "100000_300" 95 + / "browser_corrupt-example-com.jsonl" 96 + ) 97 + corrupt.parent.mkdir(parents=True, exist_ok=True) 98 + corrupt.write_text("{bad json\n", encoding="utf-8") 99 + 100 + return seed
+4 -1
solstone/apps/transcripts/tests/test_api_index.py
··· 33 33 def test_api_index_reports_nonzero_coverage_and_months( 34 34 client: Any, 35 35 journal_copy: Path, 36 + seed_browser_fixture_inventory: Any, 36 37 ) -> None: 38 + seed_browser_fixture_inventory() 39 + 37 40 response = client.get("/app/transcripts/api/index") 38 41 assert response.status_code == 200 39 42 body = response.get_json() 40 43 41 44 expected_days = _expected_nonzero_days(journal_copy) 42 45 assert min(expected_days) == "20240101" 43 - assert max(expected_days) == "20260520" 46 + assert max(expected_days) == "20260703" 44 47 assert body["coverage"] == { 45 48 "start": min(expected_days), 46 49 "end": max(expected_days),
+23 -7
solstone/apps/transcripts/tests/test_api_stats.py
··· 80 80 ) -> None: 81 81 _populate_day_caches(journal_copy) 82 82 scanner = Mock(side_effect=AssertionError("raw scanner should not be called")) 83 - monkeypatch.setattr(routes, "cluster_scan", scanner) 83 + monkeypatch.setattr(routes, "scan_day", scanner) 84 84 85 85 body = _get_month(client) 86 86 ··· 95 95 ) -> None: 96 96 _populate_day_caches(journal_copy) 97 97 _cache_file(journal_copy).unlink() 98 - real_cluster_scan = routes.cluster_scan 99 - scanner = Mock(side_effect=real_cluster_scan) 100 - monkeypatch.setattr(routes, "cluster_scan", scanner) 98 + real_scan_day = routes.scan_day 99 + scanner = Mock(side_effect=real_scan_day) 100 + monkeypatch.setattr(routes, "scan_day", scanner) 101 101 102 102 body = _get_month(client) 103 103 ··· 142 142 ) -> None: 143 143 _populate_day_caches(journal_copy) 144 144 _write_corrupt_cache(journal_copy, SIBLING_DAY) 145 - real_cluster_scan = routes.cluster_scan 146 - scanner = Mock(side_effect=real_cluster_scan) 147 - monkeypatch.setattr(routes, "cluster_scan", scanner) 145 + real_scan_day = routes.scan_day 146 + scanner = Mock(side_effect=real_scan_day) 147 + monkeypatch.setattr(routes, "scan_day", scanner) 148 148 149 149 body = _get_month(client) 150 150 ··· 194 194 body = _get_month(client) 195 195 196 196 assert body[REFERENCE_DAY] == REFERENCE_COUNT 197 + 198 + 199 + def test_browser_segments_count_in_date_nav_stats( 200 + client: Any, 201 + seed_browser_fixture_inventory: Any, 202 + ) -> None: 203 + seed_browser_fixture_inventory() 204 + 205 + response = client.get("/app/transcripts/api/stats/202607") 206 + 207 + assert response.status_code == 200 208 + assert response.get_json() == { 209 + "20260701": 1, 210 + "20260702": 2, 211 + "20260703": 1, 212 + }
+106
solstone/apps/transcripts/tests/test_segment_routes.py
··· 2 2 # Copyright (c) 2026 sol pbc 3 3 4 4 import builtins 5 + import datetime as datetime_module 5 6 import io 6 7 import json 7 8 import math ··· 586 587 assert data["data_state"] == {"audio": "analyzed", "screen": "analyzed"} 587 588 assert set(data["media_purged"]) == {"audio", "screen"} 588 589 assert all(isinstance(value, bool) for value in data["media_purged"].values()) 590 + 591 + 592 + def test_segment_content_merges_browser_between_audio_chunks( 593 + client, 594 + journal_copy, 595 + seed_browser_fixture_inventory, 596 + ): 597 + seed_browser_fixture_inventory() 598 + browser_path = ( 599 + journal_copy 600 + / "chronicle" 601 + / "20260702" 602 + / "workstation.browser" 603 + / "093000_300" 604 + / "browser_docs-example-com.jsonl" 605 + ) 606 + rows = [ 607 + json.loads(line) 608 + for line in browser_path.read_text(encoding="utf-8").splitlines() 609 + ] 610 + # format_audio anchors offsets to the segment's naive local-tz base. 611 + base = int(datetime_module.datetime(2026, 7, 2, 9, 30, 0).timestamp() * 1000) 612 + rows[0]["ts"] = base + 30000 613 + rows[1]["ts"] = base + 35000 614 + browser_path.write_text( 615 + "\n".join(json.dumps(row) for row in rows) + "\n", 616 + encoding="utf-8", 617 + ) 618 + 619 + response = client.get( 620 + "/app/transcripts/api/segment/20260702/workstation.browser/093000_300" 621 + ) 622 + 623 + assert response.status_code == 200 624 + data = response.get_json() 625 + assert [chunk["type"] for chunk in data["chunks"]] == [ 626 + "audio", 627 + "browser", 628 + "browser", 629 + "audio", 630 + ] 631 + ts = [chunk["timestamp"] for chunk in data["chunks"]] 632 + assert ts == sorted(ts) 633 + assert len(set(ts)) == 4 634 + assert ts[0] < ts[1] < ts[2] < ts[3] 635 + assert data["data_state"]["browser"] == "analyzed" 636 + 637 + 638 + def test_segment_content_browser_only_reads_multiple_site_files( 639 + client, 640 + seed_browser_fixture_inventory, 641 + ): 642 + seed_browser_fixture_inventory() 643 + 644 + response = client.get( 645 + "/app/transcripts/api/segment/20260703/suze.browser/000141_317" 646 + ) 647 + 648 + assert response.status_code == 200 649 + data = response.get_json() 650 + browser_chunks = [chunk for chunk in data["chunks"] if chunk["type"] == "browser"] 651 + site_names = {chunk["source_ref"]["site_name"] for chunk in browser_chunks} 652 + assert {"Gmail", "Docs"} <= site_names 653 + assert data["data_state"]["browser"] == "analyzed" 654 + 655 + 656 + def test_segment_content_browser_corrupt_file_warns_and_keeps_valid_site( 657 + client, 658 + seed_browser_fixture_inventory, 659 + ): 660 + seed_browser_fixture_inventory() 661 + 662 + response = client.get( 663 + "/app/transcripts/api/segment/20260701/workstation.browser/100000_300" 664 + ) 665 + 666 + assert response.status_code == 200 667 + data = response.get_json() 668 + browser_chunks = [chunk for chunk in data["chunks"] if chunk["type"] == "browser"] 669 + assert data["warnings"] >= 1 670 + assert any(detail["type"] == "browser" for detail in data["warning_details"]) 671 + assert any( 672 + chunk["source_ref"]["site_name"] == "valid.example.com" 673 + for chunk in browser_chunks 674 + ) 675 + assert data["data_state"]["browser"] == "analyzed" 676 + 677 + 678 + def test_browser_only_segment_list_think_verdict_is_not_awaiting( 679 + client, 680 + seed_browser_fixture_inventory, 681 + ): 682 + seed_browser_fixture_inventory() 683 + 684 + response = client.get("/app/transcripts/api/segments/20260703") 685 + 686 + assert response.status_code == 200 687 + segments = response.get_json()["segments"] 688 + browser_segment = next( 689 + segment 690 + for segment in segments 691 + if segment["stream"] == "suze.browser" and segment["key"] == "000141_317" 692 + ) 693 + assert browser_segment["data_state"]["browser"] == "analyzed" 694 + assert browser_segment["think"] not in {"awaiting", "stuck"} 589 695 590 696 591 697 def test_markdown_only_import_segment_lists_as_markdown(client, journal_copy):
+91 -9
solstone/apps/transcripts/workspace.html
··· 264 264 border: 1px solid #eab308; 265 265 } 266 266 267 + .tr-seg-browser { 268 + background: rgba(147, 197, 253, 0.55); 269 + border: 1px solid #2563eb; 270 + } 271 + 267 272 .tr-seg-markdown { 268 273 background: rgba(147, 197, 253, 0.6); 269 274 border: 1px solid #3b82f6; ··· 1162 1167 border: 1px solid #3b82f6; 1163 1168 } 1164 1169 1170 + .tr-zoom-pill-browser { 1171 + background: linear-gradient(135deg, rgba(96, 165, 250, 0.78), rgba(191, 219, 254, 0.68)); 1172 + border: 1px solid #2563eb; 1173 + } 1174 + 1165 1175 .tr-zoom-pill-both { 1166 1176 background: linear-gradient(to right, rgba(134, 239, 172, 0.75), rgba(194, 234, 155, 0.7) 50%, rgba(253, 230, 138, 0.75)); 1167 1177 border: 1px solid #84cc16; ··· 1622 1632 border-left: 3px solid #facc15; 1623 1633 } 1624 1634 1635 + .tr-entry-browser { 1636 + border-left: 3px solid #60a5fa; 1637 + cursor: default; 1638 + } 1639 + 1625 1640 .tr-entry-time { 1626 1641 flex-shrink: 0; 1627 1642 width: 48px; ··· 1646 1661 margin-top: 2px; 1647 1662 } 1648 1663 1664 + .tr-entry-browser .tr-entry-content { 1665 + display: grid; 1666 + gap: 6px; 1667 + } 1668 + 1669 + .tr-browser-site { 1670 + display: flex; 1671 + flex-wrap: wrap; 1672 + gap: 6px; 1673 + align-items: baseline; 1674 + color: #1d4ed8; 1675 + font-size: 12px; 1676 + font-weight: 600; 1677 + } 1678 + 1679 + .tr-browser-title { 1680 + color: #6b7280; 1681 + font-weight: 400; 1682 + } 1683 + 1649 1684 .tr-entry-thumb { 1650 1685 flex-shrink: 0; 1651 1686 width: 120px; ··· 1989 2024 body.presentation-mode .tr-seg { width: 48px; box-shadow: 0 2px 4px rgba(0,0,0,.10); } 1990 2025 body.presentation-mode .tr-seg-audio { background: rgba(74, 222, 128, 0.85); border-width: 2px; } 1991 2026 body.presentation-mode .tr-seg-screen { background: rgba(234, 179, 8, 0.85); border-width: 2px; } 2027 + body.presentation-mode .tr-seg-browser { background: rgba(96, 165, 250, 0.85); border-width: 2px; } 1992 2028 body.presentation-mode .tr-zoom-pill { font-weight: 700; } 1993 2029 body.presentation-mode .tr-content { padding: 8px 28px 28px; gap: 20px; } 1994 2030 body.presentation-mode .tr-title { font-size: 28px; color: #0b1220; } ··· 3151 3187 }); 3152 3188 } 3153 3189 3190 + function browserOnlySegments(segments) { 3191 + return (segments || []).filter(seg => { 3192 + const types = seg.types || []; 3193 + return types.includes('browser') && !types.includes('audio') && !types.includes('screen'); 3194 + }); 3195 + } 3196 + 3154 3197 function addSegmentIndicator(type, startMin, endMin, column, streams = [], state = 'analyzed', think = null, segment = null) { 3155 3198 const el = document.createElement('div'); 3156 - el.className = 'tr-seg ' + (type === 'screen' ? 'tr-seg-screen' : type === 'markdown' ? 'tr-seg-markdown' : 'tr-seg-audio'); 3199 + el.className = 'tr-seg ' + ( 3200 + type === 'screen' 3201 + ? 'tr-seg-screen' 3202 + : type === 'markdown' 3203 + ? 'tr-seg-markdown' 3204 + : type === 'browser' 3205 + ? 'tr-seg-browser' 3206 + : 'tr-seg-audio' 3207 + ); 3157 3208 if (state === 'pending') { 3158 3209 el.classList.add('tr-seg-pending'); 3159 3210 } else if (state === 'analyzing') { ··· 3351 3402 const hasAudio = seg.types.includes('audio'); 3352 3403 const hasScreen = seg.types.includes('screen'); 3353 3404 const hasMarkdown = seg.types.includes('markdown'); 3405 + const hasBrowser = seg.types.includes('browser'); 3354 3406 const dataState = seg.data_state || {}; 3355 3407 const hasPendingAdvertised = (seg.types || []).some( 3356 3408 type => dataState[type] && dataState[type] !== 'analyzed' ··· 3364 3416 pill.classList.add('tr-zoom-pill-audio'); 3365 3417 } else if (hasMarkdown) { 3366 3418 pill.classList.add('tr-zoom-pill-markdown'); 3419 + } else if (hasBrowser) { 3420 + pill.classList.add('tr-zoom-pill-browser'); 3367 3421 } else { 3368 3422 pill.classList.add('tr-zoom-pill-screen'); 3369 3423 } ··· 3374 3428 } else if (seg.think === 'awaiting') { 3375 3429 pill.classList.add('tr-zoom-pill-awaiting'); 3376 3430 } 3377 - const typeLabel = (hasAudio && hasScreen) ? 'audio and screen' : hasAudio ? 'audio' : hasMarkdown ? 'import' : 'screen'; 3431 + const typeLabel = (hasAudio && hasScreen) ? 'audio and screen' : hasAudio ? 'audio' : hasMarkdown ? 'import' : hasBrowser ? 'pages' : 'screen'; 3378 3432 3379 3433 if (selectedSegment && selectedSegment.key === seg.key) { 3380 3434 pill.classList.add('tr-active'); ··· 3383 3437 pill.style.top = zoomY(visStart) + 'px'; 3384 3438 pill.style.height = Math.max(4, zoomY(visEnd) - zoomY(visStart)) + 'px'; 3385 3439 const duration = Math.round(visEnd - visStart); 3386 - const typeDesc = (hasAudio && hasScreen) ? 'audio + screen' : hasAudio ? 'audio' : hasMarkdown ? 'import' : 'screen'; 3440 + const typeDesc = (hasAudio && hasScreen) ? 'audio + screen' : hasAudio ? 'audio' : hasMarkdown ? 'import' : hasBrowser ? 'pages' : 'screen'; 3387 3441 const segmentStatusLabel = hasAnalyzingAdvertised ? 'analyzing' : hasPendingAdvertised ? 'pending' : seg.think === 'awaiting' ? AWAITING_THINKING_LABEL : ''; 3388 3442 const statusSuffix = segmentStatusLabel ? ' · ' + segmentStatusLabel : ''; 3389 3443 const ariaStatusSuffix = segmentStatusLabel ? ', ' + segmentStatusLabel : ''; ··· 3651 3705 } 3652 3706 if (dataState.screen) { 3653 3707 addTab('screen', 'screen'); 3708 + } 3709 + if (dataState.browser) { 3710 + addTab('browser', 'pages'); 3654 3711 } 3655 3712 if ((data.signals?.events || []).length > 0) { 3656 3713 addTab('signals', 'signals'); ··· 3689 3746 tabPanes[tabId] = pane; 3690 3747 3691 3748 if (tabId === 'transcript') { 3692 - renderSegmentTimeline(segmentData, true, true, pane); 3749 + renderSegmentTimeline(segmentData, true, true, pane, true); 3693 3750 } else if (tabId === 'audio') { 3694 - renderSegmentTimeline(segmentData, true, false, pane); 3751 + renderSegmentTimeline(segmentData, true, false, pane, false); 3695 3752 } else if (tabId === 'screen') { 3696 3753 const segmentToken = selectedSegment?.key; 3697 3754 pane.innerHTML = window.SurfaceState.loading({ text: 'Loading screen entries...' }); ··· 3708 3765 if (tabPanes[tabId] !== pane) { 3709 3766 return; 3710 3767 } 3711 - renderSegmentTimeline(segmentData, false, true, pane); 3768 + renderSegmentTimeline(segmentData, false, true, pane, false); 3712 3769 }) 3713 3770 .catch(() => { 3714 3771 if (!selectedSegment || selectedSegment.key !== segmentToken) { ··· 3722 3779 desc: 'something went wrong decoding the screen data. try selecting the segment again.' 3723 3780 }); 3724 3781 }); 3782 + } else if (tabId === 'browser') { 3783 + renderSegmentTimeline(segmentData, false, false, pane, true); 3725 3784 } else if (tabId === 'signals') { 3726 3785 renderSignalsTab(segmentData, pane); 3727 3786 } else if (tabId.startsWith('md-')) { ··· 4093 4152 targetEl.innerHTML = html; 4094 4153 } 4095 4154 4096 - function renderSegmentTimeline(data, showAudio, showScreen, targetEl) { 4155 + function renderSegmentTimeline(data, showAudio, showScreen, targetEl, showBrowser = true) { 4156 + const browserOnlyView = showBrowser && !showAudio && !showScreen; 4097 4157 const chunks = (data.chunks || []).filter(c => { 4158 + if (browserOnlyView && c.type !== 'browser') return false; 4098 4159 if (c.type === 'audio' && !showAudio) return false; 4099 4160 if (c.type === 'screen' && !showScreen) return false; 4161 + if (c.type === 'browser' && !showBrowser) return false; 4100 4162 return true; 4101 4163 }); 4102 4164 const dataState = data.data_state || {}; ··· 4134 4196 return; 4135 4197 } 4136 4198 } 4137 - const tabType = !showScreen ? 'audio' : !showAudio ? 'screen' : 'transcript'; 4199 + const tabType = showBrowser && !showAudio && !showScreen ? 'browser' : !showScreen ? 'audio' : !showAudio ? 'screen' : 'transcript'; 4138 4200 const tabEmptyMap = { 4139 4201 transcript: { icon: emptyIcons.transcript, heading: 'no transcript entries', desc: 'this segment has no transcript content' }, 4140 4202 audio: { icon: emptyIcons.audio, heading: 'no audio entries', desc: 'this segment has no audio content' }, 4141 - screen: { icon: emptyIcons.screen, heading: 'no screen entries', desc: 'this segment has no screen observations' } 4203 + screen: { icon: emptyIcons.screen, heading: 'no screen entries', desc: 'this segment has no screen observations' }, 4204 + browser: { icon: emptyIcons.transcript, heading: 'no page entries', desc: 'this segment has no page content' } 4142 4205 }; 4143 4206 const emptyInfo = tabEmptyMap[tabType] || tabEmptyMap.transcript; 4144 4207 targetEl.innerHTML = window.SurfaceState.empty(emptyInfo); ··· 4199 4262 html += `<div class="tr-entry tr-entry-transcript" data-idx="${idx}" data-type="${escapeHtml(item.type)}" data-timestamp="${item.timestamp}" role="listitem" tabindex="0">`; 4200 4263 html += `<div class="tr-entry-time">${timeStr}</div>`; 4201 4264 html += '<div class="tr-entry-content">'; 4265 + html += `<div class="tr-entry-text">${markdown}</div>`; 4266 + html += '</div></div>'; 4267 + } else if (item.type === 'browser') { 4268 + const timeStr = item.time || ''; 4269 + const markdown = window.AppServices.renderMarkdown(item.markdown) || ''; 4270 + const source = item.source_ref || {}; 4271 + const siteName = source.site_name || source.site || source.file || 'pages'; 4272 + const title = source.title || ''; 4273 + html += `<div class="tr-entry tr-entry-browser" data-idx="${idx}" data-type="browser" data-timestamp="${item.timestamp}" role="listitem">`; 4274 + html += `<div class="tr-entry-time">${timeStr}</div>`; 4275 + html += '<div class="tr-entry-content">'; 4276 + html += '<span class="sr-only">pages: </span>'; 4277 + html += `<div class="tr-browser-site">${escapeHtml(siteName)}${title ? `<span class="tr-browser-title">${escapeHtml(title)}</span>` : ''}</div>`; 4202 4278 html += `<div class="tr-entry-text">${markdown}</div>`; 4203 4279 html += '</div></div>'; 4204 4280 } else if (item.type === 'screen') { ··· 4787 4863 markdownOnlySegments(data.segments).forEach(seg => { 4788 4864 addSegmentIndicator('markdown', parseTime(seg.start), parseTime(seg.end), 0, [seg.stream], 'analyzed', seg.think, seg); 4789 4865 }); 4866 + browserOnlySegments(data.segments).forEach(seg => { 4867 + addSegmentIndicator('browser', parseTime(seg.start), parseTime(seg.end), 1, [seg.stream], 'analyzed', seg.think, seg); 4868 + }); 4790 4869 fetchDayBodyWindow() 4791 4870 .then(payload => { 4792 4871 dayBodyWindow = payload; ··· 5088 5167 }); 5089 5168 markdownOnlySegments(allSegments).forEach(seg => { 5090 5169 addSegmentIndicator('markdown', parseTime(seg.start), parseTime(seg.end), 0, [seg.stream], 'analyzed', seg.think, seg); 5170 + }); 5171 + browserOnlySegments(allSegments).forEach(seg => { 5172 + addSegmentIndicator('browser', parseTime(seg.start), parseTime(seg.end), 1, [seg.stream], 'analyzed', seg.think, seg); 5091 5173 }); 5092 5174 renderBodyEvents(dayBodyWindow); 5093 5175 })
+4
solstone/think/journal_stats.py
··· 401 401 402 402 stats["transcript_ranges"] = 0 403 403 stats["percept_ranges"] = 0 404 + stats["browser_segments"] = 0 404 405 try: 405 406 audio_ranges, screen_ranges, segments = cluster_scan_day(day) 406 407 stats["transcript_ranges"] = len(audio_ranges) 407 408 stats["percept_ranges"] = len(screen_ranges) 409 + stats["browser_segments"] = sum( 410 + 1 for segment in segments if "browser" in segment.get("types", ()) 411 + ) 408 412 completion = classify_segment_completion( 409 413 segments, 410 414 read_segment_progress(day),
+4 -2
solstone/think/stats_schema.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - SCHEMA_VERSION = 7 4 + SCHEMA_VERSION = 8 5 5 6 6 DAY_FIELDS = ( 7 7 "transcript_sessions", ··· 12 12 "percept_frames", 13 13 "percept_duration", 14 14 "percept_ranges", 15 + "browser_segments", 15 16 "pending_segments", 16 17 "segments_pending_think", 17 18 "outputs_processed", ··· 28 29 "percept_frames", 29 30 "percept_duration", 30 31 "percept_ranges", 32 + "browser_segments", 31 33 "pending_segments", 32 34 "segments_pending_think", 33 35 "outputs_processed", ··· 54 56 55 57 56 58 def validate(data: dict) -> list[str]: 57 - """Validate stats output against schema v7. Returns list of error strings (empty = valid).""" 59 + """Validate stats output against current schema. Returns error strings.""" 58 60 errors = [] 59 61 60 62 # Check schema_version
+2
tests/fixtures/journal/chronicle/20260701/workstation.browser/100000_300/browser_valid-example-com.jsonl
··· 1 + {"t":"segment_start","ts":1782921600000,"rel":0,"site":"valid.example.com","url":"https://valid.example.com/page","title":"Valid Browser Fixture","adapter":"","ctx":"page","n":2,"blocks":[{"type":"heading","text":"Valid Browser Fixture"},{"type":"text","text":"Valid file beside a corrupt one."}]} 2 + {"t":"delta","ts":1782921610000,"rel":10000,"site":"valid.example.com","adapter":"","ctx":"page","op":"add","block":{"type":"text","text":"Valid delta still renders."}}
+1
tests/fixtures/journal/chronicle/20260701/workstation.browser/100000_300/stream.json
··· 1 + {"stream": "workstation.browser", "seq": 1}
+3
tests/fixtures/journal/chronicle/20260702/workstation.browser/093000_300/audio.jsonl
··· 1 + {"setting":"desk","topics":["browser","ordering"]} 2 + {"start":"00:00:10","source":"mic","speaker":1,"text":"Audio before the browser page."} 3 + {"start":"00:00:50","source":"mic","speaker":1,"text":"Audio after the browser page."}
+2
tests/fixtures/journal/chronicle/20260702/workstation.browser/093000_300/browser_docs-example-com.jsonl
··· 1 + {"t":"segment_start","ts":1783006230000,"rel":30000,"site":"docs.example.com","url":"https://docs.example.com/browser-order","title":"Browser Ordering Fixture","adapter":"docs","ctx":"doc","n":2,"blocks":[{"type":"heading","text":"Browser Ordering Fixture"},{"type":"text","text":"This browser row sorts between audio rows."}]} 2 + {"t":"delta","ts":1783006235000,"rel":35000,"site":"docs.example.com","adapter":"docs","ctx":"doc","op":"update","block":{"type":"text","text":"Ordering update remains between audio rows."}}
+1
tests/fixtures/journal/chronicle/20260702/workstation.browser/093000_300/stream.json
··· 1 + {"stream": "workstation.browser", "seq": 1}
+2
tests/fixtures/journal/chronicle/20260703/suze.browser/000141_317/browser_docs-google-com.jsonl
··· 1 + {"t":"segment_start","ts":1783046560000,"rel":59000,"site":"docs.google.com","url":"https://docs.google.com/document/d/browser-stream","title":"Browser Stream Notes","adapter":"docs","ctx":"doc","n":2,"blocks":[{"type":"heading","text":"Browser Stream Notes"},{"type":"text","text":"Fixture covers two browser site files."}]} 2 + {"t":"delta","ts":1783046572000,"rel":71000,"site":"docs.google.com","adapter":"docs","ctx":"doc","op":"add","block":{"type":"text","text":"Added timeline browser-origin note."}}
+2 -2
tests/test_journal_stats.py
··· 619 619 } 620 620 621 621 622 - def test_stats_schema_v7_accepts_segment_repair_backlog_day(): 622 + def test_stats_schema_v8_accepts_segment_repair_backlog_day(): 623 623 stats_mod = importlib.import_module("solstone.think.journal_stats") 624 624 schema_mod = importlib.import_module("solstone.think.stats_schema") 625 625 ··· 656 656 657 657 data = js.to_dict() 658 658 659 - assert schema_mod.SCHEMA_VERSION == 7 659 + assert schema_mod.SCHEMA_VERSION == 8 660 660 assert schema_mod.validate(data) == [] 661 661 assert data["backlog"]["days"][0]["segment_repair_status"] == "degraded" 662 662
+15
tests/test_stats_contract.py
··· 60 60 day = journal / "chronicle" / "20240101" 61 61 seg1 = day / "default" / "123456_300" 62 62 seg2 = day / "default" / "134500_300" 63 + browser_seg = day / "workstation.browser" / "140000_300" 63 64 seg1.mkdir(parents=True) 64 65 seg2.mkdir(parents=True) 66 + browser_seg.mkdir(parents=True) 65 67 (day / "talents").mkdir(parents=True) 66 68 67 69 audio_lines = [ ··· 83 85 ) 84 86 85 87 (seg2 / "audio.flac").write_bytes(b"fLaC") 88 + browser_lines = [ 89 + { 90 + "t": "segment_start", 91 + "ts": 1704117600000, 92 + "site": "docs.example.com", 93 + "title": "Stats Browser Fixture", 94 + "adapter": "docs", 95 + "blocks": [{"type": "text", "text": "browser stats fixture"}], 96 + } 97 + ] 98 + (browser_seg / "browser_docs-example-com.jsonl").write_text( 99 + "\n".join(json.dumps(line) for line in browser_lines) + "\n" 100 + ) 86 101 (day / "talents" / "schedule.json").write_text("[]") 87 102 88 103 facet_dir = journal / "facets" / "work"
+2 -2
tests/test_stats_schema.py
··· 28 28 } 29 29 30 30 31 - def test_schema_version_is_7(): 31 + def test_schema_version_is_8(): 32 32 schema_mod = importlib.import_module("solstone.think.stats_schema") 33 33 34 - assert schema_mod.SCHEMA_VERSION == 7 34 + assert schema_mod.SCHEMA_VERSION == 8 35 35 36 36 37 37 def test_validate_passes_on_valid_output(tmp_path, monkeypatch):