45
COMPACTED_CONTINUITY_CHARS = 8_000
46
COMPACTED_PRESS_CONTEXT_CHARS = 14_000
47
COMPACTED_HISTORICAL_CONTEXT_CHARS = 12_000
48
+SYNTHESIS_MAX_TOKENS = 2_000
49
+SYNTHESIS_PROMPT_TOKEN_BUDGET = 20_000
50
+DEFAULT_SYNTHESIS_MODEL = "openai/gpt-4o-mini"
51
+RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
52
+NON_RETRYABLE_STATUS_CLASSES = {400, 401, 403, 404}
53
+MAX_RETRIES = 3
54
+BASE_DELAY = 2 # seconds
55
56
57
@dataclass
219
type=Path,
220
help="Write deterministic rendered-prompt preflight details as Markdown.",
221
)
222
+ parser.add_argument(
223
+ "--run-synthesis",
224
+ action="store_true",
225
+ help="Run Step 1 synthesis (press/historical context → compact narrative) and exit.",
226
+ )
227
+ parser.add_argument(
228
+ "--synthesis-output",
229
+ type=Path,
230
+ default=None,
231
+ help="Path to write the synthesis narrative output (used with --run-synthesis).",
232
+ )
233
+ parser.add_argument(
234
+ "--synthesis-input",
235
+ type=Path,
236
+ default=None,
237
+ help="Path to a pre-computed synthesis narrative to inject into the analysis prompt (Step 2).",
238
+ )
239
+ parser.add_argument(
240
+ "--synthesis-model",
241
+ type=str,
242
+ default=None,
243
+ help=f"Model to use for synthesis step (default: {DEFAULT_SYNTHESIS_MODEL}).",
244
+ )
245
return parser.parse_args(argv)
246
247
755
return compacted, decisions
756
757
758
+def _strip_ai_instruction_blocks(text: str) -> str:
759
+ """Remove AI-only instruction sections (### Instructions, directives) from press context.
760
+
761
+ These blocks are intended for the main analysis prompt and should not be
762
+ forwarded into synthesis to reduce prompt-injection surface area.
763
+ """
764
+ # Remove markdown sections starting with ### Instructions (case-insensitive)
765
+ # up to the next same-or-higher-level heading or end of text
766
+ text = re.sub(
767
+ r"(?m)^###\s+Instructions?\b.*?(?=^#{1,3}\s|\Z)",
768
+ "",
769
+ text,
770
+ flags=re.DOTALL | re.IGNORECASE,
771
+ )
772
+ # Remove divergence directive blocks (commonly marked with special tags)
773
+ text = re.sub(
774
+ r"(?m)^<!--\s*(?:ai-only|divergence|directive)\b.*?-->.*?(?:<!--\s*/(?:ai-only|divergence|directive)\s*-->|\Z)",
775
+ "",
776
+ text,
777
+ flags=re.DOTALL | re.IGNORECASE,
778
+ )
779
+ return text.strip()
780
+
781
+
782
+def _build_synthesis_prompt(
783
+ *,
784
+ press_content: str,
785
+ historical_context_content: str,
786
+ continuity_content: str,
787
+ current_week: str,
788
+ current_datetime: str,
789
+) -> str:
790
+ """Build a compact prompt for Step 1: Industry & Press Synthesis.
791
+
792
+ Input: press context + historical context + continuity capsule.
793
+ Output instruction: max 2K token narrative of the tech industry landscape this week.
794
+ """
795
+ sections = []
796
+ sections.append(
797
+ "You are an expert technology industry analyst. Your task is to synthesize "
798
+ "the provided press context, historical context, and continuity notes into a "
799
+ "compact industry narrative (maximum 2000 tokens / ~1500 words).\n\n"
800
+ "Focus on:\n"
801
+ "- Key technology trends and shifts happening this week\n"
802
+ "- Notable industry movements (acquisitions, launches, pivots)\n"
803
+ "- Developer ecosystem changes\n"
804
+ "- Connections to longer-term patterns from historical context\n\n"
805
+ "Output ONLY the narrative — no headers, no metadata, no instructions. "
806
+ "Write in a dense, information-rich style suitable for feeding into a downstream "
807
+ "analysis step that will correlate this with GitHub repository data.\n\n"
808
+ f"Current week: {current_week}\n"
809
+ f"Current datetime: {current_datetime}\n"
810
+ )
811
+ if press_content:
812
+ sections.append(f"## Press Context\n\n{press_content}")
813
+ if historical_context_content:
814
+ sections.append(f"## Historical Context\n\n{historical_context_content}")
815
+ if continuity_content and continuity_content != "_No continuity capsule has been recorded yet._":
816
+ sections.append(f"## Continuity Notes\n\n{continuity_content}")
817
+
818
+ return "\n\n---\n\n".join(sections)
819
+
820
+
821
+def run_synthesis_step(
822
+ *,
823
+ press_context_path: Path | None = None,
824
+ content_root: Path = DEFAULT_CONTENT_ROOT,
825
+ continuity_file: Path = DEFAULT_CONTINUITY_FILE,
826
+ current_datetime: str,
827
+ current_week: str,
828
+ previous_summary_path: Path | None = None,
829
+ prompt_token_budget: int = SYNTHESIS_PROMPT_TOKEN_BUDGET,
830
+ model: str | None = None,
831
+) -> str:
832
+ """Execute Step 1: synthesize press/historical context into a compact narrative.
833
+
834
+ Returns the narrative string (max ~2K tokens). Raises RuntimeError on API failure.
835
+ """
836
+ historical_context_content = assemble_historical_context(
837
+ current_datetime=current_datetime,
838
+ previous_summary_path=previous_summary_path,
839
+ content_root=content_root,
840
+ max_words=1_500,
841
+ prompt_token_budget=prompt_token_budget,
842
+ ).strip()
843
+ from scripts.sanitize_repo_content import _escape_untrusted_boundaries
844
+
845
+ historical_context_content = _escape_untrusted_boundaries(historical_context_content)
846
+ if not historical_context_content:
847
+ historical_context_content = "_No historical context was available beyond the current weekly payload._"
848
+
849
+ continuity_content = render_continuity(continuity_file)
850
+
851
+ press_content = (
852
+ press_context_path.read_text(encoding="utf-8").strip()
853
+ if press_context_path and press_context_path.exists() and press_context_path.stat().st_size > 0
854
+ else ""
855
+ )
856
+
857
+ # Strip AI-only instruction blocks from press context before synthesis
858
+ if press_content:
859
+ press_content = _strip_ai_instruction_blocks(press_content)
860
+ # Escape boundary markers in untrusted press content
861
+ from scripts.sanitize_repo_content import _escape_untrusted_boundaries
862
+ if press_content:
863
+ press_content = _escape_untrusted_boundaries(press_content)
864
+
865
+ # If there's no meaningful content to synthesize, return empty
866
+ if not press_content and historical_context_content.startswith("_No historical context"):
867
+ return ""
868
+
869
+ prompt = _build_synthesis_prompt(
870
+ press_content=press_content,
871
+ historical_context_content=historical_context_content,
872
+ continuity_content=continuity_content,
873
+ current_week=current_week,
874
+ current_datetime=current_datetime,
875
+ )
876
+
877
+ # Check that synthesis prompt is within its own budget
878
+ prompt_tokens = estimate_tokens(prompt)
879
+ if prompt_tokens > SYNTHESIS_PROMPT_TOKEN_BUDGET:
880
+ # Truncate press content to fit (clamp to avoid negative index)
881
+ excess_chars = (prompt_tokens - SYNTHESIS_PROMPT_TOKEN_BUDGET) * 4
882
+ end_index = max(0, len(press_content) - excess_chars)
883
+ press_content = press_content[:end_index]
884
+ prompt = _build_synthesis_prompt(
885
+ press_content=press_content,
886
+ historical_context_content=historical_context_content,
887
+ continuity_content=continuity_content,
888
+ current_week=current_week,
889
+ current_datetime=current_datetime,
890
+ )
891
+
892
+ return _call_synthesis_api(prompt, model=model or DEFAULT_SYNTHESIS_MODEL)
893
+
894
+
895
+def _call_synthesis_api(prompt: str, *, model: str) -> str:
896
+ """Call GitHub Models API for the synthesis step."""
897
+ token = os.environ.get("GITHUB_TOKEN")
898
+ if not token:
899
+ raise RuntimeError("GITHUB_TOKEN is required for synthesis step.")
900
+
901
+ # Inject canary token for output leak detection
902
+ from scripts.canary_token import generate_canary, inject_canary
903
+ canary = generate_canary()
904
+ prompt = inject_canary(prompt, canary)
905
+
906
+ endpoint = os.environ.get("GITHUB_MODELS_ENDPOINT", DEFAULT_MODELS_ENDPOINT)
907
+ validate_https_url(endpoint, label="GitHub Models endpoint", allowed_hosts=ALLOWED_MODELS_HOSTS)
908
+ timeout = int(os.environ.get("GITHUB_MODELS_TIMEOUT", str(DEFAULT_MODELS_TIMEOUT)))
909
+ payload = {
910
+ "model": model,
911
+ "messages": [{"role": "user", "content": prompt}],
912
+ "temperature": 0.2,
913
+ "max_tokens": SYNTHESIS_MAX_TOKENS, # Cap at documented 2K
914
+ }
915
+ body = json.dumps(payload).encode("utf-8")
916
+
917
+ last_exc: Exception | None = None
918
+ for attempt in range(MAX_RETRIES + 1):
919
+ req = request.Request(
920
+ endpoint,
921
+ data=body,
922
+ headers={
923
+ "Authorization": f"Bearer {token}",
924
+ "Content-Type": "application/json",
925
+ "Accept": "application/json",
926
+ },
927
+ method="POST",
928
+ )
929
+ try:
930
+ with request.urlopen(req, timeout=timeout) as response: # nosec B310
931
+ response_payload = json.load(response)
932
+ markdown = extract_markdown(response_payload)
933
+ # Validate output for canary leak and injection artifacts
934
+ violations = validate_output_safety(markdown, canary)
935
+ if violations:
936
+ msg = f"Output safety violations detected: {'; '.join(violations)}"
937
+ canary_leaked = any("Canary token leaked" in v for v in violations)
938
+ if canary_leaked:
939
+ raise RuntimeError(f"BLOCKED: {msg}")
940
+ print(f"::warning::{msg}", file=sys.stderr)
941
+ return markdown
942
+ except error.HTTPError as exc:
943
+ if exc.code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
944
+ detail = exc.read().decode("utf-8", errors="replace")
945
+ raise RuntimeError(
946
+ f"Synthesis API request failed ({exc.code}): {detail}"
947
+ ) from exc
948
+ # Respect Retry-After header on 429
949
+ retry_after = None
950
+ if exc.code == 429:
951
+ retry_after_header = exc.headers.get("Retry-After") if exc.headers else None
952
+ if retry_after_header:
953
+ try:
954
+ retry_after = float(retry_after_header)
955
+ except (ValueError, TypeError):
956
+ pass
957
+ delay = retry_after if retry_after else BASE_DELAY ** (attempt + 1) + _JITTER_RANDOM.uniform(0, 1)
958
+ print(
959
+ f"[retry] Synthesis API returned {exc.code}, "
960
+ f"retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})",
961
+ file=sys.stderr,
962
+ )
963
+ last_exc = exc
964
+ time.sleep(delay)
965
+ except error.URLError as exc:
966
+ if attempt == MAX_RETRIES:
967
+ raise RuntimeError(
968
+ f"Synthesis API network error: {exc.reason}"
969
+ ) from exc
970
+ delay = BASE_DELAY ** (attempt + 1) + _JITTER_RANDOM.uniform(0, 1)
971
+ print(
972
+ f"[retry] Synthesis API network error: {exc.reason}, "
973
+ f"retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})",
974
+ file=sys.stderr,
975
+ )
976
+ last_exc = exc
977
+ time.sleep(delay)
978
+
979
+ raise RuntimeError("Synthesis API request failed after retries") from last_exc
980
+
981
+
982
def _build_prompt(
983
*,
984
prompt_template_path: Path,
993
press_context_path: Path | None = None,
994
prompt_token_budget: int = DEFAULT_PROMPT_TOKEN_BUDGET,
995
allow_compaction: bool = True,
996
+ synthesis_narrative: str | None = None,
997
) -> tuple[str, PromptPreflight]:
998
payload = load_json(raw_json_path)
999
sanitized_payload = sanitize_repo_payload(payload)
1021
if press_context_path and press_context_path.exists() and press_context_path.stat().st_size > 0
1022
else ""
1023
)
1024
+ # When a synthesis narrative is available (Step 1 output), it replaces
1025
+ # the raw press context and historical context — those were already
1026
+ # distilled into the narrative. This dramatically reduces token count.
1027
+ if synthesis_narrative:
1028
+ historical_context_content = (
1029
+ f"[Industry narrative synthesized from press & historical context]\n\n{synthesis_narrative}"
1030
+ )
1031
+ press_content = ""
1032
payload_for_prompt = sanitized_payload
1033
raw_decisions = {"new_repos": "included", "trending_repos": "included"}
1034
previous_decision = "included" if previous_summary_path else "not included: no previous summary"
1353
raise ValueError("GitHub Models response did not contain markdown output.")
1354
1355
1093
-RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
1094
-NON_RETRYABLE_STATUS_CLASSES = {400, 401, 403, 404}
1095
-MAX_RETRIES = 3
1096
-BASE_DELAY = 2 # seconds
1097
-
1098
-
1356
def validate_https_url(url: str, *, label: str, allowed_hosts: frozenset[str] | None = None) -> None:
1357
parsed = parse.urlparse(url)
1358
if parsed.scheme.lower() != "https":
1751
):
1752
wisdom_file, skills_dir, continuity_file = resolve_analysis_context_paths()
1753
1754
+ # Step 1: Run synthesis if requested
1755
+ if args.run_synthesis:
1756
+ payload = load_json(args.raw_json)
1757
+ sanitized_payload = sanitize_repo_payload(payload)
1758
+ current_week = sanitized_payload["week"]
1759
+ previous_summary_path = find_previous_summary(current_week, args.analyzed_dir)
1760
+ try:
1761
+ narrative = run_synthesis_step(
1762
+ press_context_path=args.press_context,
1763
+ content_root=args.content_root,
1764
+ continuity_file=continuity_file,
1765
+ current_datetime=args.current_datetime,
1766
+ current_week=current_week,
1767
+ previous_summary_path=previous_summary_path,
1768
+ prompt_token_budget=args.prompt_token_budget,
1769
+ model=args.synthesis_model,
1770
+ )
1771
+ except RuntimeError as exc:
1772
+ print(f"::warning::Synthesis step failed: {exc}", file=sys.stderr)
1773
+ return 1
1774
+ output_path = args.synthesis_output or args.output
1775
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1776
+ output_path.write_text(narrative, encoding="utf-8")
1777
+ print(
1778
+ f"::notice::Synthesis step complete: {estimate_tokens(narrative)} tokens written to {output_path}",
1779
+ file=sys.stderr,
1780
+ )
1781
+ return 0
1782
+
1783
+ # Load synthesis narrative from Step 1 output if provided
1784
+ synthesis_narrative: str | None = None
1785
+ if args.synthesis_input and args.synthesis_input.exists() and args.synthesis_input.stat().st_size > 0:
1786
+ synthesis_narrative = args.synthesis_input.read_text(encoding="utf-8").strip()
1787
+ if synthesis_narrative:
1788
+ # Escape boundary markers — synthesis output is untrusted LLM content
1789
+ from scripts.sanitize_repo_content import _escape_untrusted_boundaries
1790
+ synthesis_narrative = _escape_untrusted_boundaries(synthesis_narrative)
1791
+ print(
1792
+ f"::notice::Using synthesis narrative ({estimate_tokens(synthesis_narrative)} tokens) from {args.synthesis_input}",
1793
+ file=sys.stderr,
1794
+ )
1795
+
1796
prompt, preflight = _build_prompt(
1797
prompt_template_path=args.prompt_template,
1798
raw_json_path=args.raw_json,
1806
press_context_path=args.press_context,
1807
prompt_token_budget=args.prompt_token_budget,
1808
allow_compaction=True,
1809
+ synthesis_narrative=synthesis_narrative,
1810
)
1811
write_preflight_reports(preflight, args.preflight_report_json, args.preflight_report_md)
1812