open-research-protocol 0.4.10 → 0.4.12

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.
package/README.md CHANGED
@@ -147,7 +147,9 @@ orp pack fetch --source <git-url> --pack-id <pack-id> --install-target . --json
147
147
  orp gate run --profile default --json
148
148
  orp packet emit --profile default --json
149
149
  orp compute decide --input orp.compute.json --json
150
+ orp compute decide --project-map orp.compute-map.json --point-id adult-vs-developmental-rgc-opponent --json
150
151
  orp compute run-local --input orp.compute.json --task orp.compute.task.json --json
152
+ orp compute run-local --project-map orp.compute-map.json --point-id adult-vs-developmental-rgc-opponent --task orp.compute.task.json --json
151
153
  orp report summary --json
152
154
  ```
153
155
 
@@ -157,6 +159,7 @@ These surfaces are meant to help automated systems discover ORP quickly:
157
159
  - `orp home --json` returns the same landing context in machine-readable form
158
160
  - `orp auth ...`, `orp ideas ...`, `orp world ...`, `orp checkpoint ...`, `orp runner ...`, and `orp agent ...` expose the hosted workspace surface directly through ORP
159
161
  - `orp compute ...` exposes targeted-compute admission, local execution, and paid-approval gating through a stable ORP wrapper surface
162
+ - `orp compute ...` can now consume either a raw compute packet input or a repo-declared `breakthroughs` project compute map plus a compute-point id
160
163
  - `orp youtube inspect ...` exposes public YouTube metadata plus full transcript ingestion through a stable ORP artifact shape for agent use when caption tracks are available
161
164
  - `orp init`, `orp status`, `orp branch start`, `orp checkpoint create`, `orp backup`, `orp ready`, `orp doctor`, and `orp cleanup` expose the local-first repo governance surface directly through ORP
162
165
  - `orp discover ...` exposes profile-based GitHub scanning as a built-in ORP ability
@@ -4,12 +4,14 @@ import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
6
6
  import {
7
+ buildComputePointDecisionPacket,
7
8
  buildOrpComputeGateResult,
8
9
  buildOrpComputePacket,
9
10
  defineComputePacket,
10
11
  defineDecision,
11
12
  defineImpactRead,
12
13
  definePolicy,
14
+ defineProjectComputeMap,
13
15
  defineResultBundle,
14
16
  defineRung,
15
17
  evaluateDispatch,
@@ -21,7 +23,9 @@ function printHelp() {
21
23
 
22
24
  Usage:
23
25
  orp compute decide --input <path> [--packet-out <path>] [--json]
26
+ orp compute decide --project-map <path> --point-id <id> [--rung-id <id>] [--success-bar <path>] [--packet-out <path>] [--json]
24
27
  orp compute run-local --input <path> --task <path> [--receipt-out <path>] [--packet-out <path>] [--json]
28
+ orp compute run-local --project-map <path> --point-id <id> --task <path> [--rung-id <id>] [--success-bar <path>] [--receipt-out <path>] [--packet-out <path>] [--json]
25
29
 
26
30
  Input JSON shape:
27
31
  {
@@ -40,6 +44,22 @@ Input JSON shape:
40
44
  }
41
45
  }
42
46
 
47
+ Project-map mode:
48
+ {
49
+ "projectId": "longevity-controller",
50
+ "repoRoots": ["/abs/path"],
51
+ "rungs": [...],
52
+ "defaultPolicy": {...},
53
+ "computePoints": [...]
54
+ }
55
+
56
+ Project-map mode options:
57
+ - --project-map <path> points to a repo compute catalog
58
+ - --point-id <id> selects the compute point
59
+ - --rung-id <id> optionally overrides the point default rung
60
+ - --success-bar <path> optionally points to a JSON object merged into the packet success bar
61
+ - repo/orp context is derived from the project map unless overridden with --repo-root, --board-id, --problem-id, or --artifact-root
62
+
43
63
  Task JSON shape for run-local:
44
64
  {
45
65
  "command": "node",
@@ -116,11 +136,71 @@ function buildContext(raw) {
116
136
  };
117
137
  }
118
138
 
139
+ async function loadContext(options) {
140
+ if (options.input && options.projectMap) {
141
+ throw new Error("use either --input or --project-map, not both");
142
+ }
143
+
144
+ if (options.projectMap) {
145
+ if (!options.pointId) {
146
+ throw new Error("project-map mode requires --point-id <id>");
147
+ }
148
+
149
+ const projectMap = defineProjectComputeMap(await readJson(options.projectMap));
150
+ const successBar = options.successBar
151
+ ? await readJson(options.successBar)
152
+ : undefined;
153
+ const template = buildComputePointDecisionPacket({
154
+ projectComputeMap: projectMap,
155
+ pointId: options.pointId,
156
+ rungId: options.rungId,
157
+ successBar,
158
+ });
159
+
160
+ return {
161
+ raw: {
162
+ projectMap,
163
+ repo: {
164
+ rootPath: options.repoRoot || projectMap.repoRoots[0] || process.cwd(),
165
+ },
166
+ orp: {
167
+ boardId: options.boardId || "targeted_compute",
168
+ problemId: options.problemId || template.computePoint.id,
169
+ artifactRoot:
170
+ options.artifactRoot ||
171
+ `orp/artifacts/compute/${template.computePoint.id}`,
172
+ },
173
+ },
174
+ projectMap,
175
+ computePoint: template.computePoint,
176
+ decision: template.decision,
177
+ rung: template.rung,
178
+ policy: template.policy,
179
+ packet: template.packet,
180
+ };
181
+ }
182
+
183
+ if (!options.input) {
184
+ throw new Error("compute command requires --input <path> or --project-map <path>");
185
+ }
186
+
187
+ return buildContext(await readJson(options.input));
188
+ }
189
+
119
190
  function commandLabel(subcommand, options) {
120
191
  const parts = ["orp", "compute", subcommand];
121
192
  if (options.input) {
122
193
  parts.push("--input", options.input);
123
194
  }
195
+ if (options.projectMap) {
196
+ parts.push("--project-map", options.projectMap);
197
+ }
198
+ if (options.pointId) {
199
+ parts.push("--point-id", options.pointId);
200
+ }
201
+ if (options.rungId) {
202
+ parts.push("--rung-id", options.rungId);
203
+ }
124
204
  if (options.task) {
125
205
  parts.push("--task", options.task);
126
206
  }
@@ -148,11 +228,7 @@ function summarizeDispatch(dispatchResult) {
148
228
  }
149
229
 
150
230
  async function runDecide(options) {
151
- if (!options.input) {
152
- throw new Error("compute decide requires --input <path>");
153
- }
154
-
155
- const context = buildContext(await readJson(options.input));
231
+ const context = await loadContext(options);
156
232
  const dispatchResult = evaluateDispatch(context);
157
233
  const gateResult = buildOrpComputeGateResult({
158
234
  gateId: context.packet.rungId,
@@ -195,14 +271,11 @@ async function runDecide(options) {
195
271
  }
196
272
 
197
273
  async function runLocal(options) {
198
- if (!options.input) {
199
- throw new Error("compute run-local requires --input <path>");
200
- }
201
274
  if (!options.task) {
202
275
  throw new Error("compute run-local requires --task <path>");
203
276
  }
204
277
 
205
- const context = buildContext(await readJson(options.input));
278
+ const context = await loadContext(options);
206
279
  const task = await readJson(options.task);
207
280
  const dispatchResult = evaluateDispatch(context);
208
281
 
package/cli/orp.py CHANGED
@@ -115,6 +115,10 @@ DEFAULT_DISCOVER_SCAN_ROOT = "orp/discovery/github"
115
115
  DEFAULT_HOSTED_BASE_URL = "https://orp.earth"
116
116
  KERNEL_SCHEMA_VERSION = "1.0.0"
117
117
  YOUTUBE_SOURCE_SCHEMA_VERSION = "1.0.0"
118
+ YOUTUBE_ANDROID_CLIENT_VERSION = "20.10.38"
119
+ YOUTUBE_ANDROID_USER_AGENT = (
120
+ f"com.google.android.youtube/{YOUTUBE_ANDROID_CLIENT_VERSION} (Linux; U; Android 14)"
121
+ )
118
122
 
119
123
 
120
124
  class HostedApiError(RuntimeError):
@@ -362,6 +366,35 @@ def _http_get_json(url: str, *, headers: dict[str, str] | None = None, timeout_s
362
366
  raise RuntimeError(f"Response from {url} was not a JSON object.")
363
367
 
364
368
 
369
+ def _http_post_json(
370
+ url: str,
371
+ payload: dict[str, Any],
372
+ *,
373
+ headers: dict[str, str] | None = None,
374
+ timeout_sec: int = 20,
375
+ ) -> dict[str, Any]:
376
+ body = json.dumps(payload).encode("utf-8")
377
+ merged_headers = {"Content-Type": "application/json"}
378
+ if headers:
379
+ merged_headers.update(headers)
380
+ request = urlrequest.Request(url, data=body, headers=merged_headers, method="POST")
381
+ try:
382
+ with urlrequest.urlopen(request, timeout=timeout_sec) as response:
383
+ text = response.read().decode("utf-8", errors="replace")
384
+ except urlerror.HTTPError as exc:
385
+ body_text = exc.read().decode("utf-8", errors="replace").strip()
386
+ raise RuntimeError(f"HTTP {exc.code} while fetching {url}: {body_text or exc.reason}") from exc
387
+ except urlerror.URLError as exc:
388
+ raise RuntimeError(f"Could not reach {url}: {exc.reason}") from exc
389
+ try:
390
+ parsed = json.loads(text)
391
+ except Exception as exc:
392
+ raise RuntimeError(f"Response from {url} was not valid JSON.") from exc
393
+ if isinstance(parsed, dict):
394
+ return parsed
395
+ raise RuntimeError(f"Response from {url} was not a JSON object.")
396
+
397
+
365
398
  def _youtube_request_headers() -> dict[str, str]:
366
399
  return {
367
400
  "User-Agent": (
@@ -372,6 +405,13 @@ def _youtube_request_headers() -> dict[str, str]:
372
405
  }
373
406
 
374
407
 
408
+ def _youtube_android_request_headers() -> dict[str, str]:
409
+ return {
410
+ "User-Agent": YOUTUBE_ANDROID_USER_AGENT,
411
+ "Accept-Language": "en-US,en;q=0.9",
412
+ }
413
+
414
+
375
415
  def _youtube_source_schema_path() -> Path:
376
416
  return Path(__file__).resolve().parent.parent / "spec" / "v1" / "youtube-source.schema.json"
377
417
 
@@ -459,21 +499,52 @@ def _youtube_track_label(track: dict[str, Any]) -> str:
459
499
  return str(track.get("languageCode", "")).strip()
460
500
 
461
501
 
462
- def _pick_youtube_caption_track(tracks: list[dict[str, Any]], preferred_lang: str = "") -> dict[str, Any] | None:
463
- if not tracks:
464
- return None
502
+ def _youtube_track_source(track: dict[str, Any]) -> str:
503
+ return str(track.get("_orp_source", "") or "unknown").strip()
504
+
505
+
506
+ def _youtube_track_inventory(tracks: list[dict[str, Any]]) -> list[dict[str, Any]]:
507
+ inventory: list[dict[str, Any]] = []
508
+ seen: set[tuple[str, str, str, str]] = set()
509
+ for track in tracks:
510
+ if not isinstance(track, dict):
511
+ continue
512
+ language_code = str(track.get("languageCode", "")).strip()
513
+ label = _youtube_track_label(track)
514
+ kind = "auto" if str(track.get("kind", "")).strip().lower() == "asr" else "manual"
515
+ source = _youtube_track_source(track)
516
+ key = (language_code, label, kind, source)
517
+ if key in seen:
518
+ continue
519
+ seen.add(key)
520
+ inventory.append(
521
+ {
522
+ "language_code": language_code,
523
+ "name": label,
524
+ "kind": kind,
525
+ "source": source,
526
+ }
527
+ )
528
+ return inventory
529
+
530
+
531
+ def _youtube_caption_track_sort_key(track: dict[str, Any], preferred_lang: str = "") -> tuple[int, int]:
465
532
  preferred = str(preferred_lang or "").strip().lower()
533
+ code = str(track.get("languageCode", "")).strip().lower()
534
+ kind = str(track.get("kind", "")).strip().lower()
535
+ auto = 1 if kind == "asr" else 0
536
+ source = _youtube_track_source(track)
537
+ source_bias = 15 if source == "android_player" else 0
538
+ exact = 1 if preferred and code == preferred else 0
539
+ prefix = 1 if preferred and code.startswith(preferred + "-") else 0
540
+ english = 1 if code.startswith("en") else 0
541
+ return (exact * 100 + prefix * 80 + english * 20 + source_bias - auto * 5, -auto)
466
542
 
467
- def score(track: dict[str, Any]) -> tuple[int, int]:
468
- code = str(track.get("languageCode", "")).strip().lower()
469
- kind = str(track.get("kind", "")).strip().lower()
470
- auto = 1 if kind == "asr" else 0
471
- exact = 1 if preferred and code == preferred else 0
472
- prefix = 1 if preferred and code.startswith(preferred + "-") else 0
473
- english = 1 if code.startswith("en") else 0
474
- return (exact * 100 + prefix * 80 + english * 20 - auto * 5, -auto)
475
543
 
476
- ranked = sorted(tracks, key=score, reverse=True)
544
+ def _pick_youtube_caption_track(tracks: list[dict[str, Any]], preferred_lang: str = "") -> dict[str, Any] | None:
545
+ if not tracks:
546
+ return None
547
+ ranked = sorted(tracks, key=lambda track: _youtube_caption_track_sort_key(track, preferred_lang), reverse=True)
477
548
  return ranked[0] if ranked else None
478
549
 
479
550
 
@@ -544,6 +615,19 @@ def _parse_youtube_transcript_xml(text: str) -> tuple[str, list[dict[str, Any]]]
544
615
  "text": body,
545
616
  }
546
617
  )
618
+ if not segments:
619
+ for node in root.findall(".//p"):
620
+ body = html.unescape("".join(node.itertext() or []))
621
+ body = re.sub(r"\s+", " ", body).strip()
622
+ if not body:
623
+ continue
624
+ segments.append(
625
+ {
626
+ "start_ms": int(node.attrib.get("t", "0") or "0"),
627
+ "duration_ms": int(node.attrib.get("d", "0") or "0"),
628
+ "text": body,
629
+ }
630
+ )
547
631
  transcript_text = "\n".join(str(row["text"]) for row in segments)
548
632
  return transcript_text, segments
549
633
 
@@ -577,6 +661,8 @@ def _youtube_fetch_watch_state(video_id: str) -> dict[str, Any]:
577
661
  .get("playerCaptionsTracklistRenderer", {})
578
662
  .get("captionTracks", [])
579
663
  )
664
+ tracks = captions if isinstance(captions, list) else []
665
+ normalized_tracks = [{**row, "_orp_source": "watch_page"} for row in tracks if isinstance(row, dict)]
580
666
  return {
581
667
  "player_response": player_response,
582
668
  "video_details": player_response.get("videoDetails", {}) if isinstance(player_response.get("videoDetails"), dict) else {},
@@ -590,29 +676,110 @@ def _youtube_fetch_watch_state(video_id: str) -> dict[str, Any]:
590
676
  if isinstance(player_response.get("playabilityStatus"), dict)
591
677
  else {}
592
678
  ),
593
- "caption_tracks": captions if isinstance(captions, list) else [],
679
+ "caption_tracks": normalized_tracks,
680
+ }
681
+
682
+
683
+ def _youtube_fetch_android_player_state(video_id: str) -> dict[str, Any]:
684
+ payload = _http_post_json(
685
+ "https://www.youtube.com/youtubei/v1/player?prettyPrint=false",
686
+ {
687
+ "context": {
688
+ "client": {
689
+ "clientName": "ANDROID",
690
+ "clientVersion": YOUTUBE_ANDROID_CLIENT_VERSION,
691
+ }
692
+ },
693
+ "videoId": video_id,
694
+ },
695
+ headers=_youtube_android_request_headers(),
696
+ timeout_sec=25,
697
+ )
698
+ captions = (
699
+ payload.get("captions", {})
700
+ .get("playerCaptionsTracklistRenderer", {})
701
+ .get("captionTracks", [])
702
+ )
703
+ tracks = captions if isinstance(captions, list) else []
704
+ normalized_tracks = [{**row, "_orp_source": "android_player"} for row in tracks if isinstance(row, dict)]
705
+ return {
706
+ "player_response": payload,
707
+ "video_details": payload.get("videoDetails", {}) if isinstance(payload.get("videoDetails"), dict) else {},
708
+ "microformat": {},
709
+ "playability_status": payload.get("playabilityStatus", {}) if isinstance(payload.get("playabilityStatus"), dict) else {},
710
+ "caption_tracks": normalized_tracks,
594
711
  }
595
712
 
596
713
 
714
+ def _youtube_ranked_caption_tracks(
715
+ watch_tracks: list[dict[str, Any]],
716
+ android_tracks: list[dict[str, Any]],
717
+ preferred_lang: str = "",
718
+ ) -> list[dict[str, Any]]:
719
+ ranked = sorted(
720
+ [track for track in android_tracks if isinstance(track, dict)]
721
+ + [track for track in watch_tracks if isinstance(track, dict)],
722
+ key=lambda track: _youtube_caption_track_sort_key(track, preferred_lang),
723
+ reverse=True,
724
+ )
725
+ unique: list[dict[str, Any]] = []
726
+ seen: set[tuple[str, str, str, str]] = set()
727
+ for track in ranked:
728
+ key = (
729
+ str(track.get("languageCode", "")).strip(),
730
+ _youtube_track_label(track),
731
+ str(track.get("kind", "")).strip().lower(),
732
+ _youtube_track_source(track),
733
+ )
734
+ if key in seen:
735
+ continue
736
+ seen.add(key)
737
+ unique.append(track)
738
+ return unique
739
+
740
+
741
+ def _youtube_parse_transcript_response(text: str) -> tuple[str, list[dict[str, Any]], str]:
742
+ stripped = str(text or "").lstrip()
743
+ if not stripped:
744
+ return ("", [], "empty")
745
+ if stripped.startswith("{"):
746
+ try:
747
+ payload = json.loads(text)
748
+ except Exception:
749
+ payload = None
750
+ if isinstance(payload, dict):
751
+ transcript_text, segments = _parse_youtube_transcript_json3(payload)
752
+ if transcript_text:
753
+ return (transcript_text, segments, "json3")
754
+ transcript_text, segments = _parse_youtube_transcript_xml(text)
755
+ if transcript_text:
756
+ return (transcript_text, segments, "xml")
757
+ return ("", [], "unparsed")
758
+
759
+
597
760
  def _youtube_fetch_transcript_from_track(track: dict[str, Any]) -> tuple[str, list[dict[str, Any]], str]:
598
761
  base_url = str(track.get("baseUrl", "")).strip()
599
762
  if not base_url:
600
763
  return ("", [], "missing_track_url")
601
- json3_url = _youtube_add_query_param(base_url, "fmt", "json3")
602
- try:
603
- payload = _http_get_json(json3_url, headers=_youtube_request_headers(), timeout_sec=25)
604
- transcript_text, segments = _parse_youtube_transcript_json3(payload)
605
- if transcript_text:
606
- return transcript_text, segments, "json3"
607
- except Exception:
608
- pass
609
- try:
610
- xml_text = _http_get_text(base_url, headers=_youtube_request_headers(), timeout_sec=25)
611
- transcript_text, segments = _parse_youtube_transcript_xml(xml_text)
764
+ source = _youtube_track_source(track) or "unknown"
765
+ candidate_urls = [
766
+ ("base", base_url),
767
+ ("json3", _youtube_add_query_param(base_url, "fmt", "json3")),
768
+ ("srv3", _youtube_add_query_param(base_url, "fmt", "srv3")),
769
+ ]
770
+ seen_urls: set[str] = set()
771
+ for mode, candidate_url in candidate_urls:
772
+ if candidate_url in seen_urls:
773
+ continue
774
+ seen_urls.add(candidate_url)
775
+ try:
776
+ response_text = _http_get_text(candidate_url, headers=_youtube_request_headers(), timeout_sec=25)
777
+ except Exception:
778
+ continue
779
+ transcript_text, segments, parsed_mode = _youtube_parse_transcript_response(response_text)
612
780
  if transcript_text:
613
- return transcript_text, segments, "xml"
614
- except Exception:
615
- pass
781
+ final_mode = parsed_mode if mode == "base" else f"{mode}_{parsed_mode}"
782
+ return transcript_text, segments, f"{source}_{final_mode}"
616
783
  return ("", [], "unavailable")
617
784
 
618
785
 
@@ -647,28 +814,61 @@ def _youtube_inspect_payload(raw_url: str, preferred_lang: str = "") -> dict[str
647
814
  watch_state = _youtube_fetch_watch_state(video_id)
648
815
  except Exception as exc:
649
816
  warnings.append(str(exc))
817
+ android_state: dict[str, Any] = {}
818
+ try:
819
+ android_state = _youtube_fetch_android_player_state(video_id)
820
+ except Exception as exc:
821
+ warnings.append(str(exc))
650
822
 
651
- video_details = watch_state.get("video_details", {}) if isinstance(watch_state.get("video_details"), dict) else {}
823
+ watch_video_details = watch_state.get("video_details", {}) if isinstance(watch_state.get("video_details"), dict) else {}
824
+ android_video_details = (
825
+ android_state.get("video_details", {}) if isinstance(android_state.get("video_details"), dict) else {}
826
+ )
827
+ video_details = watch_video_details or android_video_details
652
828
  microformat = watch_state.get("microformat", {}) if isinstance(watch_state.get("microformat"), dict) else {}
653
829
  playability = watch_state.get("playability_status", {}) if isinstance(watch_state.get("playability_status"), dict) else {}
654
- tracks = [row for row in watch_state.get("caption_tracks", []) if isinstance(row, dict)]
655
- chosen_track = _pick_youtube_caption_track(tracks, preferred_lang)
830
+ if not playability:
831
+ playability = android_state.get("playability_status", {}) if isinstance(android_state.get("playability_status"), dict) else {}
832
+ watch_tracks = [row for row in watch_state.get("caption_tracks", []) if isinstance(row, dict)]
833
+ android_tracks = [row for row in android_state.get("caption_tracks", []) if isinstance(row, dict)]
834
+ tracks = _youtube_ranked_caption_tracks(watch_tracks, android_tracks, preferred_lang)
835
+ available_tracks = _youtube_track_inventory(tracks)
656
836
  transcript_text = ""
657
837
  transcript_segments: list[dict[str, Any]] = []
658
838
  transcript_fetch_mode = "none"
659
839
  transcript_available = False
660
840
  transcript_language = ""
661
841
  transcript_track_name = ""
842
+ transcript_track_source = ""
662
843
  transcript_kind = "none"
844
+ transcript_sources_tried: list[str] = []
845
+ chosen_track: dict[str, Any] | None = None
846
+ for candidate in tracks:
847
+ transcript_sources_tried.append(
848
+ ":".join(
849
+ part
850
+ for part in [
851
+ _youtube_track_source(candidate),
852
+ str(candidate.get("languageCode", "")).strip(),
853
+ _youtube_track_label(candidate),
854
+ ]
855
+ if part
856
+ )
857
+ )
858
+ transcript_text, transcript_segments, transcript_fetch_mode = _youtube_fetch_transcript_from_track(candidate)
859
+ if transcript_text.strip():
860
+ transcript_available = True
861
+ chosen_track = candidate
862
+ break
663
863
  if chosen_track is not None:
664
- transcript_text, transcript_segments, transcript_fetch_mode = _youtube_fetch_transcript_from_track(chosen_track)
665
- transcript_available = bool(transcript_text.strip())
666
864
  transcript_language = str(chosen_track.get("languageCode", "")).strip()
667
865
  transcript_track_name = _youtube_track_label(chosen_track)
866
+ transcript_track_source = _youtube_track_source(chosen_track)
668
867
  transcript_kind = "auto" if str(chosen_track.get("kind", "")).strip().lower() == "asr" else "manual"
868
+ if tracks:
669
869
  if not transcript_available:
670
870
  warnings.append("A caption track was found, but transcript text could not be fetched.")
671
- elif watch_state:
871
+ elif watch_state or android_state:
672
872
  warnings.append("No caption tracks were available for this video.")
673
873
 
674
874
  title = str(video_details.get("title") or oembed.get("title") or "").strip()
@@ -698,13 +898,17 @@ def _youtube_inspect_payload(raw_url: str, preferred_lang: str = "") -> dict[str
698
898
  "duration_seconds": duration_seconds or None,
699
899
  "published_at": published_at,
700
900
  "playability_status": str(playability.get("status", "")).strip(),
901
+ "transcript_track_count": len(available_tracks),
902
+ "available_transcript_tracks": available_tracks,
701
903
  "transcript_available": transcript_available,
702
904
  "transcript_language": transcript_language,
703
905
  "transcript_track_name": transcript_track_name,
906
+ "transcript_track_source": transcript_track_source,
704
907
  "transcript_kind": transcript_kind,
705
908
  "transcript_fetch_mode": transcript_fetch_mode,
706
909
  "transcript_text": transcript_text,
707
910
  "transcript_segments": transcript_segments,
911
+ "transcript_sources_tried": transcript_sources_tried,
708
912
  "warnings": _unique_strings(warnings),
709
913
  }
710
914
  payload["text_bundle"] = _youtube_text_bundle(payload)
@@ -754,8 +958,10 @@ def cmd_youtube_inspect(args: argparse.Namespace) -> int:
754
958
  ("video.title", str(payload.get("title", "")).strip()),
755
959
  ("video.author", str(payload.get("author_name", "")).strip()),
756
960
  ("video.duration_seconds", payload.get("duration_seconds") or ""),
961
+ ("transcript.track_count", payload.get("transcript_track_count") or 0),
757
962
  ("transcript.available", str(bool(payload.get("transcript_available", False))).lower()),
758
963
  ("transcript.language", str(payload.get("transcript_language", "")).strip()),
964
+ ("transcript.track_source", str(payload.get("transcript_track_source", "")).strip()),
759
965
  ("transcript.kind", str(payload.get("transcript_kind", "")).strip()),
760
966
  ("saved", str(bool(out_path is not None)).lower()),
761
967
  ("path", _path_for_state(out_path, repo_root) if out_path is not None else ""),
@@ -5774,7 +5980,7 @@ def _about_payload() -> dict[str, Any]:
5774
5980
  "Default CLI output is human-readable; listed commands with json_output=true also support --json.",
5775
5981
  "Reasoning-kernel artifacts shape promotable repository truth for tasks, decisions, hypotheses, experiments, checkpoints, policies, and results.",
5776
5982
  "Kernel evolution in ORP should stay explicit: observe real usage, propose changes, and migrate artifacts through versioned CLI surfaces rather than silent agent mutation.",
5777
- "YouTube inspection is a built-in ORP ability exposed through `orp youtube inspect`, returning public metadata and caption transcript text when available.",
5983
+ "YouTube inspection is a built-in ORP ability exposed through `orp youtube inspect`, returning public metadata plus full transcript text and segments whenever public caption tracks are available.",
5778
5984
  "Discovery profiles in ORP are portable search-intent files managed directly by ORP.",
5779
5985
  "Collaboration is a built-in ORP ability exposed through `orp collaborate ...`.",
5780
5986
  "Project/session linking is a built-in ORP ability exposed through `orp link ...` and stored machine-locally under `.git/orp/link/`.",
@@ -5885,7 +6091,7 @@ def _home_payload(repo_root: Path, config_arg: str) -> dict[str, Any]:
5885
6091
  "command": "orp whoami --json",
5886
6092
  },
5887
6093
  {
5888
- "label": "Inspect a YouTube video and public transcript for agent context",
6094
+ "label": "Inspect a YouTube video and ingest full public transcript context",
5889
6095
  "command": "orp youtube inspect https://www.youtube.com/watch?v=<video_id> --json",
5890
6096
  },
5891
6097
  {
@@ -12715,7 +12921,7 @@ def build_parser() -> argparse.ArgumentParser:
12715
12921
 
12716
12922
  s_youtube_inspect = youtube_sub.add_parser(
12717
12923
  "inspect",
12718
- help="Inspect a YouTube video and fetch public metadata plus transcript text when captions are available",
12924
+ help="Inspect a YouTube video and fetch public metadata plus full transcript text and segments when caption tracks are available",
12719
12925
  )
12720
12926
  s_youtube_inspect.add_argument("url", help="YouTube watch/share URL or 11-character video id")
12721
12927
  s_youtube_inspect.add_argument(
@@ -6,7 +6,7 @@ YouTube videos.
6
6
  It gives agents and users a stable way to turn a YouTube link into:
7
7
 
8
8
  - normalized video metadata,
9
- - public caption transcript text when available,
9
+ - full public transcript text and segment timing when caption tracks are available,
10
10
  - segment-level timing rows,
11
11
  - and one agent-friendly `text_bundle` field that can be handed directly into
12
12
  summarization, extraction, comparison, or kernel-shaped artifact creation.
@@ -61,13 +61,17 @@ The command returns:
61
61
  - `published_at`
62
62
  - `playability_status`
63
63
  - transcript fields:
64
+ - `transcript_track_count`
65
+ - `available_transcript_tracks`
64
66
  - `transcript_available`
65
67
  - `transcript_language`
66
68
  - `transcript_track_name`
69
+ - `transcript_track_source`
67
70
  - `transcript_kind`
68
71
  - `transcript_fetch_mode`
69
72
  - `transcript_text`
70
73
  - `transcript_segments`
74
+ - `transcript_sources_tried`
71
75
  - agent-ready bundle:
72
76
  - `text_bundle`
73
77
  - capture notes:
@@ -89,6 +93,11 @@ discipline while staying outside the evidence boundary by default.
89
93
  `orp youtube inspect` returns public source context. It does **not** make the
90
94
  result canonical evidence by itself.
91
95
 
96
+ When public caption tracks exist, ORP now attempts full transcript ingestion
97
+ across multiple retrieval strategies and records which track/source succeeded.
98
+ If a video has no accessible caption tracks, ORP reports that honestly instead
99
+ of silently fabricating a transcript.
100
+
92
101
  If a video matters for repo truth, the agent should still:
93
102
 
94
103
  1. inspect the video,
package/llms.txt CHANGED
@@ -13,7 +13,7 @@ ORP (Open Research Protocol) is a docs-first, local-first, agent-friendly protoc
13
13
  ## Fast Machine Discovery
14
14
 
15
15
  - Run `orp about --json` for machine-readable tool metadata, artifact paths, schemas, supported commands, and bundled packs.
16
- - Run `orp youtube inspect <youtube-url> --json` to normalize a public YouTube video into ORP's source artifact shape, including transcript text when public captions are fetchable.
16
+ - Run `orp youtube inspect <youtube-url> --json` to normalize a public YouTube video into ORP's source artifact shape, including full transcript text and timing segments when public caption tracks are available.
17
17
  - Run `orp erdos sync --json` for machine-readable Erdos catalog sync results.
18
18
  - Run `orp pack list --json` for machine-readable bundled pack inventory.
19
19
  - Core runtime commands also support `--json`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-research-protocol",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "ORP CLI (Open Research Protocol): agent-friendly research workflows, runtime, reports, and pack tooling.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=18"
37
37
  },
38
38
  "dependencies": {
39
- "breakthroughs": "^0.1.0"
39
+ "breakthroughs": "^0.1.1"
40
40
  },
41
41
  "scripts": {
42
42
  "postinstall": "node scripts/npm-postinstall-check.js",
@@ -202,7 +202,7 @@ def _benchmark_init_starter(iterations: int) -> dict[str, Any]:
202
202
 
203
203
  targets = {
204
204
  "init_mean_lt_ms": 375.0,
205
- "validate_mean_lt_ms": 210.0,
205
+ "validate_mean_lt_ms": 250.0,
206
206
  "gate_mean_lt_ms": 350.0,
207
207
  }
208
208
  observed = {
@@ -20,13 +20,17 @@
20
20
  "duration_seconds",
21
21
  "published_at",
22
22
  "playability_status",
23
+ "transcript_track_count",
24
+ "available_transcript_tracks",
23
25
  "transcript_available",
24
26
  "transcript_language",
25
27
  "transcript_track_name",
28
+ "transcript_track_source",
26
29
  "transcript_kind",
27
30
  "transcript_fetch_mode",
28
31
  "transcript_text",
29
32
  "transcript_segments",
33
+ "transcript_sources_tried",
30
34
  "warnings",
31
35
  "text_bundle"
32
36
  ],
@@ -83,6 +87,41 @@
83
87
  "playability_status": {
84
88
  "type": "string"
85
89
  },
90
+ "transcript_track_count": {
91
+ "type": "integer",
92
+ "minimum": 0
93
+ },
94
+ "available_transcript_tracks": {
95
+ "type": "array",
96
+ "items": {
97
+ "type": "object",
98
+ "additionalProperties": false,
99
+ "required": [
100
+ "language_code",
101
+ "name",
102
+ "kind",
103
+ "source"
104
+ ],
105
+ "properties": {
106
+ "language_code": {
107
+ "type": "string"
108
+ },
109
+ "name": {
110
+ "type": "string"
111
+ },
112
+ "kind": {
113
+ "type": "string",
114
+ "enum": [
115
+ "manual",
116
+ "auto"
117
+ ]
118
+ },
119
+ "source": {
120
+ "type": "string"
121
+ }
122
+ }
123
+ }
124
+ },
86
125
  "transcript_available": {
87
126
  "type": "boolean"
88
127
  },
@@ -92,6 +131,9 @@
92
131
  "transcript_track_name": {
93
132
  "type": "string"
94
133
  },
134
+ "transcript_track_source": {
135
+ "type": "string"
136
+ },
95
137
  "transcript_kind": {
96
138
  "type": "string",
97
139
  "enum": [
@@ -101,14 +143,7 @@
101
143
  ]
102
144
  },
103
145
  "transcript_fetch_mode": {
104
- "type": "string",
105
- "enum": [
106
- "json3",
107
- "xml",
108
- "unavailable",
109
- "none",
110
- "missing_track_url"
111
- ]
146
+ "type": "string"
112
147
  },
113
148
  "transcript_text": {
114
149
  "type": "string"
@@ -138,6 +173,12 @@
138
173
  }
139
174
  }
140
175
  },
176
+ "transcript_sources_tried": {
177
+ "type": "array",
178
+ "items": {
179
+ "type": "string"
180
+ }
181
+ },
141
182
  "warnings": {
142
183
  "type": "array",
143
184
  "items": {