pipulate 2.76__tar.gz → 2.77__tar.gz

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.
Files changed (98) hide show
  1. {pipulate-2.76 → pipulate-2.77}/.agents/skills/journal/references/index.md +12 -7
  2. {pipulate-2.76 → pipulate-2.77}/PKG-INFO +1 -1
  3. {pipulate-2.76 → pipulate-2.77}/__init__.py +2 -2
  4. {pipulate-2.76 → pipulate-2.77}/pipulate.egg-info/PKG-INFO +1 -1
  5. {pipulate-2.76 → pipulate-2.77}/pyproject.toml +1 -1
  6. {pipulate-2.76 → pipulate-2.77}/tests/test_mck_rep2.py +43 -14
  7. {pipulate-2.76 → pipulate-2.77}/tools/scraper_tools.py +116 -4
  8. {pipulate-2.76 → pipulate-2.77}/.agents/skills/gsc-readonly/SKILL.md +0 -0
  9. {pipulate-2.76 → pipulate-2.77}/.agents/skills/hello-workflow/SKILL.md +0 -0
  10. {pipulate-2.76 → pipulate-2.77}/.agents/skills/journal/SKILL.md +0 -0
  11. {pipulate-2.76 → pipulate-2.77}/.agents/skills/pipulate/SKILL.md +0 -0
  12. {pipulate-2.76 → pipulate-2.77}/.agents/skills/roles/SKILL.md +0 -0
  13. {pipulate-2.76 → pipulate-2.77}/.agents/skills/sheets-readonly/SKILL.md +0 -0
  14. {pipulate-2.76 → pipulate-2.77}/AGENTS.md +0 -0
  15. {pipulate-2.76 → pipulate-2.77}/AUDIT.md +0 -0
  16. {pipulate-2.76 → pipulate-2.77}/LICENSE +0 -0
  17. {pipulate-2.76 → pipulate-2.77}/MANIFEST.in +0 -0
  18. {pipulate-2.76 → pipulate-2.77}/README.md +0 -0
  19. {pipulate-2.76 → pipulate-2.77}/apps/010_introduction.py +0 -0
  20. {pipulate-2.76 → pipulate-2.77}/apps/015_config.py +0 -0
  21. {pipulate-2.76 → pipulate-2.77}/apps/020_profiles.py +0 -0
  22. {pipulate-2.76 → pipulate-2.77}/apps/025_aspect.py +0 -0
  23. {pipulate-2.76 → pipulate-2.77}/apps/030_roles.py +0 -0
  24. {pipulate-2.76 → pipulate-2.77}/apps/040_hello_workflow.py +0 -0
  25. {pipulate-2.76 → pipulate-2.77}/apps/050_documentation.py +0 -0
  26. {pipulate-2.76 → pipulate-2.77}/apps/060_tasks.py +0 -0
  27. {pipulate-2.76 → pipulate-2.77}/apps/070_history.py +0 -0
  28. {pipulate-2.76 → pipulate-2.77}/apps/110_parameter_buster.py +0 -0
  29. {pipulate-2.76 → pipulate-2.77}/apps/120_link_graph.py +0 -0
  30. {pipulate-2.76 → pipulate-2.77}/apps/130_gap_analysis.py +0 -0
  31. {pipulate-2.76 → pipulate-2.77}/apps/200_workflow_genesis.py +0 -0
  32. {pipulate-2.76 → pipulate-2.77}/apps/210_widget_examples.py +0 -0
  33. {pipulate-2.76 → pipulate-2.77}/apps/220_roadmap.py +0 -0
  34. {pipulate-2.76 → pipulate-2.77}/apps/230_dev_assistant.py +0 -0
  35. {pipulate-2.76 → pipulate-2.77}/apps/240_simon_mcp.py +0 -0
  36. {pipulate-2.76 → pipulate-2.77}/apps/300_blank_placeholder.py +0 -0
  37. {pipulate-2.76 → pipulate-2.77}/apps/400_botify_trifecta.py +0 -0
  38. {pipulate-2.76 → pipulate-2.77}/apps/440_browser_automation.py +0 -0
  39. {pipulate-2.76 → pipulate-2.77}/apps/450_stream_simulator.py +0 -0
  40. {pipulate-2.76 → pipulate-2.77}/apps/510_text_field.py +0 -0
  41. {pipulate-2.76 → pipulate-2.77}/apps/520_text_area.py +0 -0
  42. {pipulate-2.76 → pipulate-2.77}/apps/530_dropdown.py +0 -0
  43. {pipulate-2.76 → pipulate-2.77}/apps/540_checkboxes.py +0 -0
  44. {pipulate-2.76 → pipulate-2.77}/apps/550_radios.py +0 -0
  45. {pipulate-2.76 → pipulate-2.77}/apps/560_range.py +0 -0
  46. {pipulate-2.76 → pipulate-2.77}/apps/570_switch.py +0 -0
  47. {pipulate-2.76 → pipulate-2.77}/apps/580_upload.py +0 -0
  48. {pipulate-2.76 → pipulate-2.77}/apps/610_markdown.py +0 -0
  49. {pipulate-2.76 → pipulate-2.77}/apps/620_mermaid.py +0 -0
  50. {pipulate-2.76 → pipulate-2.77}/apps/630_prism.py +0 -0
  51. {pipulate-2.76 → pipulate-2.77}/apps/640_javascript.py +0 -0
  52. {pipulate-2.76 → pipulate-2.77}/apps/710_pandas.py +0 -0
  53. {pipulate-2.76 → pipulate-2.77}/apps/720_rich.py +0 -0
  54. {pipulate-2.76 → pipulate-2.77}/apps/730_matplotlib.py +0 -0
  55. {pipulate-2.76 → pipulate-2.77}/apps/810_webbrowser.py +0 -0
  56. {pipulate-2.76 → pipulate-2.77}/apps/820_selenium.py +0 -0
  57. {pipulate-2.76 → pipulate-2.77}/apps/830_pico_slider.py +0 -0
  58. {pipulate-2.76 → pipulate-2.77}/cli.py +0 -0
  59. {pipulate-2.76 → pipulate-2.77}/config.py +0 -0
  60. {pipulate-2.76 → pipulate-2.77}/imports/__init__.py +0 -0
  61. {pipulate-2.76 → pipulate-2.77}/imports/ai_dictdb.py +0 -0
  62. {pipulate-2.76 → pipulate-2.77}/imports/ai_tool_discovery_simple_parser.py +0 -0
  63. {pipulate-2.76 → pipulate-2.77}/imports/append_only_conversation.py +0 -0
  64. {pipulate-2.76 → pipulate-2.77}/imports/ascii_displays.py +0 -0
  65. {pipulate-2.76 → pipulate-2.77}/imports/botify/__init__.py +0 -0
  66. {pipulate-2.76 → pipulate-2.77}/imports/botify/code_generators.py +0 -0
  67. {pipulate-2.76 → pipulate-2.77}/imports/botify/true_schema_discoverer.py +0 -0
  68. {pipulate-2.76 → pipulate-2.77}/imports/botify_code_generation.py +0 -0
  69. {pipulate-2.76 → pipulate-2.77}/imports/crud.py +0 -0
  70. {pipulate-2.76 → pipulate-2.77}/imports/database_safety_wrapper.py +0 -0
  71. {pipulate-2.76 → pipulate-2.77}/imports/dom_processing/__init__.py +0 -0
  72. {pipulate-2.76 → pipulate-2.77}/imports/dom_processing/ai_dom_beautifier.py +0 -0
  73. {pipulate-2.76 → pipulate-2.77}/imports/dom_processing/enhanced_dom_processor.py +0 -0
  74. {pipulate-2.76 → pipulate-2.77}/imports/durable_backup_system.py +0 -0
  75. {pipulate-2.76 → pipulate-2.77}/imports/mcp_orchestrator.py +0 -0
  76. {pipulate-2.76 → pipulate-2.77}/imports/server_logging.py +0 -0
  77. {pipulate-2.76 → pipulate-2.77}/imports/stream_orchestrator.py +0 -0
  78. {pipulate-2.76 → pipulate-2.77}/imports/voice_synthesis.py +0 -0
  79. {pipulate-2.76 → pipulate-2.77}/pipulate/__init__.py +0 -0
  80. {pipulate-2.76 → pipulate-2.77}/pipulate/core.py +0 -0
  81. {pipulate-2.76 → pipulate-2.77}/pipulate.egg-info/SOURCES.txt +0 -0
  82. {pipulate-2.76 → pipulate-2.77}/pipulate.egg-info/dependency_links.txt +0 -0
  83. {pipulate-2.76 → pipulate-2.77}/pipulate.egg-info/entry_points.txt +0 -0
  84. {pipulate-2.76 → pipulate-2.77}/pipulate.egg-info/requires.txt +0 -0
  85. {pipulate-2.76 → pipulate-2.77}/pipulate.egg-info/top_level.txt +0 -0
  86. {pipulate-2.76 → pipulate-2.77}/server.py +0 -0
  87. {pipulate-2.76 → pipulate-2.77}/setup.cfg +0 -0
  88. {pipulate-2.76 → pipulate-2.77}/tests/test_apply_airlock.py +0 -0
  89. {pipulate-2.76 → pipulate-2.77}/tools/__init__.py +0 -0
  90. {pipulate-2.76 → pipulate-2.77}/tools/advanced_automation_tools.py +0 -0
  91. {pipulate-2.76 → pipulate-2.77}/tools/botify_tools.py +0 -0
  92. {pipulate-2.76 → pipulate-2.77}/tools/connector_tools.py +0 -0
  93. {pipulate-2.76 → pipulate-2.77}/tools/conversation_tools.py +0 -0
  94. {pipulate-2.76 → pipulate-2.77}/tools/dom_tools.py +0 -0
  95. {pipulate-2.76 → pipulate-2.77}/tools/keychain_tools.py +0 -0
  96. {pipulate-2.76 → pipulate-2.77}/tools/llm_optics.py +0 -0
  97. {pipulate-2.76 → pipulate-2.77}/tools/mcp_tools.py +0 -0
  98. {pipulate-2.76 → pipulate-2.77}/tools/system_tools.py +0 -0
@@ -1,8 +1,8 @@
1
1
  # The Pipulate journal, indexed
2
2
 
3
- > Auto-generated on 2026-10-02 by `scripts/articles/generate_ai_context.py` and
3
+ > Auto-generated on 2026-10-04 by `scripts/articles/generate_ai_context.py` and
4
4
  > rewritten from scratch on every release. If this date looks stale, assume
5
- > the rest of the repo is newer than this map. 1510 entries indexed.
5
+ > the rest of the repo is newer than this map. 1515 entries indexed.
6
6
 
7
7
  This repository holds the *machinery*. The *reasoning*, the running journal
8
8
  that explains why every piece exists, lives on a separate website and not in
@@ -27,6 +27,11 @@ The first 20 entries include full `index.md` URLs to establish
27
27
  the link pattern. All remaining entries are bare slugs. Reconstruct any full
28
28
  URL as: `https://mikelev.in/futureproof/{slug}/index.md`
29
29
 
30
+ - [2026-10-04] [The Terminal House Style: Quieting the Console Without Silencing the Evidence](https://mikelev.in/futureproof/terminal-house-style-quiet-console-evidence/index.md)
31
+ - [2026-10-03] [Mechanical Advantage and the Active Root: Authoritative Prefixes for Replayable AI Pipelines](https://mikelev.in/futureproof/mechanical-advantage-and-the-active-root/index.md)
32
+ - [2026-10-03] [The Single-Owner Rule and the Verifiable Deed: Quieting Pipeline Noise in the Age of AI](https://mikelev.in/futureproof/single-owner-rule-and-verifiable-deeds/index.md)
33
+ - [2026-10-03] [The Render Canary and the Quiet Compiler: Distinguishing Exposure from Corruption in the Age of AI](https://mikelev.in/futureproof/render-canary-and-the-quiet-compiler/index.md)
34
+ - [2026-10-02] [The Enter Fence and the Bell Cue: Building Replayable Browser Walks in the Age of AI](https://mikelev.in/futureproof/the-enter-fence-and-the-bell-cue/index.md)
30
35
  - [2026-10-02] [The Clear Cups Protocol: Auditing MCP Calls with Replayable Receipts](https://mikelev.in/futureproof/clear-cups-protocol-auditing-mcp-calls/index.md)
31
36
  - [2026-10-02] [The Door Table and the Moving Twig: Replayable Installs and the Stick Bug Secret](https://mikelev.in/futureproof/the-door-table-and-the-moving-twig/index.md)
32
37
  - [2026-10-01] [The Scrollback Rule: Engineering Full-Width Terminal Dividers for Replayable Logs](https://mikelev.in/futureproof/scrollback-rule-clear-x-receipts/index.md)
@@ -42,16 +47,16 @@ URL as: `https://mikelev.in/futureproof/{slug}/index.md`
42
47
  - [2026-09-28] [Pinball, Pachinko, and the Art of Replayable AI Workflows](https://mikelev.in/futureproof/pinball-pachinko-replayable-ai-workflows/index.md)
43
48
  - [2026-09-28] [The Three-Folder Boundary: Ergonomic Workspaces and the Blank Stare in the Age of AI](https://mikelev.in/futureproof/three-folder-boundary-and-the-blank-stare/index.md)
44
49
  - [2026-09-26] [Closing the Loop: Fixing Patch Parser Failures and Actuator Surprises](https://mikelev.in/futureproof/fixing-patch-parser-failures-and-actuator-surprises/index.md)
45
- - [2026-09-26] [The Three Plain Words: Unifying Context and Building Verifiable Receipts](https://mikelev.in/futureproof/plain-words-verifiable-ai-context/index.md)
46
- - [2026-09-24] [When the Power Returns: Reconciling Diverged Git Branches and Single-Point Failures](https://mikelev.in/futureproof/when-the-power-returns-verifiable-git-resync/index.md)
47
- - [2026-09-24] [Local Chrome Profile Inventory and Typed Matrix Resolution](https://mikelev.in/futureproof/local-chrome-profile-inventory-and-typed-matrix-resolution/index.md)
48
- - [2026-09-21] [Local-First Development and Resilience in the Age of AI](https://mikelev.in/futureproof/local-first-resilience-age-of-ai/index.md)
49
- - [2026-09-20] [The Letting-Go Moment: Building Vim Training Wheels for the Age of AI](https://mikelev.in/futureproof/vim-training-wheels-and-the-letting-go-moment/index.md)
50
50
 
51
51
  ## Compact slug index -- pattern: https://mikelev.in/futureproof/{slug}/index.md
52
52
 
53
53
  Format: `[date] [size] slug` -- fetch any entry as `https://mikelev.in/futureproof/{slug}/index.md`
54
54
 
55
+ - [2026-09-26] [460k] plain-words-verifiable-ai-context
56
+ - [2026-09-24] [88k] when-the-power-returns-verifiable-git-resync
57
+ - [2026-09-24] [159k] local-chrome-profile-inventory-and-typed-matrix-resolution
58
+ - [2026-09-21] [184k] local-first-resilience-age-of-ai
59
+ - [2026-09-20] [219k] vim-training-wheels-and-the-letting-go-moment
55
60
  - [2026-09-20] [327k] disciplined-forgetting-and-the-memory-hierarchy
56
61
  - [2026-09-19] [175k] four-models-one-matchbook-prompt-fanout
57
62
  - [2026-09-19] [48k] dual-model-failover-independent-backoff
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipulate
3
- Version: 2.76
3
+ Version: 2.77
4
4
  Summary: The context compiler that helps you prompt well -- local-first, Nix-reproducible, replayable. The successor to AI SEO software.
5
5
  Author-email: Mike Levin <pipulate@gmail.com>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -12,7 +12,7 @@ Usage:
12
12
  pipulate
13
13
  """
14
14
 
15
- __version__ = "2.76"
15
+ __version__ = "2.77"
16
16
  # APOSTROPHES RESTORED (2026-08-04). They were stripped as a workaround for
17
17
  # flake.nix's descMatch regex, whose character class excluded ' from the
18
18
  # CAPTURE and truncated the banner to "(So)". That regex was fixed in the same
@@ -23,7 +23,7 @@ __version__ = "2.76"
23
23
  # is not a property of the system; the regex is. Blast radius is one banner:
24
24
  # nothing but flake.nix reads this name -- version_sync.py syncs __version__
25
25
  # and __description__, never this. So'wI' chu' -- "engage the cloaking device."
26
- __version_description__ = "Release Robustness"
26
+ __version_description__ = "Learning to Walk"
27
27
  # SPDX expression, single source of truth, synced into pyproject.toml by
28
28
  # scripts/release/version_sync.py. "-or-later" (not bare AGPL-3.0, which is
29
29
  # deprecated SPDX) because the header below grants "any later version".
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipulate
3
- Version: 2.76
3
+ Version: 2.77
4
4
  Summary: The context compiler that helps you prompt well -- local-first, Nix-reproducible, replayable. The successor to AI SEO software.
5
5
  Author-email: Mike Levin <pipulate@gmail.com>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "pipulate"
7
7
  # VERSION NOTE: Synced from pipulate/__init__.py.__version__ via python version_sync.py
8
- version = "2.76"
8
+ version = "2.77"
9
9
  authors = [
10
10
  { name="Mike Levin", email="pipulate@gmail.com" },
11
11
  ]
@@ -10,43 +10,70 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
10
10
  sys.path.insert(0, str(REPO_ROOT))
11
11
  sys.path.insert(0, str(REPO_ROOT / "scripts"))
12
12
 
13
- import prompt_foo
13
+ # prompt_foo is imported inside the two resolver tests (2026-10-04), so the
14
+ # trail tests below load neither the compiler nor the browser stack.
14
15
  import walk
15
16
 
16
17
 
17
18
  class MotherCatRep2Tests(unittest.TestCase):
18
- def test_pageworkers_trail_satisfies_strict_car_a_schema(self):
19
- trail_path = REPO_ROOT / "assets" / "trails" / "botify_pageworkers.yaml"
20
- trail = walk.load_trail(trail_path)
19
+ def test_public_walk_yaml_is_the_default_trail(self):
20
+ # THREE WALKS (2026-10-04): the PageWorkers trail this test once read
21
+ # was purged with five others; it pinned a trail's shape, never the
22
+ # Rep 2 witness. The bundled trail is public_walk, YAML since that day.
23
+ # Its JSON twin stays out of this test, so removing the twin moves no line.
24
+ self.assertEqual(walk.DEFAULT_TRAIL.name, "public_walk.yaml")
25
+ trail = walk.load_trail(walk.DEFAULT_TRAIL)
21
26
 
22
27
  self.assertEqual(trail["schema_version"], 1)
23
- self.assertEqual(trail["name"], "botify_pageworkers")
28
+ self.assertEqual(trail["name"], "public_walk")
24
29
  self.assertEqual(
25
- [stop["url_env"] for stop in trail["stops"]],
26
- [
27
- "PIPULATE_TRAIL_BOTIFY_OPTIMIZATION_URL",
28
- "PIPULATE_TRAIL_BOTIFY_MONITORING_URL",
29
- "PIPULATE_TRAIL_BOTIFY_REPORTING_URL",
30
- ],
30
+ [stop["name"] for stop in trail["stops"]],
31
+ ["the_word", "the_receipt", "the_two_pages"],
31
32
  )
33
+ self.assertNotIn("introduction", trail)
32
34
  self.assertEqual(
33
35
  {
34
36
  "headless": trail["defaults"]["headless"],
35
37
  "persistent": trail["defaults"]["persistent"],
36
38
  "override_cache": trail["defaults"]["override_cache"],
37
- "is_notebook_context": trail["defaults"]["is_notebook_context"],
38
39
  "profile_name": trail["defaults"]["profile_name"],
39
40
  },
40
41
  {
41
42
  "headless": False,
42
43
  "persistent": True,
43
44
  "override_cache": True,
44
- "is_notebook_context": False,
45
- "profile_name": "botify",
45
+ "profile_name": "default",
46
46
  },
47
47
  )
48
48
 
49
+ def test_introduction_is_optional_and_shares_the_walk_namespace(self):
50
+ # THE INTRODUCTION IS SKIPPED BY DEFAULT (2026-10-04): an optional list
51
+ # of stops, validated like the walk's own; a name is unique across both
52
+ # lists, because both land in one capture archive.
53
+ import yaml
54
+
55
+ raw = yaml.safe_load(walk.DEFAULT_TRAIL.read_text(encoding="utf-8"))
56
+ first = raw["stops"][0]
57
+ fresh = dict(first, name="first_look", target_slot="first_slot")
58
+ with tempfile.TemporaryDirectory() as temp_dir:
59
+ path = Path(temp_dir) / "intro.yaml"
60
+ path.write_text(
61
+ yaml.safe_dump(dict(raw, introduction=[fresh])), encoding="utf-8"
62
+ )
63
+ trail = walk.load_trail(path)
64
+ self.assertEqual(
65
+ [stop["name"] for stop in trail["introduction"]], ["first_look"]
66
+ )
67
+ self.assertEqual(len(trail["stops"]), 3)
68
+ path.write_text(
69
+ yaml.safe_dump(dict(raw, introduction=[first])), encoding="utf-8"
70
+ )
71
+ with self.assertRaisesRegex(walk.TrailError, "must be unique"):
72
+ walk.load_trail(path)
73
+
49
74
  def test_resolver_matches_requested_and_final_guided_urls(self):
75
+ import prompt_foo
76
+
50
77
  requested_url = "https://app.example.com/project/page?analysis=1"
51
78
  final_url = requested_url + "&context=24h"
52
79
 
@@ -111,6 +138,8 @@ class MotherCatRep2Tests(unittest.TestCase):
111
138
  )
112
139
 
113
140
  def test_resolver_falls_back_to_legacy_prompt_foo_cache(self):
141
+ import prompt_foo
142
+
114
143
  target_url = "https://example.com/a/b?variant=1"
115
144
 
116
145
  with tempfile.TemporaryDirectory() as temp_dir:
@@ -415,6 +415,7 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
415
415
  output_dir = base_dir / domain / url_path_slug
416
416
  artifacts = {}
417
417
  final_url = None
418
+ seconds_to_enter = None
418
419
 
419
420
  def failure(message):
420
421
  return {
@@ -663,6 +664,18 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
663
664
  driver.execute_cdp_cmd("Network.setCacheDisabled", {"cacheDisabled": True})
664
665
  except Exception as exc:
665
666
  logger.warning(f"⚠️ Could not turn off the browser cache; this page may come from it: {exc}")
667
+ # A BLANK START (2026-10-02): the window opened on the browser's
668
+ # new-tab page, and its chrome:// and chrome-untrusted:// loads sat at
669
+ # the top of every network log. Open about:blank, then read the
670
+ # performance log once and drop what it holds. The page under test
671
+ # has not been requested yet, so this read can take only the start
672
+ # page's traffic; the one read of the flight is still the drain
673
+ # further down.
674
+ try:
675
+ driver.get("about:blank")
676
+ driver.get_log("performance")
677
+ except Exception as exc:
678
+ logger.warning(f"⚠️ Could not clear the start page from the network log: {exc}")
666
679
  logger.info(f"Navigating to: {url}")
667
680
  driver.get(url)
668
681
 
@@ -686,6 +699,7 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
686
699
  # supplied one, play the ready sound exactly once.
687
700
  _stop_scrape_music(music_proc, ding=True)
688
701
  music_proc = None
702
+ bell_at = time.monotonic()
689
703
 
690
704
  if checkpoint is not None:
691
705
  try:
@@ -702,6 +716,7 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
702
716
  checkpoint_result.get("error")
703
717
  or "interactive checkpoint was not confirmed"
704
718
  )
719
+ seconds_to_enter = round(time.monotonic() - bell_at, 1)
705
720
 
706
721
  try:
707
722
  final_url = driver.current_url
@@ -752,9 +767,12 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
752
767
  # Silent, plausible, and it inverts the one measurement the triptych
753
768
  # exists to take.
754
769
  #
755
- # get_log() DRAINS: this is the one and only read. A second
756
- # get_log("performance") anywhere returns [], so any debug drain added
757
- # above this line steals the whole flight and leaves the ledger empty.
770
+ # get_log() DRAINS: this is the one read of the flight. A second
771
+ # get_log("performance") returns only what came after the first, so
772
+ # any debug drain added between driver.get(url) and here steals the
773
+ # flight and leaves the ledger empty. The start-page read just before
774
+ # driver.get(url) is the one exception (2026-10-02): it runs before
775
+ # the page under test is requested.
758
776
  if verbose: logger.info("🛜 Draining CDP performance log (network flight recorder)...")
759
777
  cdp_events = []
760
778
  try:
@@ -901,6 +919,10 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
901
919
  # not as bad -- fail-open on purpose, because marking years of
902
920
  # legacy captures suspect is noise wearing caution's hat.
903
921
  "source_provenance": source_provenance,
922
+ # SECONDS FROM THE BELL TO ENTER (2026-10-02): how long the human
923
+ # spent on the page after it loaded, so a glance can be told from
924
+ # two clicks and a wait. None when no human was asked.
925
+ "seconds_bell_to_enter": seconds_to_enter,
904
926
  "headers": actual_headers
905
927
  }
906
928
  headers_path = output_dir / "headers.json"
@@ -909,9 +931,99 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
909
931
 
910
932
  if take_screenshot:
911
933
  screenshot_path = output_dir / "screenshot.png"
912
- driver.save_screenshot(str(screenshot_path))
934
+ # THE WHOLE PAGE, NOT THE WINDOW (2026-10-02, walk 3: the operator
935
+ # had scrolled down, and the screenshot held only the lower part).
936
+ # save_screenshot takes the viewport as it stands. CDP takes the
937
+ # page: scroll to the top, read the page's CSS size, and capture
938
+ # beyond the viewport with a clip of that size. Height is capped at
939
+ # 16384 px so a very long page cannot ask for a giant image. Any
940
+ # failure falls back to the window, and the log says so.
941
+ try:
942
+ import base64
943
+ driver.execute_script("window.scrollTo({top: 0, left: 0, behavior: 'instant'});")
944
+ metrics = driver.execute_cdp_cmd("Page.getLayoutMetrics", {})
945
+ size = metrics.get("cssContentSize") or metrics.get("contentSize") or {}
946
+ width = max(1, int(size.get("width") or 1920))
947
+ height = max(1, min(int(size.get("height") or 1080), 16384))
948
+ shot = driver.execute_cdp_cmd("Page.captureScreenshot", {
949
+ "format": "png",
950
+ "captureBeyondViewport": True,
951
+ "clip": {"x": 0, "y": 0, "width": width, "height": height, "scale": 1},
952
+ })
953
+ screenshot_path.write_bytes(base64.b64decode(shot["data"]))
954
+ except Exception as exc:
955
+ logger.warning(f"⚠️ Full-page screenshot failed; saving the window instead: {exc}")
956
+ driver.save_screenshot(str(screenshot_path))
913
957
  artifacts['screenshot'] = str(screenshot_path)
914
958
 
959
+ # --- Response bodies: first-party scripts and data, bounded ---
960
+ # WHERE DID THIS TEXT COME FROM (2026-10-02, the PocketRender ride): a
961
+ # single-page app sends a small shell and builds every view from
962
+ # scripts and fetches, so the answer is in those bodies, not in
963
+ # source.html, and the browser forgets them when it closes. Kept:
964
+ # Script, XHR and Fetch responses whose host ends in the final URL's
965
+ # last two labels (app.botify.com and irf.production.botify.com both
966
+ # end in botify.com; a co.uk-style host takes in more, never less),
967
+ # read off the drained ledger with Network.getResponseBody, so nothing
968
+ # is requested twice. Bounds: 60 files, 2 MB each (cut, and marked),
969
+ # 20 MB in all, 120 tries. Only bodies/index.jsonl (address without
970
+ # its query, status, type, bytes) is an artifact; the bodies stay
971
+ # beside the capture, under the same gitignored browser_cache/ as
972
+ # everything else the page held.
973
+ try:
974
+ import base64
975
+ final_host = (urlparse(final_url).hostname or "").lower()
976
+ site = ".".join(final_host.split(".")[-2:])
977
+ bodies_dir = output_dir / "bodies"
978
+ rows, seen, kept, total = [], set(), 0, 0
979
+ for ev in cdp_events:
980
+ params_ev = ev.get("params", {})
981
+ response = params_ev.get("response", {})
982
+ body_url = response.get("url", "")
983
+ host = (urlparse(body_url).hostname or "").lower()
984
+ request_id = params_ev.get("requestId")
985
+ kind = params_ev.get("type")
986
+ if (ev.get("method") != "Network.responseReceived"
987
+ or kind not in ("Script", "XHR", "Fetch")
988
+ or not request_id or request_id in seen
989
+ or not site or not (host == site or host.endswith("." + site))):
990
+ continue
991
+ if kept >= 60 or total >= 20_000_000 or len(rows) >= 120:
992
+ break
993
+ seen.add(request_id)
994
+ row = {"url": body_url.split("#", 1)[0].split("?", 1)[0],
995
+ "status": response.get("status"), "type": kind,
996
+ "mime": response.get("mimeType", "")}
997
+ try:
998
+ got = driver.execute_cdp_cmd("Network.getResponseBody", {"requestId": request_id})
999
+ except Exception as exc:
1000
+ row["error"] = (str(exc).strip().splitlines() or [type(exc).__name__])[0][:200]
1001
+ rows.append(row)
1002
+ continue
1003
+ if got.get("base64Encoded"):
1004
+ data, ext = base64.b64decode(got.get("body", "")), ".bin"
1005
+ else:
1006
+ data = got.get("body", "").encode("utf-8")
1007
+ ext = ".js" if kind == "Script" else ".json" if "json" in row["mime"] else ".txt"
1008
+ cut = len(data) > 2_000_000
1009
+ data = data[:2_000_000]
1010
+ name = f"{kept + 1:03d}-{kind.lower()}-{hashlib.sha256(body_url.encode('utf-8')).hexdigest()[:10]}{ext}"
1011
+ bodies_dir.mkdir(exist_ok=True)
1012
+ (bodies_dir / name).write_bytes(data)
1013
+ kept += 1
1014
+ total += len(data)
1015
+ row.update({"file": name, "bytes": len(data), "cut": cut})
1016
+ rows.append(row)
1017
+ if rows:
1018
+ bodies_dir.mkdir(exist_ok=True)
1019
+ index_path = bodies_dir / "index.jsonl"
1020
+ index_path.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
1021
+ artifacts['bodies_index'] = str(index_path)
1022
+ if verbose:
1023
+ logger.info(f"📦 Kept {kept} first-party response bodies ({total} bytes) in {bodies_dir.name}/")
1024
+ except Exception as exc:
1025
+ logger.warning(f"⚠️ Could not keep response bodies: {exc}")
1026
+
915
1027
 
916
1028
 
917
1029
  # 2. Create LLM-Optimized Simplified DOMs (The Symmetrical Lens)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes