personal memory agent
0
fork

Configure Feed

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

observe/grab: restore v2 polish affordances

- A: add drill-down `Next:` footers at levels 0-3 and the level-5a `Save:` / `Batch:` footer.
- B: add the level-4 Inspect / Save one / Save many footer with the "How extraction works" explainer, add the purged variant, and preserve the legacy-schema no-footer variant.
- C: add the third level-3 status `analyzed; raw media purged by retention`, make save-mode errors name retention, and add `summary.video_present` to level-4 JSON.
- D: add helpful "what's available" listings for unknown day, stream, segment, and screen errors.
- E: distinguish header-only `screen.jsonl` from legacy schema and emit `No qualified frames in this screen's analysis.`.
- F: scope malformed-JSONL WARN quieting to default `sol grab` while keeping `-v` diagnostic warnings.

Skip the optional Group B `next` JSON field for v2; unchanged levels keep the v1 JSON contract byte-stable.

Fixture coverage added for `20240104/default/120000_300/` (purged), `20240105/default/130000_300/` (header-only), and `20240106/default/140000_300/` (malformed-jsonl). Add `level_3_purged.json`, `level_4_purged.json`, and `level_4_header_only.json`; rebaseline `level_0.json` and `level_4.json`.

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

+543 -72
+213 -67
observe/grab.py
··· 7 7 8 8 import argparse 9 9 import json 10 + import logging 10 11 from dataclasses import dataclass 11 12 from datetime import datetime, timedelta 12 13 from pathlib import Path ··· 30 31 "2": ("segments", ["segment", "start", "end", "screens", "frames_analyzed"]), 31 32 "3": ("screens", ["screen", "position", "connector", "frames_analyzed", "status"]), 32 33 } 34 + NEXT_FOOTERS = { 35 + "0": "Next: sol grab <day>", 36 + "1": "Next: sol grab <day> <stream>", 37 + "2": "Next: sol grab <day> <stream> <segment>", 38 + "3": "Next: sol grab <day> <stream> <segment> <screen>", 39 + } 40 + LEVEL_4_FOOTER = ( 41 + "Inspect: sol grab <day> <stream> <segment> <screen> <id>\n" 42 + "Save one: sol grab <day> <stream> <segment> <screen> <id> --out PATH\n" 43 + "Save many: sol grab <day> <stream> <segment> <screen> " 44 + "<id1>,<id2>,... --out PATH\n" 45 + "\n" 46 + "How extraction works:\n" 47 + " Decoding walks the video linearly from frame 0 — seeking is unsafe at the\n" 48 + " 1 Hz capture rate. Cost is dominated by the highest requested frame_id, not\n" 49 + " the count. Asking for ids 7,12,23 costs the same as asking for 23 alone.\n" 50 + " Prefer batch mode when you want more than one frame from the same screen." 51 + ) 52 + LEVEL_4_PURGED_FOOTER = ( 53 + "Save mode unavailable: raw video has been purged by retention.\n" 54 + "Frame metadata above is still readable.\n" 55 + "\n" 56 + "Inspect: sol grab <day> <stream> <segment> <screen> <id>" 57 + ) 33 58 34 59 35 60 @dataclass ··· 40 65 frame_records: list[dict[str, Any]] 41 66 frame_index: dict[int, dict[str, Any]] 42 67 legacy_schema: bool 68 + header_only: bool 43 69 status: str 44 70 segment_start: datetime 45 71 ··· 117 143 print(" ".join(str(row.get(col, "")).ljust(widths[col]) for col in columns)) 118 144 119 145 146 + def _format_alternatives(header: str, items: list[str]) -> str: 147 + lines = ["", "", header] 148 + lines.extend(f" {item}" for item in items) 149 + return "\n".join(lines) 150 + 151 + 152 + def _available_days() -> list[str]: 153 + chronicle_dir = Path(get_journal()) / "chronicle" 154 + if not chronicle_dir.is_dir(): 155 + return [] 156 + return sorted( 157 + day_dir.name 158 + for day_dir in chronicle_dir.iterdir() 159 + if day_dir.is_dir() and len(day_dir.name) == 8 and day_dir.name.isdigit() 160 + ) 161 + 162 + 163 + def _closest_days(day: str, days: list[str]) -> list[str]: 164 + if not day.isdigit(): 165 + return days[:5] 166 + target = int(day) 167 + closest = sorted(days, key=lambda value: (abs(int(value) - target), int(value)))[:5] 168 + return sorted(closest) 169 + 170 + 171 + def _available_segments(stream_dir: Path) -> list[str]: 172 + segments = [] 173 + for segment_dir in sorted(path for path in stream_dir.iterdir() if path.is_dir()): 174 + start_time, end_time = segment_parse(segment_dir.name) 175 + if start_time is not None and end_time is not None: 176 + segments.append(segment_dir.name) 177 + return segments 178 + 179 + 180 + def _truncate_segments(segments: list[str]) -> list[str]: 181 + if len(segments) <= 20: 182 + return segments 183 + return [*segments[:10], "...", *segments[-10:]] 184 + 185 + 186 + def _available_screen_tokens(segment_dir: Path) -> list[str]: 187 + tokens: set[str] = set() 188 + for entry in segment_dir.iterdir(): 189 + if not entry.is_file(): 190 + continue 191 + suffix = entry.suffix.lower() 192 + if suffix == ".jsonl" and _is_screen_token(entry.stem): 193 + tokens.add(entry.stem) 194 + if suffix in VIDEO_EXTENSIONS and _is_screen_token(entry.stem): 195 + tokens.add(entry.stem) 196 + return sorted(_normalize_screen_token(token) for token in tokens) 197 + 198 + 120 199 def _require_day(day: str) -> Path: 121 200 day_dir = Path(get_journal()) / "chronicle" / day 122 201 if not day_dir.is_dir(): 123 - raise FileNotFoundError(f"day {day} not found") 202 + alternatives = _format_alternatives( 203 + "Available days (closest 5):", 204 + _closest_days(day, _available_days()), 205 + ) 206 + raise FileNotFoundError(f"day {day} not found{alternatives}") 124 207 return day_dir 125 208 126 209 127 210 def _require_stream(day: str, stream: str) -> Path: 128 - _require_day(day) 211 + day_dir = _require_day(day) 129 212 stream_dir = Path(get_journal()) / "chronicle" / day / stream 130 213 if not stream_dir.is_dir(): 131 - raise FileNotFoundError(f"stream {stream} not found in {day}") 214 + streams = sorted( 215 + path.name 216 + for path in day_dir.iterdir() 217 + if path.is_dir() and path.name != "health" 218 + ) 219 + alternatives = _format_alternatives( 220 + f"Available streams in {day}:", 221 + streams, 222 + ) 223 + raise FileNotFoundError(f"stream {stream} not found in {day}{alternatives}") 132 224 return stream_dir 133 225 134 226 135 227 def _require_segment(day: str, stream: str, segment: str) -> Path: 136 - _require_stream(day, stream) 228 + stream_dir = _require_stream(day, stream) 137 229 seg_dir = segment_path(day, segment, stream, create=False) 138 230 if not seg_dir.is_dir(): 139 - raise FileNotFoundError(f"segment {segment} not found in {day}/{stream}") 231 + alternatives = _format_alternatives( 232 + f"Available segments in {day}/{stream}:", 233 + _truncate_segments(_available_segments(stream_dir)), 234 + ) 235 + raise FileNotFoundError( 236 + f"segment {segment} not found in {day}/{stream}{alternatives}" 237 + ) 140 238 return seg_dir 141 239 142 240 ··· 186 284 video_path = candidate 187 285 break 188 286 287 + frame_records = [record for record in records if "frame_id" in record] 288 + frame_index = {int(record["frame_id"]): record for record in frame_records} 289 + if header: 290 + non_header_records = records[1:] 291 + else: 292 + non_header_records = records 293 + header_only = ( 294 + jsonl_path is not None and not frame_records and not non_header_records 295 + ) 296 + legacy_schema = ( 297 + jsonl_path is not None and not frame_records and bool(non_header_records) 298 + ) 299 + 189 300 if jsonl_path is None and video_path is not None: 190 301 status = "captured but not analyzed" 302 + elif jsonl_path is not None and video_path is None: 303 + status = "analyzed; raw media purged by retention" 191 304 elif jsonl_path is not None: 192 305 status = "analyzed" 193 306 else: 307 + alternatives = _format_alternatives( 308 + f"Available screens in {day}/{stream}/{segment}:", 309 + _available_screen_tokens(segment_dir), 310 + ) 194 311 raise FileNotFoundError( 195 - f"screen {screen_token} not found in {day}/{stream}/{segment}" 312 + f"screen {screen_token} not found in {day}/{stream}/{segment}{alternatives}" 196 313 ) 197 314 198 - frame_records = [record for record in records if "frame_id" in record] 199 - frame_index = {int(record["frame_id"]): record for record in frame_records} 200 - legacy_schema = jsonl_path is not None and not frame_records 201 - 202 315 segment_start, _segment_end = _segment_bounds(day, segment) 203 316 204 317 journal = Path(get_journal()) ··· 209 322 frame_records=sorted(frame_records, key=lambda record: int(record["frame_id"])), 210 323 frame_index=frame_index, 211 324 legacy_schema=legacy_schema, 325 + header_only=header_only, 212 326 status=status, 213 327 segment_start=segment_start, 214 328 ) ··· 216 330 217 331 def list_segment_screens(day: str, stream: str, segment: str) -> dict[str, Any]: 218 332 segment_dir = _require_segment(day, stream, segment) 219 - tokens: set[str] = set() 220 - for entry in segment_dir.iterdir(): 221 - if not entry.is_file(): 222 - continue 223 - suffix = entry.suffix.lower() 224 - if suffix == ".jsonl" and _is_screen_token(entry.stem): 225 - tokens.add(entry.stem) 226 - if suffix in VIDEO_EXTENSIONS and _is_screen_token(entry.stem): 227 - tokens.add(entry.stem) 333 + tokens = [_screen_stem(token) for token in _available_screen_tokens(segment_dir)] 228 334 229 335 screens = [] 230 336 for token in sorted(tokens): ··· 335 441 ) 336 442 frames = ( 337 443 [] 338 - if bundle.legacy_schema 444 + if bundle.legacy_schema or bundle.header_only 339 445 else [ 340 446 _frame_view(bundle.segment_start, frame) for frame in bundle.frame_records 341 447 ] 342 448 ) 343 449 error_frames = ( 344 450 0 345 - if bundle.legacy_schema 451 + if bundle.legacy_schema or bundle.header_only 346 452 else sum(1 for frame in bundle.frame_records if "error" in frame) 347 453 ) 348 454 ··· 354 460 "frames_analyzed": len(bundle.frame_records), 355 461 "error_frames": error_frames, 356 462 "legacy_schema": bundle.legacy_schema, 463 + "video_present": bundle.video_path is not None, 357 464 }, 358 465 "frames": frames, 359 466 }, ··· 432 539 ) -> dict[str, Any]: 433 540 bundle = _load_analyzed_bundle(day, stream, segment, screen_token) 434 541 if bundle.video_path is None: 542 + if bundle.jsonl_rel is not None: 543 + frame_id_token = ",".join(str(frame_id) for frame_id in frame_ids) 544 + command = ( 545 + f"sol grab {day} {stream} {segment} {screen_token} {frame_id_token}" 546 + ) 547 + raise FileNotFoundError( 548 + "raw video has been purged by retention; metadata-only access " 549 + f"remains via: {command}" 550 + ) 435 551 raise FileNotFoundError( 436 552 f"raw video not found for screen {screen_token} in {segment}" 437 553 ) ··· 502 618 if level in TABLE_LEVELS: 503 619 key, columns = TABLE_LEVELS[level] 504 620 _print_table(columns, data[key]) 621 + print() 622 + print(NEXT_FOOTERS[level]) 505 623 return 506 624 if level == "4": 507 - if data["summary"]["legacy_schema"]: 625 + summary = data["summary"] 626 + if summary["legacy_schema"]: 508 627 print("0 frames analyzed: file uses pre-frame_id schema") 628 + elif summary["frames_analyzed"] == 0 and not data["frames"]: 629 + print("No qualified frames in this screen's analysis.") 509 630 else: 510 631 _print_table( 511 632 ["frame_id", "timestamp", "abs_time", "primary", "notes"], 512 633 data["frames"], 513 634 ) 635 + print() 636 + if summary["video_present"]: 637 + print(LEVEL_4_FOOTER) 638 + else: 639 + print(LEVEL_4_PURGED_FOOTER) 514 640 return 515 641 if level == "5a": 516 642 scope = payload["scope"] ··· 527 653 print(f"Notes: {computed['notes']}") 528 654 print() 529 655 print(json.dumps(data["frame"], indent=2)) 656 + print() 657 + print("Save: sol grab <day> <stream> <segment> <screen> <id> --out PATH") 658 + print( 659 + "Batch: sol grab <day> <stream> <segment> <screen> " 660 + "<id1>,<id2>,... --out PATH" 661 + ) 530 662 return 531 663 if level in {"5b", "5c"}: 532 664 for item in data["saved"]: ··· 561 693 help="Emit JSON instead of table or plain output.", 562 694 ) 563 695 args = setup_cli(parser) 564 - require_solstone() 565 - 566 - tokens = list(args.args) 567 - if len(tokens) > 5: 568 - parser.error( 569 - "grab accepts at most 5 positional tokens: day stream segment screen frame-id" 570 - ) 571 - if args.force and not args.out: 572 - parser.error("--force requires --out") 573 - if args.out and len(tokens) != 5: 574 - parser.error("--out requires day stream segment screen and frame-id") 575 - if args.out: 576 - try: 577 - resolve_output_paths(args.out, [1]) 578 - except ValueError as exc: 579 - parser.error(str(exc)) 696 + observe_utils_logger = logging.getLogger("observe.utils") 697 + previous_level = observe_utils_logger.level 698 + should_quiet = ( 699 + observe_utils_logger.getEffectiveLevel() == logging.WARNING 700 + and not args.verbose 701 + and not args.debug 702 + ) 703 + if should_quiet: 704 + observe_utils_logger.setLevel(logging.ERROR) 580 705 581 - list_handlers = { 582 - 0: lambda: list_available_days(), 583 - 1: lambda: list_day_streams(tokens[0]), 584 - 2: lambda: list_stream_segments(tokens[0], tokens[1]), 585 - 3: lambda: list_segment_screens(tokens[0], tokens[1], tokens[2]), 586 - 4: lambda: list_screen_frames(tokens[0], tokens[1], tokens[2], tokens[3]), 587 - } 588 706 try: 589 - if len(tokens) < 5: 590 - payload = list_handlers[len(tokens)]() 591 - else: 592 - frame_ids = parse_frame_id_token(tokens[4]) 593 - if len(frame_ids) > 1 and not args.out: 594 - parser.error("multiple frame ids require --out") 595 - if args.out: 596 - payload = save_frame_images( 597 - tokens[0], 598 - tokens[1], 599 - tokens[2], 600 - tokens[3], 601 - frame_ids, 602 - args.out, 603 - args.force, 604 - ) 707 + require_solstone() 708 + 709 + tokens = list(args.args) 710 + if len(tokens) > 5: 711 + parser.error( 712 + "grab accepts at most 5 positional tokens: day stream segment screen frame-id" 713 + ) 714 + if args.force and not args.out: 715 + parser.error("--force requires --out") 716 + if args.out and len(tokens) != 5: 717 + parser.error("--out requires day stream segment screen and frame-id") 718 + if args.out: 719 + try: 720 + resolve_output_paths(args.out, [1]) 721 + except ValueError as exc: 722 + parser.error(str(exc)) 723 + 724 + list_handlers = { 725 + 0: lambda: list_available_days(), 726 + 1: lambda: list_day_streams(tokens[0]), 727 + 2: lambda: list_stream_segments(tokens[0], tokens[1]), 728 + 3: lambda: list_segment_screens(tokens[0], tokens[1], tokens[2]), 729 + 4: lambda: list_screen_frames(tokens[0], tokens[1], tokens[2], tokens[3]), 730 + } 731 + try: 732 + if len(tokens) < 5: 733 + payload = list_handlers[len(tokens)]() 605 734 else: 606 - payload = show_frame_metadata( 607 - tokens[0], tokens[1], tokens[2], tokens[3], frame_ids[0] 608 - ) 609 - except (FileNotFoundError, FileExistsError, RuntimeError, ValueError) as exc: 610 - raise SystemExit(str(exc)) from exc 735 + frame_ids = parse_frame_id_token(tokens[4]) 736 + if len(frame_ids) > 1 and not args.out: 737 + parser.error("multiple frame ids require --out") 738 + if args.out: 739 + payload = save_frame_images( 740 + tokens[0], 741 + tokens[1], 742 + tokens[2], 743 + tokens[3], 744 + frame_ids, 745 + args.out, 746 + args.force, 747 + ) 748 + else: 749 + payload = show_frame_metadata( 750 + tokens[0], tokens[1], tokens[2], tokens[3], frame_ids[0] 751 + ) 752 + except (FileNotFoundError, FileExistsError, RuntimeError, ValueError) as exc: 753 + raise SystemExit(str(exc)) from exc 611 754 612 - emit_output(payload, as_json=bool(args.json)) 755 + emit_output(payload, as_json=bool(args.json)) 756 + finally: 757 + if should_quiet: 758 + observe_utils_logger.setLevel(previous_level) 613 759 614 760 615 761 if __name__ == "__main__":
+2
tests/fixtures/sol_grab/journal/chronicle/20240104/default/120000_300/screen.jsonl
··· 1 + {"raw": "screen.webm"} 2 + {"frame_id": 7, "timestamp": 1.0, "requests": [{"type": "describe", "model": "gemini-2.5-flash-lite", "duration": 0.4}], "analysis": {"visual_description": "Archived planning board metadata.", "primary": "productivity", "secondary": "none", "overlap": true}}
+1
tests/fixtures/sol_grab/journal/chronicle/20240104/default/120000_300/stream.json
··· 1 + {"stream": "default", "prev_day": "20240103", "prev_segment": "110000_300", "seq": 4}
+1
tests/fixtures/sol_grab/journal/chronicle/20240105/default/130000_300/screen.jsonl
··· 1 + {"raw": "screen.webm"}
tests/fixtures/sol_grab/journal/chronicle/20240105/default/130000_300/screen.webm

This is a binary file and will not be displayed.

+1
tests/fixtures/sol_grab/journal/chronicle/20240105/default/130000_300/stream.json
··· 1 + {"stream": "default", "prev_day": "20240104", "prev_segment": "120000_300", "seq": 5}
+4
tests/fixtures/sol_grab/journal/chronicle/20240106/default/140000_300/screen.jsonl
··· 1 + {"raw": "screen.webm"} 2 + {"frame_id": 1, "timestamp": 0.0, "requests": [{"type": "describe", "model": "gemini-2.5-flash-lite", "duration": 0.3}], "analysis": {"visual_description": "Diagnostics page with status rows.", "primary": "reading", "secondary": "none", "overlap": true}} 3 + {"frame_id": 4 + {"frame_id": 2, "timestamp": 1.0, "requests": [{"type": "describe", "model": "gemini-2.5-flash-lite", "duration": 0.3}], "analysis": {"visual_description": "Terminal with fixture output.", "primary": "terminal", "secondary": "none", "overlap": true}}
tests/fixtures/sol_grab/journal/chronicle/20240106/default/140000_300/screen.webm

This is a binary file and will not be displayed.

+1
tests/fixtures/sol_grab/journal/chronicle/20240106/default/140000_300/stream.json
··· 1 + {"stream": "default", "prev_day": "20240105", "prev_segment": "130000_300", "seq": 6}
+21
tests/fixtures/sol_grab/level_0.json
··· 23 23 "segments": 2, 24 24 "screens": 3, 25 25 "frames_analyzed": 6 26 + }, 27 + { 28 + "day": "20240104", 29 + "streams": 1, 30 + "segments": 1, 31 + "screens": 1, 32 + "frames_analyzed": 1 33 + }, 34 + { 35 + "day": "20240105", 36 + "streams": 1, 37 + "segments": 1, 38 + "screens": 1, 39 + "frames_analyzed": 0 40 + }, 41 + { 42 + "day": "20240106", 43 + "streams": 1, 44 + "segments": 1, 45 + "screens": 1, 46 + "frames_analyzed": 2 26 47 } 27 48 ] 28 49 }
+21
tests/fixtures/sol_grab/level_3_purged.json
··· 1 + { 2 + "level": "3", 3 + "scope": { 4 + "day": "20240104", 5 + "stream": "default", 6 + "segment": "120000_300" 7 + }, 8 + "data": { 9 + "screens": [ 10 + { 11 + "screen": "screen", 12 + "position": "unknown", 13 + "connector": "unknown", 14 + "frames_analyzed": 1, 15 + "jsonl": "20240104/default/120000_300/screen.jsonl", 16 + "video": null, 17 + "status": "analyzed; raw media purged by retention" 18 + } 19 + ] 20 + } 21 + }
+2 -1
tests/fixtures/sol_grab/level_4.json
··· 10 10 "summary": { 11 11 "frames_analyzed": 9, 12 12 "error_frames": 1, 13 - "legacy_schema": false 13 + "legacy_schema": false, 14 + "video_present": true 14 15 }, 15 16 "frames": [ 16 17 {
+18
tests/fixtures/sol_grab/level_4_header_only.json
··· 1 + { 2 + "level": "4", 3 + "scope": { 4 + "day": "20240105", 5 + "stream": "default", 6 + "segment": "130000_300", 7 + "screen": "screen" 8 + }, 9 + "data": { 10 + "summary": { 11 + "frames_analyzed": 0, 12 + "error_frames": 0, 13 + "legacy_schema": false, 14 + "video_present": true 15 + }, 16 + "frames": [] 17 + } 18 + }
+26
tests/fixtures/sol_grab/level_4_purged.json
··· 1 + { 2 + "level": "4", 3 + "scope": { 4 + "day": "20240104", 5 + "stream": "default", 6 + "segment": "120000_300", 7 + "screen": "screen" 8 + }, 9 + "data": { 10 + "summary": { 11 + "frames_analyzed": 1, 12 + "error_frames": 0, 13 + "legacy_schema": false, 14 + "video_present": false 15 + }, 16 + "frames": [ 17 + { 18 + "frame_id": 7, 19 + "timestamp": 1.0, 20 + "abs_time": "2024-01-04T12:00:01", 21 + "primary": "productivity", 22 + "notes": "" 23 + } 24 + ] 25 + } 26 + }
+232 -4
tests/test_grab.py
··· 70 70 assert "20240103" in out 71 71 72 72 73 + def test_grab_level_0_human_ends_with_next_footer(monkeypatch, capsys): 74 + code, message, out, err = _invoke_grab(monkeypatch, capsys) 75 + assert code == 0 76 + assert message == "" 77 + assert err == "" 78 + assert out.rstrip().endswith("Next: sol grab <day>") 79 + 80 + 73 81 def test_grab_level_1_json_matches_fixture(monkeypatch, capsys): 74 82 code, message, out, err = _invoke_grab(monkeypatch, capsys, "--json", "20240102") 75 83 assert code == 0 ··· 78 86 assert json.loads(out) == _expected("level_1.json") 79 87 80 88 89 + def test_grab_level_1_human_ends_with_next_footer(monkeypatch, capsys): 90 + code, message, out, err = _invoke_grab(monkeypatch, capsys, "20240102") 91 + assert code == 0 92 + assert message == "" 93 + assert err == "" 94 + assert out.rstrip().endswith("Next: sol grab <day> <stream>") 95 + 96 + 81 97 def test_grab_missing_day_errors(monkeypatch, capsys): 82 98 code, message, out, err = _invoke_grab(monkeypatch, capsys, "20990101") 83 99 assert code == 1 84 100 assert out == "" 85 101 assert err == "" 86 - assert message == "day 20990101 not found" 102 + assert message.startswith("day 20990101 not found\n\n") 103 + assert "Available days (closest 5):\n 20240102" in message 87 104 88 105 89 106 def test_grab_level_2_json_matches_fixture(monkeypatch, capsys): ··· 96 113 assert json.loads(out) == _expected("level_2.json") 97 114 98 115 116 + def test_grab_level_2_human_ends_with_next_footer(monkeypatch, capsys): 117 + code, message, out, err = _invoke_grab(monkeypatch, capsys, "20240103", "default") 118 + assert code == 0 119 + assert message == "" 120 + assert err == "" 121 + assert out.rstrip().endswith("Next: sol grab <day> <stream> <segment>") 122 + 123 + 99 124 def test_grab_missing_stream_errors(monkeypatch, capsys): 100 125 code, message, out, err = _invoke_grab(monkeypatch, capsys, "20240102", "missing") 101 126 assert code == 1 102 127 assert out == "" 103 128 assert err == "" 104 - assert message == "stream missing not found in 20240102" 129 + assert ( 130 + message 131 + == "stream missing not found in 20240102\n\nAvailable streams in 20240102:\n default" 132 + ) 105 133 106 134 107 135 def test_grab_level_3_json_matches_fixture(monkeypatch, capsys): ··· 114 142 assert json.loads(out) == _expected("level_3.json") 115 143 116 144 145 + def test_grab_level_3_purged_json_matches_fixture(monkeypatch, capsys): 146 + code, message, out, err = _invoke_grab( 147 + monkeypatch, capsys, "--json", "20240104", "default", "120000_300" 148 + ) 149 + assert code == 0 150 + assert message == "" 151 + assert err == "" 152 + assert json.loads(out) == _expected("level_3_purged.json") 153 + 154 + 155 + def test_grab_level_3_pins_all_status_strings(monkeypatch, capsys): 156 + statuses = set() 157 + for args in ( 158 + ("20240102", "default", "233000_300"), 159 + ("20240103", "default", "110000_300"), 160 + ("20240104", "default", "120000_300"), 161 + ): 162 + code, message, out, err = _invoke_grab(monkeypatch, capsys, "--json", *args) 163 + assert code == 0 164 + assert message == "" 165 + assert err == "" 166 + payload = json.loads(out) 167 + statuses.update(screen["status"] for screen in payload["data"]["screens"]) 168 + 169 + assert statuses == { 170 + "analyzed", 171 + "analyzed; raw media purged by retention", 172 + "captured but not analyzed", 173 + } 174 + 175 + 117 176 def test_grab_level_3_lists_named_monitors(monkeypatch, capsys): 118 177 code, message, out, err = _invoke_grab( 119 178 monkeypatch, capsys, "--json", "20240103", "default", "100000_300" ··· 130 189 assert payload["data"]["screens"][1]["connector"] == "HDMI-1" 131 190 132 191 192 + def test_grab_level_3_human_ends_with_next_footer(monkeypatch, capsys): 193 + code, message, out, err = _invoke_grab( 194 + monkeypatch, capsys, "20240103", "default", "100000_300" 195 + ) 196 + assert code == 0 197 + assert message == "" 198 + assert err == "" 199 + assert out.rstrip().endswith("Next: sol grab <day> <stream> <segment> <screen>") 200 + 201 + 133 202 def test_grab_missing_segment_errors(monkeypatch, capsys): 134 203 code, message, out, err = _invoke_grab( 135 204 monkeypatch, capsys, "20240103", "default", "999999_300" ··· 137 206 assert code == 1 138 207 assert out == "" 139 208 assert err == "" 140 - assert message == "segment 999999_300 not found in 20240103/default" 209 + assert ( 210 + message == "segment 999999_300 not found in 20240103/default\n\n" 211 + "Available segments in 20240103/default:\n" 212 + " 100000_300\n" 213 + " 110000_300" 214 + ) 141 215 142 216 143 217 def test_grab_level_4_json_matches_fixture(monkeypatch, capsys): ··· 150 224 assert json.loads(out) == _expected("level_4.json") 151 225 152 226 227 + def test_grab_level_4_purged_json_matches_fixture(monkeypatch, capsys): 228 + code, message, out, err = _invoke_grab( 229 + monkeypatch, capsys, "--json", "20240104", "default", "120000_300", "screen" 230 + ) 231 + assert code == 0 232 + assert message == "" 233 + assert err == "" 234 + assert json.loads(out) == _expected("level_4_purged.json") 235 + 236 + 237 + def test_grab_level_4_header_only_json_matches_fixture(monkeypatch, capsys): 238 + code, message, out, err = _invoke_grab( 239 + monkeypatch, capsys, "--json", "20240105", "default", "130000_300", "screen" 240 + ) 241 + assert code == 0 242 + assert message == "" 243 + assert err == "" 244 + assert json.loads(out) == _expected("level_4_header_only.json") 245 + 246 + 153 247 def test_grab_level_4_human_includes_error_notes(monkeypatch, capsys): 154 248 code, message, out, err = _invoke_grab( 155 249 monkeypatch, capsys, "20240102", "default", "233000_300", "screen" ··· 161 255 assert "error: Vision request timed out while describing frame 18." in out 162 256 163 257 258 + def test_grab_level_4_human_includes_extraction_footer(monkeypatch, capsys): 259 + code, message, out, err = _invoke_grab( 260 + monkeypatch, capsys, "20240102", "default", "233000_300", "screen" 261 + ) 262 + assert code == 0 263 + assert message == "" 264 + assert err == "" 265 + assert out.rstrip().endswith( 266 + "Inspect: sol grab <day> <stream> <segment> <screen> <id>\n" 267 + "Save one: sol grab <day> <stream> <segment> <screen> <id> --out PATH\n" 268 + "Save many: sol grab <day> <stream> <segment> <screen> " 269 + "<id1>,<id2>,... --out PATH\n" 270 + "\n" 271 + "How extraction works:\n" 272 + " Decoding walks the video linearly from frame 0 — seeking is unsafe at the\n" 273 + " 1 Hz capture rate. Cost is dominated by the highest requested frame_id, not\n" 274 + " the count. Asking for ids 7,12,23 costs the same as asking for 23 alone.\n" 275 + " Prefer batch mode when you want more than one frame from the same screen." 276 + ) 277 + 278 + 164 279 def test_grab_level_4_legacy_schema_reports_zero_frames(monkeypatch, capsys): 165 280 code, message, out, err = _invoke_grab( 166 281 monkeypatch, capsys, "20240101", "default", "123456_300", "screen" ··· 171 286 assert out.strip() == "0 frames analyzed: file uses pre-frame_id schema" 172 287 173 288 289 + def test_grab_level_4_header_only_reports_no_qualified_frames(monkeypatch, capsys): 290 + code, message, out, err = _invoke_grab( 291 + monkeypatch, capsys, "20240105", "default", "130000_300", "screen" 292 + ) 293 + assert code == 0 294 + assert message == "" 295 + assert err == "" 296 + assert out == "No qualified frames in this screen's analysis.\n" 297 + 298 + 299 + def test_grab_level_4_legacy_and_header_only_are_distinct(monkeypatch, capsys): 300 + code, message, out, err = _invoke_grab( 301 + monkeypatch, capsys, "--json", "20240101", "default", "123456_300", "screen" 302 + ) 303 + assert code == 0 304 + assert message == "" 305 + assert err == "" 306 + assert json.loads(out)["data"]["summary"]["legacy_schema"] is True 307 + 308 + code, message, out, err = _invoke_grab( 309 + monkeypatch, capsys, "--json", "20240105", "default", "130000_300", "screen" 310 + ) 311 + assert code == 0 312 + assert message == "" 313 + assert err == "" 314 + payload = json.loads(out) 315 + assert payload["data"]["summary"]["frames_analyzed"] == 0 316 + assert payload["data"]["frames"] == [] 317 + assert payload["data"]["summary"]["legacy_schema"] is False 318 + 319 + 320 + def test_grab_level_4_purged_human_uses_metadata_only_footer(monkeypatch, capsys): 321 + code, message, out, err = _invoke_grab( 322 + monkeypatch, capsys, "20240104", "default", "120000_300", "screen" 323 + ) 324 + assert code == 0 325 + assert message == "" 326 + assert err == "" 327 + assert "--out PATH" not in out 328 + assert out.rstrip().endswith( 329 + "Save mode unavailable: raw video has been purged by retention.\n" 330 + "Frame metadata above is still readable.\n" 331 + "\n" 332 + "Inspect: sol grab <day> <stream> <segment> <screen> <id>" 333 + ) 334 + 335 + 174 336 def test_grab_level_4_captured_but_not_analyzed_errors(monkeypatch, capsys): 175 337 code, message, out, err = _invoke_grab( 176 338 monkeypatch, capsys, "20240103", "default", "110000_300", "screen" ··· 188 350 assert code == 1 189 351 assert out == "" 190 352 assert err == "" 191 - assert message == "screen missing_screen not found in 20240102/default/233000_300" 353 + assert ( 354 + message == "screen missing_screen not found in 20240102/default/233000_300\n\n" 355 + "Available screens in 20240102/default/233000_300:\n" 356 + " screen" 357 + ) 192 358 193 359 194 360 def test_grab_level_5a_json_matches_fixture(monkeypatch, capsys): ··· 219 385 assert '"frame_id": 7' in out 220 386 221 387 388 + def test_grab_level_5a_human_shows_save_and_batch_footer(monkeypatch, capsys): 389 + code, message, out, err = _invoke_grab( 390 + monkeypatch, capsys, "20240102", "default", "233000_300", "screen", "7" 391 + ) 392 + assert code == 0 393 + assert message == "" 394 + assert err == "" 395 + assert out.rstrip().endswith( 396 + "Save: sol grab <day> <stream> <segment> <screen> <id> --out PATH\n" 397 + "Batch: sol grab <day> <stream> <segment> <screen> " 398 + "<id1>,<id2>,... --out PATH" 399 + ) 400 + 401 + 402 + def test_grab_json_outputs_do_not_include_footer_prose(monkeypatch, capsys): 403 + code, message, out, err = _invoke_grab(monkeypatch, capsys, "--json") 404 + assert code == 0 405 + assert message == "" 406 + assert err == "" 407 + assert "Next:" not in out 408 + assert "Save:" not in out 409 + assert "How extraction works:" not in out 410 + 411 + 222 412 def test_grab_level_5a_legacy_schema_errors(monkeypatch, capsys): 223 413 code, message, out, err = _invoke_grab( 224 414 monkeypatch, capsys, "20240101", "default", "123456_300", "screen", "1" ··· 240 430 assert out == "" 241 431 assert err == "" 242 432 assert message == "frame id 999 not found in screen for 233000_300" 433 + 434 + 435 + def test_grab_save_purged_video_reports_retention_message( 436 + monkeypatch, capsys, tmp_path 437 + ): 438 + code, message, out, err = _invoke_grab( 439 + monkeypatch, 440 + capsys, 441 + "--out", 442 + str(tmp_path / "frame.png"), 443 + "20240104", 444 + "default", 445 + "120000_300", 446 + "screen", 447 + "7", 448 + ) 449 + assert code == 1 450 + assert out == "" 451 + assert err == "" 452 + assert "purged by retention" in message 453 + assert "sol grab 20240104 default 120000_300 screen 7" in message 243 454 244 455 245 456 def test_grab_level_5b_json_matches_fixture_and_writes_png( ··· 443 654 assert message == "" 444 655 assert out == "" 445 656 assert "--out requires day stream segment screen and frame-id" in err 657 + 658 + 659 + def test_grab_malformed_jsonl_quiet_by_default(monkeypatch, capsys, caplog): 660 + code, message, out, err = _invoke_grab(monkeypatch, capsys) 661 + assert code == 0 662 + assert message == "" 663 + assert "20240106" in out 664 + assert "WARNING:observe.utils:" not in err 665 + assert "Invalid JSON" not in caplog.text 666 + 667 + 668 + def test_grab_malformed_jsonl_warns_with_verbose(monkeypatch, capsys, caplog): 669 + code, message, out, err = _invoke_grab(monkeypatch, capsys, "-v") 670 + assert code == 0 671 + assert message == "" 672 + assert "20240106" in out 673 + assert "Invalid JSON" in caplog.text 446 674 447 675 448 676 @pytest.mark.parametrize("token", ["0", "-1", "abc", "7,7", "1,,2"])