pipulate 2.73__tar.gz → 2.75__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.73 → pipulate-2.75}/.agents/skills/journal/references/index.md +12 -7
  2. {pipulate-2.73 → pipulate-2.75}/PKG-INFO +8 -8
  3. {pipulate-2.73 → pipulate-2.75}/README.md +7 -7
  4. {pipulate-2.73 → pipulate-2.75}/__init__.py +2 -2
  5. {pipulate-2.73 → pipulate-2.75}/imports/voice_synthesis.py +327 -11
  6. {pipulate-2.73 → pipulate-2.75}/pipulate.egg-info/PKG-INFO +8 -8
  7. {pipulate-2.73 → pipulate-2.75}/pyproject.toml +1 -1
  8. {pipulate-2.73 → pipulate-2.75}/tools/scraper_tools.py +43 -25
  9. {pipulate-2.73 → pipulate-2.75}/.agents/skills/gsc-readonly/SKILL.md +0 -0
  10. {pipulate-2.73 → pipulate-2.75}/.agents/skills/hello-workflow/SKILL.md +0 -0
  11. {pipulate-2.73 → pipulate-2.75}/.agents/skills/journal/SKILL.md +0 -0
  12. {pipulate-2.73 → pipulate-2.75}/.agents/skills/pipulate/SKILL.md +0 -0
  13. {pipulate-2.73 → pipulate-2.75}/.agents/skills/roles/SKILL.md +0 -0
  14. {pipulate-2.73 → pipulate-2.75}/.agents/skills/sheets-readonly/SKILL.md +0 -0
  15. {pipulate-2.73 → pipulate-2.75}/AGENTS.md +0 -0
  16. {pipulate-2.73 → pipulate-2.75}/AUDIT.md +0 -0
  17. {pipulate-2.73 → pipulate-2.75}/LICENSE +0 -0
  18. {pipulate-2.73 → pipulate-2.75}/MANIFEST.in +0 -0
  19. {pipulate-2.73 → pipulate-2.75}/apps/010_introduction.py +0 -0
  20. {pipulate-2.73 → pipulate-2.75}/apps/015_config.py +0 -0
  21. {pipulate-2.73 → pipulate-2.75}/apps/020_profiles.py +0 -0
  22. {pipulate-2.73 → pipulate-2.75}/apps/025_aspect.py +0 -0
  23. {pipulate-2.73 → pipulate-2.75}/apps/030_roles.py +0 -0
  24. {pipulate-2.73 → pipulate-2.75}/apps/040_hello_workflow.py +0 -0
  25. {pipulate-2.73 → pipulate-2.75}/apps/050_documentation.py +0 -0
  26. {pipulate-2.73 → pipulate-2.75}/apps/060_tasks.py +0 -0
  27. {pipulate-2.73 → pipulate-2.75}/apps/070_history.py +0 -0
  28. {pipulate-2.73 → pipulate-2.75}/apps/110_parameter_buster.py +0 -0
  29. {pipulate-2.73 → pipulate-2.75}/apps/120_link_graph.py +0 -0
  30. {pipulate-2.73 → pipulate-2.75}/apps/130_gap_analysis.py +0 -0
  31. {pipulate-2.73 → pipulate-2.75}/apps/200_workflow_genesis.py +0 -0
  32. {pipulate-2.73 → pipulate-2.75}/apps/210_widget_examples.py +0 -0
  33. {pipulate-2.73 → pipulate-2.75}/apps/220_roadmap.py +0 -0
  34. {pipulate-2.73 → pipulate-2.75}/apps/230_dev_assistant.py +0 -0
  35. {pipulate-2.73 → pipulate-2.75}/apps/240_simon_mcp.py +0 -0
  36. {pipulate-2.73 → pipulate-2.75}/apps/300_blank_placeholder.py +0 -0
  37. {pipulate-2.73 → pipulate-2.75}/apps/400_botify_trifecta.py +0 -0
  38. {pipulate-2.73 → pipulate-2.75}/apps/440_browser_automation.py +0 -0
  39. {pipulate-2.73 → pipulate-2.75}/apps/450_stream_simulator.py +0 -0
  40. {pipulate-2.73 → pipulate-2.75}/apps/510_text_field.py +0 -0
  41. {pipulate-2.73 → pipulate-2.75}/apps/520_text_area.py +0 -0
  42. {pipulate-2.73 → pipulate-2.75}/apps/530_dropdown.py +0 -0
  43. {pipulate-2.73 → pipulate-2.75}/apps/540_checkboxes.py +0 -0
  44. {pipulate-2.73 → pipulate-2.75}/apps/550_radios.py +0 -0
  45. {pipulate-2.73 → pipulate-2.75}/apps/560_range.py +0 -0
  46. {pipulate-2.73 → pipulate-2.75}/apps/570_switch.py +0 -0
  47. {pipulate-2.73 → pipulate-2.75}/apps/580_upload.py +0 -0
  48. {pipulate-2.73 → pipulate-2.75}/apps/610_markdown.py +0 -0
  49. {pipulate-2.73 → pipulate-2.75}/apps/620_mermaid.py +0 -0
  50. {pipulate-2.73 → pipulate-2.75}/apps/630_prism.py +0 -0
  51. {pipulate-2.73 → pipulate-2.75}/apps/640_javascript.py +0 -0
  52. {pipulate-2.73 → pipulate-2.75}/apps/710_pandas.py +0 -0
  53. {pipulate-2.73 → pipulate-2.75}/apps/720_rich.py +0 -0
  54. {pipulate-2.73 → pipulate-2.75}/apps/730_matplotlib.py +0 -0
  55. {pipulate-2.73 → pipulate-2.75}/apps/810_webbrowser.py +0 -0
  56. {pipulate-2.73 → pipulate-2.75}/apps/820_selenium.py +0 -0
  57. {pipulate-2.73 → pipulate-2.75}/apps/830_pico_slider.py +0 -0
  58. {pipulate-2.73 → pipulate-2.75}/cli.py +0 -0
  59. {pipulate-2.73 → pipulate-2.75}/config.py +0 -0
  60. {pipulate-2.73 → pipulate-2.75}/imports/__init__.py +0 -0
  61. {pipulate-2.73 → pipulate-2.75}/imports/ai_dictdb.py +0 -0
  62. {pipulate-2.73 → pipulate-2.75}/imports/ai_tool_discovery_simple_parser.py +0 -0
  63. {pipulate-2.73 → pipulate-2.75}/imports/append_only_conversation.py +0 -0
  64. {pipulate-2.73 → pipulate-2.75}/imports/ascii_displays.py +0 -0
  65. {pipulate-2.73 → pipulate-2.75}/imports/botify/__init__.py +0 -0
  66. {pipulate-2.73 → pipulate-2.75}/imports/botify/code_generators.py +0 -0
  67. {pipulate-2.73 → pipulate-2.75}/imports/botify/true_schema_discoverer.py +0 -0
  68. {pipulate-2.73 → pipulate-2.75}/imports/botify_code_generation.py +0 -0
  69. {pipulate-2.73 → pipulate-2.75}/imports/crud.py +0 -0
  70. {pipulate-2.73 → pipulate-2.75}/imports/database_safety_wrapper.py +0 -0
  71. {pipulate-2.73 → pipulate-2.75}/imports/dom_processing/__init__.py +0 -0
  72. {pipulate-2.73 → pipulate-2.75}/imports/dom_processing/ai_dom_beautifier.py +0 -0
  73. {pipulate-2.73 → pipulate-2.75}/imports/dom_processing/enhanced_dom_processor.py +0 -0
  74. {pipulate-2.73 → pipulate-2.75}/imports/durable_backup_system.py +0 -0
  75. {pipulate-2.73 → pipulate-2.75}/imports/mcp_orchestrator.py +0 -0
  76. {pipulate-2.73 → pipulate-2.75}/imports/server_logging.py +0 -0
  77. {pipulate-2.73 → pipulate-2.75}/imports/stream_orchestrator.py +0 -0
  78. {pipulate-2.73 → pipulate-2.75}/pipulate/__init__.py +0 -0
  79. {pipulate-2.73 → pipulate-2.75}/pipulate/core.py +0 -0
  80. {pipulate-2.73 → pipulate-2.75}/pipulate.egg-info/SOURCES.txt +0 -0
  81. {pipulate-2.73 → pipulate-2.75}/pipulate.egg-info/dependency_links.txt +0 -0
  82. {pipulate-2.73 → pipulate-2.75}/pipulate.egg-info/entry_points.txt +0 -0
  83. {pipulate-2.73 → pipulate-2.75}/pipulate.egg-info/requires.txt +0 -0
  84. {pipulate-2.73 → pipulate-2.75}/pipulate.egg-info/top_level.txt +0 -0
  85. {pipulate-2.73 → pipulate-2.75}/server.py +0 -0
  86. {pipulate-2.73 → pipulate-2.75}/setup.cfg +0 -0
  87. {pipulate-2.73 → pipulate-2.75}/tests/test_apply_airlock.py +0 -0
  88. {pipulate-2.73 → pipulate-2.75}/tests/test_mck_rep2.py +0 -0
  89. {pipulate-2.73 → pipulate-2.75}/tools/__init__.py +0 -0
  90. {pipulate-2.73 → pipulate-2.75}/tools/advanced_automation_tools.py +0 -0
  91. {pipulate-2.73 → pipulate-2.75}/tools/botify_tools.py +0 -0
  92. {pipulate-2.73 → pipulate-2.75}/tools/connector_tools.py +0 -0
  93. {pipulate-2.73 → pipulate-2.75}/tools/conversation_tools.py +0 -0
  94. {pipulate-2.73 → pipulate-2.75}/tools/dom_tools.py +0 -0
  95. {pipulate-2.73 → pipulate-2.75}/tools/keychain_tools.py +0 -0
  96. {pipulate-2.73 → pipulate-2.75}/tools/llm_optics.py +0 -0
  97. {pipulate-2.73 → pipulate-2.75}/tools/mcp_tools.py +0 -0
  98. {pipulate-2.73 → pipulate-2.75}/tools/system_tools.py +0 -0
@@ -1,8 +1,8 @@
1
1
  # The Pipulate journal, indexed
2
2
 
3
- > Auto-generated on 2026-09-29 by `scripts/articles/generate_ai_context.py` and
3
+ > Auto-generated on 2026-10-02 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. 1505 entries indexed.
5
+ > the rest of the repo is newer than this map. 1510 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-02] [The Clear Cups Protocol: Auditing MCP Calls with Replayable Receipts](https://mikelev.in/futureproof/clear-cups-protocol-auditing-mcp-calls/index.md)
31
+ - [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
+ - [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)
33
+ - [2026-10-01] [The Prefix Ladder and the Unalias Guard: Replayable Shell Ergonomics in the Age of AI](https://mikelev.in/futureproof/prefix-ladders-and-the-unalias-guard/index.md)
34
+ - [2026-09-30] [Forever-Commands and the Talk Toggle: Replayable CLI Habits in the Age of AI](https://mikelev.in/futureproof/forever-commands-and-the-talk-toggle/index.md)
30
35
  - [2026-09-29] [Layered Reality and Operator Overloading: Reading the Physics of CPython in the Age of AI](https://mikelev.in/futureproof/layered-reality-operator-overloading-cpython/index.md)
31
36
  - [2026-09-29] [The Three-Door Installer: Named Prompts, Split DNS, and Verifiable Releases](https://mikelev.in/futureproof/the-three-door-installer-and-named-prompts/index.md)
32
37
  - [2026-09-29] [The Deed Is Named for the Door: Engineering Replayable QA Archives for AI](https://mikelev.in/futureproof/the-deed-is-named-for-the-door-qa-archive-zip/index.md)
@@ -42,16 +47,16 @@ URL as: `https://mikelev.in/futureproof/{slug}/index.md`
42
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)
43
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)
44
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)
45
- - [2026-09-20] [Disciplined Forgetting: Scaling AI Context Through a Two-Tier Memory Hierarchy](https://mikelev.in/futureproof/disciplined-forgetting-and-the-memory-hierarchy/index.md)
46
- - [2026-09-19] [Four Models, One Matchbook: Multi-Model Consensus in the Age of AI](https://mikelev.in/futureproof/four-models-one-matchbook-prompt-fanout/index.md)
47
- - [2026-09-19] [Dual-Model Failover: Engineering Independent Backoff Clocks for LLM APIs](https://mikelev.in/futureproof/dual-model-failover-independent-backoff/index.md)
48
- - [2026-09-19] [Playing Pinball in the Age of AI: A Replayable Workflow Guide](https://mikelev.in/futureproof/playing-pinball-in-the-age-of-ai-replayable-workflow-guide/index.md)
49
- - [2026-09-19] [The Color Blue and the Pipe: Inspecting Code Before Execution](https://mikelev.in/futureproof/color-blue-pipe-inspecting-code/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-20] [327k] disciplined-forgetting-and-the-memory-hierarchy
56
+ - [2026-09-19] [175k] four-models-one-matchbook-prompt-fanout
57
+ - [2026-09-19] [48k] dual-model-failover-independent-backoff
58
+ - [2026-09-19] [193k] playing-pinball-in-the-age-of-ai-replayable-workflow-guide
59
+ - [2026-09-19] [72k] color-blue-pipe-inspecting-code
55
60
  - [2026-09-19] [294k] edna-mode-rules-for-ai-workflows
56
61
  - [2026-09-18] [207k] the-one-door-threshold-replayable-ai-workflows
57
62
  - [2026-09-18] [292k] the-first-ten-minutes-reproducible-ai-workflows
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipulate
3
- Version: 2.73
3
+ Version: 2.75
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
@@ -86,7 +86,7 @@ Under both sits `flake.nix` with its `flake.lock`: the dependency inventory an S
86
86
 
87
87
  ## Quick Start: Be Running in 5 Minutes
88
88
 
89
- **Want to skip the philosophy and just see what this does? Paste this into your terminal:**
89
+ **Want to skip the philosophy and just see what this does? Open the command line (on a Mac, the app called Terminal) and paste this:**
90
90
 
91
91
  ```bash
92
92
  curl -fsSL https://pipulate.com/install.sh | bash
@@ -113,8 +113,8 @@ These few commands:
113
113
 
114
114
  ### 4. Run It Again!
115
115
 
116
- 1. You can just forcibly exit out of that Terminal it's running from.
117
- 2. Open a new Terminal, and once again:
116
+ 1. You can just close the window it's running in.
117
+ 2. Open a new one, and once again:
118
118
 
119
119
  ```bash
120
120
  cd ~/pipulate
@@ -242,7 +242,7 @@ Pipulate is a **local-first, single-tenant desktop app framework** featuring AI-
242
242
  │ │
243
243
  ▼ ▼
244
244
  ┌─────────────────────────┐ ┌─────────────────────────┐ This is new.
245
- │ 📱 Native Window │ │ 🖥️ Terminal Console │ , O
245
+ │ 📱 Native Window │ │ 🖥️ The Command Line │ , O
246
246
  │ ┌─────────────────┐ │ │ ┌─────────────────────┐ │ \\ . O
247
247
  │ │ Web Browser │ │ │ │ nix develop (./run) │ │ |\\/| o
248
248
  │ │ (Bundled) │ │ │ │ Starting servers... │ │ / " '\
@@ -628,7 +628,7 @@ Type `jn` at the `(nix)` prompt, wait for the JupyterLab tab to open, then run t
628
628
 
629
629
  | Problem | Solution |
630
630
  |---------|----------|
631
- | `nix: command not found` | You didn't restart your terminal after Nix installation |
631
+ | `nix: command not found` | Close the window and open a new one; Nix needs a fresh window after it installs |
632
632
  | Browser doesn't open automatically | Manually visit `http://localhost:5001` and `http://localhost:8888` |
633
633
  | `Permission denied` errors | Make sure you can write to `~/pipulate` directory |
634
634
  | Port conflicts | Kill processes on ports 5001/8888: `lsof -ti:5001 \| xargs kill -9` |
@@ -656,7 +656,7 @@ If you are a developer comfortable with tools like Homebrew and `pipx`, you can
656
656
  brew install pipx
657
657
  ```
658
658
 
659
- **Step 2: Install the Pipulate CLI**
659
+ **Step 2: Install the `pipulate` command**
660
660
 
661
661
  Use `pipx` to install the `pipulate` command-line tool. This will not cause conflicts with your system Python.
662
662
 
@@ -871,7 +871,7 @@ Traditional development follows DRY principles, creating abstract, complex syste
871
871
 
872
872
  ## Developer Setup & Environment Notes
873
873
 
874
- **Nix Environment Activation:** Always run `nix develop` from the `~/pipulate` directory *before* running any project commands (`python server.py`, `pip install`, etc.) in a new terminal. This ensures you are using the correct dependencies defined in `flake.nix`.
874
+ **Nix Environment Activation:** Always run `nix develop` from the `~/pipulate` directory *before* running any project commands (`python server.py`, `pip install`, etc.) in a new window. This ensures you are using the correct dependencies defined in `flake.nix`.
875
875
 
876
876
  **Interactive vs. Quiet Shell:**
877
877
 
@@ -29,7 +29,7 @@ Under both sits `flake.nix` with its `flake.lock`: the dependency inventory an S
29
29
 
30
30
  ## Quick Start: Be Running in 5 Minutes
31
31
 
32
- **Want to skip the philosophy and just see what this does? Paste this into your terminal:**
32
+ **Want to skip the philosophy and just see what this does? Open the command line (on a Mac, the app called Terminal) and paste this:**
33
33
 
34
34
  ```bash
35
35
  curl -fsSL https://pipulate.com/install.sh | bash
@@ -56,8 +56,8 @@ These few commands:
56
56
 
57
57
  ### 4. Run It Again!
58
58
 
59
- 1. You can just forcibly exit out of that Terminal it's running from.
60
- 2. Open a new Terminal, and once again:
59
+ 1. You can just close the window it's running in.
60
+ 2. Open a new one, and once again:
61
61
 
62
62
  ```bash
63
63
  cd ~/pipulate
@@ -185,7 +185,7 @@ Pipulate is a **local-first, single-tenant desktop app framework** featuring AI-
185
185
  │ │
186
186
  ▼ ▼
187
187
  ┌─────────────────────────┐ ┌─────────────────────────┐ This is new.
188
- │ 📱 Native Window │ │ 🖥️ Terminal Console │ , O
188
+ │ 📱 Native Window │ │ 🖥️ The Command Line │ , O
189
189
  │ ┌─────────────────┐ │ │ ┌─────────────────────┐ │ \\ . O
190
190
  │ │ Web Browser │ │ │ │ nix develop (./run) │ │ |\\/| o
191
191
  │ │ (Bundled) │ │ │ │ Starting servers... │ │ / " '\
@@ -571,7 +571,7 @@ Type `jn` at the `(nix)` prompt, wait for the JupyterLab tab to open, then run t
571
571
 
572
572
  | Problem | Solution |
573
573
  |---------|----------|
574
- | `nix: command not found` | You didn't restart your terminal after Nix installation |
574
+ | `nix: command not found` | Close the window and open a new one; Nix needs a fresh window after it installs |
575
575
  | Browser doesn't open automatically | Manually visit `http://localhost:5001` and `http://localhost:8888` |
576
576
  | `Permission denied` errors | Make sure you can write to `~/pipulate` directory |
577
577
  | Port conflicts | Kill processes on ports 5001/8888: `lsof -ti:5001 \| xargs kill -9` |
@@ -599,7 +599,7 @@ If you are a developer comfortable with tools like Homebrew and `pipx`, you can
599
599
  brew install pipx
600
600
  ```
601
601
 
602
- **Step 2: Install the Pipulate CLI**
602
+ **Step 2: Install the `pipulate` command**
603
603
 
604
604
  Use `pipx` to install the `pipulate` command-line tool. This will not cause conflicts with your system Python.
605
605
 
@@ -814,7 +814,7 @@ Traditional development follows DRY principles, creating abstract, complex syste
814
814
 
815
815
  ## Developer Setup & Environment Notes
816
816
 
817
- **Nix Environment Activation:** Always run `nix develop` from the `~/pipulate` directory *before* running any project commands (`python server.py`, `pip install`, etc.) in a new terminal. This ensures you are using the correct dependencies defined in `flake.nix`.
817
+ **Nix Environment Activation:** Always run `nix develop` from the `~/pipulate` directory *before* running any project commands (`python server.py`, `pip install`, etc.) in a new window. This ensures you are using the correct dependencies defined in `flake.nix`.
818
818
 
819
819
  **Interactive vs. Quiet Shell:**
820
820
 
@@ -12,7 +12,7 @@ Usage:
12
12
  pipulate
13
13
  """
14
14
 
15
- __version__ = "2.73"
15
+ __version__ = "2.75"
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.73"
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__ = "Follow the White Rabbit"
26
+ __version_description__ = "Refined 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".
@@ -11,6 +11,8 @@ import os
11
11
  import wave
12
12
  import subprocess
13
13
  import tempfile
14
+ import re
15
+ import shutil
14
16
  import signal
15
17
  import sys
16
18
  from pathlib import Path
@@ -92,6 +94,13 @@ def _quiet_hub():
92
94
  # to fetch the voice and spend seconds before anyone had asked to speak or
93
95
  # been asked whether it may; that was also the import tax the tool roster
94
96
  # measured at ~3.8 s.
97
+ # AMENDED 2026-10-01 (THE VOICE ASKS ONCE, OUT LOUD): first_contact() below,
98
+ # run once per machine right before the first menu, prints what the voice is,
99
+ # fetches the model with setup_voice_model's own one-line disclosure, and lets
100
+ # the voice ask the question itself, because a voice nobody has heard is a
101
+ # voice nobody can answer for (CONSENT CANNOT PRECEDE ITS OBJECT). The download
102
+ # is disclosed, never asked, and still never at import. The walk's card asks
103
+ # before it downloads, as before, and stays quiet once any answer is recorded.
95
104
  # ---------------------------------------------------------------------------
96
105
  VOICE_CONSENT_FILE = Path.home() / ".config" / "pipulate" / "voice"
97
106
  VOICE_CARD = (
@@ -120,7 +129,7 @@ def voice_consent() -> str:
120
129
  return "unset"
121
130
 
122
131
 
123
- def ask_voice_consent(later_hint: str = "voice", force: bool = False) -> str:
132
+ def ask_voice_consent(later_hint: str = "talk", force: bool = False) -> str:
124
133
  """Ask on /dev/tty and record the answer. Returns 'yes', 'no' or 'unavailable'.
125
134
 
126
135
  Asks only when nothing is recorded, unless force is set (the `voice` word),
@@ -150,15 +159,307 @@ def ask_voice_consent(later_hint: str = "voice", force: bool = False) -> str:
150
159
  finally:
151
160
  tty.close()
152
161
  decision = "yes" if answer.strip().lower() in ("y", "yes") else "no"
162
+ error = _record_voice_answer(decision)
163
+ if error is not None:
164
+ print(f"(could not record the answer, so it will be asked again: {error})")
165
+ if decision == "no":
166
+ print(f"Staying quiet. To turn the voice on later, type: {later_hint}")
167
+ return decision
168
+
169
+ TALK_ON_WORDS = ("on", "yes", "y", "1", "true")
170
+ TALK_OFF_WORDS = ("off", "no", "n", "0", "false")
171
+
172
+
173
+ def _record_voice_answer(decision: str):
174
+ """Write 'yes' or 'no' as the recorded answer. Returns None, or the OSError.
175
+
176
+ THE ONE WRITER (2026-09-30): the card and `talk` both record here, so the
177
+ format voice_consent() reads has one spelling, the word and a newline.
178
+ """
153
179
  try:
154
180
  VOICE_CONSENT_FILE.parent.mkdir(parents=True, exist_ok=True)
155
181
  VOICE_CONSENT_FILE.write_text(decision + "\n", encoding="utf-8")
156
182
  except OSError as exc:
157
- print(f"(could not record the answer, so it will be asked again: {exc})")
158
- if decision == "no":
159
- print(f"Staying quiet. To turn the voice on later, type: {later_hint}")
183
+ return exc
184
+ return None
185
+
186
+
187
+ def talk_command(args) -> int:
188
+ """`talk` flips narration; `talk on` and `talk off` set it. Returns an exit code.
189
+
190
+ THE TALK FLIP (2026-09-30). The recorded answer is one word, yes or no,
191
+ in VOICE_CONSENT_FILE, and voice_consent() reads it fresh on every call,
192
+ so a flip holds at the next spoken line in every process, a running
193
+ server included, with nothing restarted. Nothing recorded runs the card
194
+ first, whatever the argument: the card carries the download disclosure,
195
+ and talk writes only over an answer the card has recorded, so a flip to
196
+ on never downloads the voice undisclosed. PIPULATE_VOICE set to a word
197
+ voice_consent() reads outranks the file, so talk names it and writes
198
+ nothing. The state printed is read back through the barrier after the
199
+ write, never inferred from the request. Exit 0 when a state was
200
+ printed, 1 when nothing could be recorded, 2 for a word talk does not
201
+ know.
202
+ """
203
+ words = [arg.strip().lower() for arg in args]
204
+ if len(words) > 1 or (words and words[0] not in TALK_ON_WORDS + TALK_OFF_WORDS):
205
+ print("usage: talk [on|off] (bare talk flips narration)")
206
+ return 2
207
+ want = None
208
+ if words:
209
+ want = "yes" if words[0] in TALK_ON_WORDS else "no"
210
+ raw = os.environ.get("PIPULATE_VOICE", "").strip()
211
+ if raw.lower() in ("0", "no", "off", "false", "1", "yes", "on", "true"):
212
+ state = "on" if voice_consent() == "yes" else "off"
213
+ print(f"narration: {state} (PIPULATE_VOICE={raw!r} decides in this shell, so the "
214
+ "recorded answer was not read or changed; unset it to use talk)")
215
+ return 0
216
+ if raw:
217
+ print(f"(PIPULATE_VOICE={raw!r} is not a word the speaker reads; the recorded answer decides)")
218
+ error = None
219
+ recorded = voice_consent()
220
+ if recorded == "unset":
221
+ if ask_voice_consent(later_hint="talk") == "unavailable":
222
+ print("talk: nothing is recorded yet and there is no keyboard to ask on; type talk at the command line.")
223
+ return 1
224
+ else:
225
+ decision = want or ("no" if recorded == "yes" else "yes")
226
+ if decision != recorded:
227
+ error = _record_voice_answer(decision)
228
+ if error is not None:
229
+ print(f"talk: could not record the answer: {error}")
230
+ after = voice_consent()
231
+ if after == "unset":
232
+ print("talk: no answer is recorded, so narration stays off.")
233
+ return 1
234
+ state = "on" if after == "yes" else "off"
235
+ note = ", unchanged" if after == recorded else ""
236
+ print(f"narration: {state}{note} (recorded in {VOICE_CONSENT_FILE})")
237
+ if after == "yes" and chip_voice_system is not None:
238
+ result = chip_voice_system.speak_text("This is Piper, reading a script. Narration is on.")
239
+ if not result.get("success"):
240
+ print(f"talk: nothing was heard: {result.get('error')}")
241
+ return 1 if error is not None else 0
242
+
243
+
244
+ FIRST_CONTACT_INTRO = ("This workshop can read its steps aloud with Piper, "
245
+ "a small voice that runs on this computer.")
246
+ FIRST_CONTACT_LINE = ("This is Piper, reading a script. If you can hear this, "
247
+ "press Enter. Type n to keep it quiet.")
248
+
249
+ # THE DOOR SPEAKS (2026-10-02, half two; half one is install.sh's door_row,
250
+ # which writes .door into every install made through a door). first_contact()
251
+ # reads .door and says that door's words. With no .door, as in a git clone,
252
+ # nothing below is used and the card is the one above, unchanged. The
253
+ # operator's five rulings of 2026-10-02, and where each one lives:
254
+ # 1. the 60 MB line stays, printed only when a download happens: that is
255
+ # setup_voice_model's own line already, so nothing here repeats it;
256
+ # 2. the welcome lines print either way and are spoken only when the
257
+ # voice is on: _show_welcome;
258
+ # 3. the welcome shows once per install, even on a computer that answered
259
+ # the voice question in an earlier install: _mark_door_welcomed writes
260
+ # welcomed= into .door, and flake.nix's gate opens until it is there;
261
+ # 4. "No local AI found yet." rather than "installed", because found is
262
+ # what a lookup knows: local_ai_line;
263
+ # 5. the folder spelled a letter at a time in the spoken install message:
264
+ # flake.nix, TTS_DIR_SPELLED.
265
+ DOOR_FILE = Path(__file__).resolve().parent.parent / ".door"
266
+ DOOR_ASK_LINE = "If you can hear this, press Enter. Type n to keep it quiet."
267
+
268
+
269
+ def read_door(path=None) -> Dict[str, Any]:
270
+ """The .door at the repo root as a dict, or {} when there is none.
271
+
272
+ One key=value per line, the way install.sh writes it. Lines starting
273
+ with # are skipped and an empty value counts as absent. welcome repeats,
274
+ so it is a list in file order; any other key keeps its last value.
275
+ """
276
+ door: Dict[str, Any] = {}
277
+ try:
278
+ text = Path(path or DOOR_FILE).read_text(encoding="utf-8")
279
+ except OSError:
280
+ return door
281
+ for line in text.splitlines():
282
+ if line.lstrip().startswith("#") or "=" not in line:
283
+ continue
284
+ key, value = (part.strip() for part in line.split("=", 1))
285
+ if not key or not value:
286
+ continue
287
+ if key == "welcome":
288
+ door.setdefault("welcome", []).append(value)
289
+ else:
290
+ door[key] = value
291
+ return door
292
+
293
+
294
+ def local_ai_line() -> str:
295
+ """One sentence about local AI that claims only what this lookup found.
296
+
297
+ ATTRIBUTED-VOICE: detected names an act, so the act is here, and it is
298
+ only a lookup: an ollama on PATH, or the Ollama app in /Applications.
299
+ Nothing is started, asked or sent.
300
+ """
301
+ if shutil.which("ollama") or Path("/Applications/Ollama.app").exists():
302
+ return "Local AI detected but not used."
303
+ return "No local AI found yet."
304
+
305
+
306
+ def door_card(door: Dict[str, Any], local_ai: str = "") -> tuple:
307
+ """(intro, ask lines, welcome lines) for first contact. Pure: no file, no sound.
308
+
309
+ A door with no workshop line, or no door at all, gets today's intro and
310
+ line exactly and no welcome. The ask lines print one per line and are
311
+ spoken as one.
312
+ """
313
+ workshop = door.get("workshop", "")
314
+ if not workshop:
315
+ return FIRST_CONTACT_INTRO, [FIRST_CONTACT_LINE], []
316
+ first = f"This is only text-to-speech. {local_ai}".strip()
317
+ return workshop, [first, DOOR_ASK_LINE], list(door.get("welcome", []))
318
+
319
+
320
+ def _spoken(lines) -> str:
321
+ """A door's welcome lines as one spoken sentence; the screen keeps them as printed.
322
+
323
+ A line that ends with no punctuation gets a comma, so the voice pauses
324
+ where the screen breaks, and Q/A loses its slash. Read 2026-10-02 in a
325
+ sandbox through the espeak phonemizer inside piper-tts 1.6.0, the version
326
+ requirements.txt pins, and not heard on any computer here: Q/A comes out
327
+ as "queue slash uh", and QA as Q, A.
328
+ """
329
+ last = len(lines) - 1
330
+ text = " ".join(line if i == last or line[-1:] in ".!?,;:" else line + ","
331
+ for i, line in enumerate(lines))
332
+ return re.sub(r"\b([A-Z])/([A-Z])\b", r"\1\2", text)
333
+
334
+
335
+ def _mark_door_welcomed(path=None):
336
+ """Append welcomed=<date> to .door. Returns None, or the OSError."""
337
+ import datetime
338
+ target = Path(path or DOOR_FILE)
339
+ try:
340
+ text = target.read_text(encoding="utf-8")
341
+ lead = "" if not text or text.endswith("\n") else "\n"
342
+ with target.open("a", encoding="utf-8") as handle:
343
+ handle.write(lead + "# Written by first_contact() the day this door's welcome was shown. "
344
+ "Delete the welcomed line to see it again.\n"
345
+ "welcomed=" + datetime.date.today().isoformat() + "\n")
346
+ except OSError as exc:
347
+ return exc
348
+ return None
349
+
350
+
351
+ def _show_welcome(door: Dict[str, Any], speak: bool) -> None:
352
+ """Print the door's welcome lines, speak them only if speak, then mark .door.
353
+
354
+ Nothing happens without a .door, or once welcomed= is in it. The voice
355
+ loads before the lines print, so a 60 MB line, when there is one, comes
356
+ first, and the words on screen arrive with the words spoken. A door with
357
+ no welcome lines is marked all the same, so the flake's gate shuts for it.
358
+ """
359
+ if not door or door.get("welcomed"):
360
+ return
361
+ lines = list(door.get("welcome", []))
362
+ voice = chip_voice_system if (speak and lines) else None
363
+ unheard = None
364
+ if voice is not None and not voice.ensure_voice():
365
+ unheard = voice.last_error or "the voice did not load"
366
+ voice = None
367
+ for line in lines:
368
+ print(line, flush=True)
369
+ if voice is not None:
370
+ result = voice.speak_text(_spoken(lines))
371
+ if not result.get("success") and not result.get("declined"):
372
+ unheard = result.get("error") or "nothing was heard"
373
+ if unheard:
374
+ print(f"(the welcome was printed but not heard: {str(unheard).splitlines()[0][:120]})")
375
+ error = _mark_door_welcomed()
376
+ if error is not None:
377
+ print(f"(could not mark the welcome as shown, so it shows again next time: {error})")
378
+
379
+
380
+ def first_contact() -> str:
381
+ """Ask by voice, once per machine, right before the first menu.
382
+
383
+ THE VOICE ASKS ONCE, OUT LOUD (2026-10-01). flake.nix's runScript calls
384
+ this before the menu prints, only where no answer is recorded. One line
385
+ says what the voice is; setup_voice_model prints its own 60 MB line before
386
+ it fetches a model that is absent; then the voice speaks FIRST_CONTACT_LINE,
387
+ printed at the same moment, and one answer is read from the keyboard.
388
+ Enter keeps the voice and n turns it off. A voice that cannot load or play
389
+ records no and says why in one line, so the question does not repeat on
390
+ every entry and talk tries again later. Ctrl+C or an empty read records
391
+ nothing, so the question returns next time. The one line spoken before any
392
+ answer exists is the question itself, which is why synthesize_and_play
393
+ takes ceremony=True from here and from nowhere else. Returns env,
394
+ recorded, unavailable, silent, interrupted, yes, no or welcomed.
395
+
396
+ AMENDED 2026-10-02 (THE DOOR SPEAKS): an install made through a door has
397
+ a .door, and the card says that door's words (door_card, above): its
398
+ workshop line where the intro was, then "This is only text-to-speech."
399
+ with what a lookup found about local AI, then the same question. The
400
+ door's welcome lines follow the answer, printed always and spoken only
401
+ on yes. Where this computer answered the question in an earlier install,
402
+ flake.nix's gate still opens once for a .door with no welcomed= line:
403
+ nothing is asked, the workshop line and the welcome print, the welcome
404
+ is spoken if the recorded answer is yes, welcomed= is written, and the
405
+ return is welcomed.
406
+ """
407
+ if os.environ.get("PIPULATE_VOICE", "").strip():
408
+ return "env"
409
+ door = read_door()
410
+ recorded = voice_consent()
411
+ if recorded != "unset" and (not door or door.get("welcomed")):
412
+ return "recorded"
413
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
414
+ return "unavailable"
415
+ logger.setLevel(logging.CRITICAL)
416
+ if recorded != "unset":
417
+ if door.get("workshop"):
418
+ print(door["workshop"], flush=True)
419
+ _show_welcome(door, speak=recorded == "yes")
420
+ return "welcomed"
421
+ voice = chip_voice_system
422
+ intro, ask_lines, _ = door_card(door, local_ai_line() if door.get("workshop") else "")
423
+ try:
424
+ print(intro, flush=True)
425
+ played = False
426
+ if voice is not None and voice.ensure_voice():
427
+ for line in ask_lines:
428
+ print(line, flush=True)
429
+ played = voice.synthesize_and_play(" ".join(ask_lines), ceremony=True)
430
+ if not played:
431
+ reason = (voice.last_error if voice is not None else None) or "the voice did not load"
432
+ reason = reason.splitlines()[0][:120]
433
+ error = _record_voice_answer("no")
434
+ print(f"The voice could not play here ({reason}), so it stays off. Type talk to try again.")
435
+ if error is not None:
436
+ print(f"(could not record that, so this runs again next time: {error})")
437
+ _show_welcome(door, speak=False)
438
+ return "silent"
439
+ with open("/dev/tty", "r", encoding="utf-8") as tty:
440
+ print("Enter or n: ", end="", flush=True)
441
+ answer = tty.readline()
442
+ except KeyboardInterrupt:
443
+ print("\nNothing recorded; the question comes back next time.")
444
+ return "interrupted"
445
+ except OSError as exc:
446
+ print(f"\nNothing recorded ({exc}); the question comes back next time.")
447
+ return "interrupted"
448
+ if not answer:
449
+ print("\nNothing recorded; the question comes back next time.")
450
+ return "interrupted"
451
+ decision = "no" if answer.strip().lower().startswith("n") else "yes"
452
+ error = _record_voice_answer(decision)
453
+ if error is not None:
454
+ print(f"(could not record the answer, so it will be asked again: {error})")
455
+ if decision == "yes":
456
+ print("Voice on. Type talk any time to turn it off.")
457
+ else:
458
+ print("Staying quiet. Type talk any time to turn the voice on.")
459
+ _show_welcome(door, speak=decision == "yes")
160
460
  return decision
161
461
 
462
+
162
463
  class ChipVoiceSystem:
163
464
  """
164
465
  Voice synthesis system for Chip O'Theseus
@@ -233,8 +534,9 @@ class ChipVoiceSystem:
233
534
  def ensure_voice(self) -> bool:
234
535
  """Load the voice on first use, never at import. Returns readiness.
235
536
 
236
- Reached only after voice_consent() read yes, so the download the card
237
- announces happens after the answer and not before it.
537
+ Reached after voice_consent() read yes, or from first_contact() after
538
+ its printed line, where the download comes before the question so the
539
+ question can be heard.
238
540
  """
239
541
  if self.voice_ready:
240
542
  return True
@@ -286,7 +588,7 @@ class ChipVoiceSystem:
286
588
 
287
589
 
288
590
 
289
- def synthesize_and_play(self, text: str) -> bool:
591
+ def synthesize_and_play(self, text: str, ceremony: bool = False) -> bool:
290
592
  """
291
593
  Synthesize text and play audio (Mike's tested approach)
292
594
 
@@ -296,9 +598,11 @@ class ChipVoiceSystem:
296
598
  Returns:
297
599
  bool: True if successful, False otherwise
298
600
  """
299
- if voice_consent() != "yes":
601
+ if voice_consent() != "yes" and not ceremony:
300
602
  # THE BARRIER. Every path to sound passes here, and the answer is
301
603
  # read fresh each time, so a no recorded mid-session holds at once.
604
+ # ceremony=True has one caller, first_contact(), and one line: the
605
+ # question that asks whether the voice may speak, before any answer.
302
606
  self.last_error = "voice not allowed"
303
607
  logger.info("🎤 Voice not allowed; nothing spoken")
304
608
  return False
@@ -695,16 +999,28 @@ def test_memory_voice_integration():
695
999
  print("❌ No memories found to test")
696
1000
  return False
697
1001
 
1002
+ if __name__ == "__main__" and sys.argv[1:2] == ["talk"]:
1003
+ # `talk`, the word the menu lists under walk: flip, or set with on/off.
1004
+ raise SystemExit(talk_command(sys.argv[2:]))
1005
+ if __name__ == "__main__" and sys.argv[1:2] == ["first"]:
1006
+ # flake.nix's runScript calls this once per machine, right before the first
1007
+ # menu, and once more in an install made through a door, for its welcome;
1008
+ # it exits 0 whatever happened, so the menu always prints after it.
1009
+ try:
1010
+ first_contact()
1011
+ except Exception as _exc:
1012
+ print(f"(the voice question was skipped: {type(_exc).__name__}: {_exc})")
1013
+ raise SystemExit(0)
698
1014
  if __name__ == "__main__" and sys.argv[1:2] == ["ask"]:
699
- # `voice`, the word the card names: re-ask and record, then speak one
1015
+ # `voice`: re-ask the whole card and record the answer, then speak one
700
1016
  # sentence on yes so the audio path is heard rather than assumed.
701
1017
  _env = os.environ.get("PIPULATE_VOICE", "").strip()
702
1018
  if _env:
703
1019
  print(f"voice: PIPULATE_VOICE={_env!r} decides; unset it to be asked.")
704
1020
  raise SystemExit(0)
705
- _decision = ask_voice_consent(later_hint="voice", force=True)
1021
+ _decision = ask_voice_consent(later_hint="talk", force=True)
706
1022
  if _decision == "unavailable":
707
- print("voice: no terminal to ask on; run this from a terminal.")
1023
+ print("voice: no keyboard to ask on; type voice at the command line.")
708
1024
  raise SystemExit(1)
709
1025
  print(f"voice: {_decision} (recorded in {VOICE_CONSENT_FILE})")
710
1026
  if _decision == "yes" and chip_voice_system is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipulate
3
- Version: 2.73
3
+ Version: 2.75
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
@@ -86,7 +86,7 @@ Under both sits `flake.nix` with its `flake.lock`: the dependency inventory an S
86
86
 
87
87
  ## Quick Start: Be Running in 5 Minutes
88
88
 
89
- **Want to skip the philosophy and just see what this does? Paste this into your terminal:**
89
+ **Want to skip the philosophy and just see what this does? Open the command line (on a Mac, the app called Terminal) and paste this:**
90
90
 
91
91
  ```bash
92
92
  curl -fsSL https://pipulate.com/install.sh | bash
@@ -113,8 +113,8 @@ These few commands:
113
113
 
114
114
  ### 4. Run It Again!
115
115
 
116
- 1. You can just forcibly exit out of that Terminal it's running from.
117
- 2. Open a new Terminal, and once again:
116
+ 1. You can just close the window it's running in.
117
+ 2. Open a new one, and once again:
118
118
 
119
119
  ```bash
120
120
  cd ~/pipulate
@@ -242,7 +242,7 @@ Pipulate is a **local-first, single-tenant desktop app framework** featuring AI-
242
242
  │ │
243
243
  ▼ ▼
244
244
  ┌─────────────────────────┐ ┌─────────────────────────┐ This is new.
245
- │ 📱 Native Window │ │ 🖥️ Terminal Console │ , O
245
+ │ 📱 Native Window │ │ 🖥️ The Command Line │ , O
246
246
  │ ┌─────────────────┐ │ │ ┌─────────────────────┐ │ \\ . O
247
247
  │ │ Web Browser │ │ │ │ nix develop (./run) │ │ |\\/| o
248
248
  │ │ (Bundled) │ │ │ │ Starting servers... │ │ / " '\
@@ -628,7 +628,7 @@ Type `jn` at the `(nix)` prompt, wait for the JupyterLab tab to open, then run t
628
628
 
629
629
  | Problem | Solution |
630
630
  |---------|----------|
631
- | `nix: command not found` | You didn't restart your terminal after Nix installation |
631
+ | `nix: command not found` | Close the window and open a new one; Nix needs a fresh window after it installs |
632
632
  | Browser doesn't open automatically | Manually visit `http://localhost:5001` and `http://localhost:8888` |
633
633
  | `Permission denied` errors | Make sure you can write to `~/pipulate` directory |
634
634
  | Port conflicts | Kill processes on ports 5001/8888: `lsof -ti:5001 \| xargs kill -9` |
@@ -656,7 +656,7 @@ If you are a developer comfortable with tools like Homebrew and `pipx`, you can
656
656
  brew install pipx
657
657
  ```
658
658
 
659
- **Step 2: Install the Pipulate CLI**
659
+ **Step 2: Install the `pipulate` command**
660
660
 
661
661
  Use `pipx` to install the `pipulate` command-line tool. This will not cause conflicts with your system Python.
662
662
 
@@ -871,7 +871,7 @@ Traditional development follows DRY principles, creating abstract, complex syste
871
871
 
872
872
  ## Developer Setup & Environment Notes
873
873
 
874
- **Nix Environment Activation:** Always run `nix develop` from the `~/pipulate` directory *before* running any project commands (`python server.py`, `pip install`, etc.) in a new terminal. This ensures you are using the correct dependencies defined in `flake.nix`.
874
+ **Nix Environment Activation:** Always run `nix develop` from the `~/pipulate` directory *before* running any project commands (`python server.py`, `pip install`, etc.) in a new window. This ensures you are using the correct dependencies defined in `flake.nix`.
875
875
 
876
876
  **Interactive vs. Quiet Shell:**
877
877
 
@@ -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.73"
8
+ version = "2.75"
9
9
  authors = [
10
10
  { name="Mike Levin", email="pipulate@gmail.com" },
11
11
  ]
@@ -268,7 +268,13 @@ def _document_candidates(cdp_events: list, domain: str, final_url: str = "") ->
268
268
 
269
269
 
270
270
  def _capture_checkpoint(stdin=None, stdout=None, before_prompt=None) -> dict:
271
- """Require an explicit CAPTURE token from the human's keyboard.
271
+ """Wait for Enter from the human's keyboard: the page has settled.
272
+
273
+ ENTER, NOT A WORD (2026-10-02, the operator's ruling). The fence used to
274
+ want the token CAPTURE, and one wrong letter ended the whole walk. Its
275
+ only job is a signal that the page has settled, and Enter is that signal;
276
+ Ctrl+C is the way out, as everywhere else. EOF still refuses, so a pipe
277
+ or a dead descriptor can never save a page.
272
278
 
273
279
  /DEV/TTY PREFERENCE (convicted 2026-07-29, public_walk stop 1): the
274
280
  inherited sys.stdin returned INSTANT EOF at the CAPTURE> prompt -- the
@@ -316,13 +322,25 @@ def _capture_checkpoint(stdin=None, stdout=None, before_prompt=None) -> dict:
316
322
  output_stream.write(f"\n(checkpoint narration unavailable: {exc})\n")
317
323
  else:
318
324
  output_stream.write(
319
- "\nWhen the page you want is ready, type CAPTURE and press Enter.\n"
325
+ "\nWhen the page you want is ready, press Enter here.\n"
326
+ "Ctrl+C stops without saving it.\n"
320
327
  )
321
- output_stream.write(
322
- "Any other response aborts without capturing artifacts.\n"
323
- "CAPTURE> "
324
- )
328
+ # A CALLER'S HOOK OWNS EVERYTHING BEFORE THE PROMPT (2026-10-02):
329
+ # the default sentences print only when no hook was given, and
330
+ # the prompt itself says what Enter does.
331
+ output_stream.write("Press Enter to save the page. ")
325
332
  output_stream.flush()
333
+ # TYPE-AHEAD IS NOT THE SIGNAL (2026-10-02, read off a walk whose
334
+ # third page printed "Press Enter to save the page. Saved." on one
335
+ # line): an Enter pressed while the page was still loading waited
336
+ # in the terminal and answered this prompt the moment it appeared.
337
+ # Keys typed before the prompt are dropped, so only an Enter after
338
+ # the bell counts. A stream that is not a terminal is left alone.
339
+ try:
340
+ import termios
341
+ termios.tcflush(input_stream.fileno(), termios.TCIFLUSH)
342
+ except Exception:
343
+ pass
326
344
  response = input_stream.readline()
327
345
  except KeyboardInterrupt:
328
346
  return {
@@ -340,23 +358,9 @@ def _capture_checkpoint(stdin=None, stdout=None, before_prompt=None) -> dict:
340
358
  "success": False,
341
359
  "error": "interactive checkpoint reached EOF",
342
360
  }
343
- # CASE-INSENSITIVE ON PURPOSE (banked 2026-08-01). The token's safety
344
- # comes entirely from being IMPOSSIBLE TO PRODUCE BY ACCIDENT: no pipe,
345
- # no EOF, no stray Enter, no dead descriptor and no automation emits
346
- # these seven letters. Lowercase is exactly as impossible as uppercase,
347
- # so the shift key was a tax with no revenue -- and it was collected at
348
- # the newcomer's first handshake, where a refusal reads as "this thing
349
- # is broken" rather than "you missed a key."
350
- # DELIBERATELY NOT A RETRY LOOP. A retry gives the fence a SECOND STATE,
351
- # and "nothing was written" stops being provable in one sentence. One
352
- # read, one comparison, one branch, and every non-match returns before
353
- # mkdir. A wrong answer still costs the ENTIRE ride, because a non-token
354
- # answer is the human's ABORT, and abort must be as instant as consent.
355
- if response.strip().upper() != "CAPTURE":
356
- return {
357
- "success": False,
358
- "error": "interactive checkpoint was not confirmed",
359
- }
361
+
362
+ # ANY LINE IS THE SIGNAL (2026-10-02): whatever was typed before Enter
363
+ # is ignored, so there is no wrong answer to end a walk on.
360
364
  return {"success": True}
361
365
  finally:
362
366
  if tty_handle is not None:
@@ -604,7 +608,9 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
604
608
  temp_profile = True
605
609
  logger.info(f"👻 Using temporary profile: {profile_path}")
606
610
 
607
- if verbose:
611
+ # A guided walk's own step line says the browser is opening, so this
612
+ # prints only for unattended captures (2026-10-02, say a thing once).
613
+ if verbose and not interactive:
608
614
  print("Opening the browser; waiting for the page...", flush=True)
609
615
  music_proc = _start_scrape_music(verbose=verbose)
610
616
  logger.info(f"🚀 Initializing undetected-chromedriver (Headless: {headless})...")
@@ -645,10 +651,22 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
645
651
  else:
646
652
  raise
647
653
 
654
+ # THE PAGE THE SERVER SENDS NOW (2026-10-02, read off a walk whose
655
+ # headers.json carried the Date and ETag of a fetch three hours older
656
+ # than the walk): a persistent profile keeps an HTTP cache, and a page
657
+ # sent without Cache-Control is reused from it, without asking the
658
+ # server, for a tenth of its age since Last-Modified. The walk saved
659
+ # that copy and called it the wire. The cache is off for every capture;
660
+ # cookies and sign-ins are kept elsewhere and are untouched.
661
+ try:
662
+ driver.execute_cdp_cmd("Network.enable", {})
663
+ driver.execute_cdp_cmd("Network.setCacheDisabled", {"cacheDisabled": True})
664
+ except Exception as exc:
665
+ logger.warning(f"⚠️ Could not turn off the browser cache; this page may come from it: {exc}")
648
666
  logger.info(f"Navigating to: {url}")
649
667
  driver.get(url)
650
668
 
651
- # A human-guided ride already has the CAPTURE fence as its settle
669
+ # A human-guided ride already has the Enter fence as its settle
652
670
  # detector. The 20-second staleness wait exists for unattended sigil
653
671
  # scrapes, where a security interstitial may reload underneath us.
654
672
  if not interactive:
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