pipulate 2.73__tar.gz → 2.74__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.74}/.agents/skills/journal/references/index.md +8 -5
  2. {pipulate-2.73 → pipulate-2.74}/PKG-INFO +8 -8
  3. {pipulate-2.73 → pipulate-2.74}/README.md +7 -7
  4. {pipulate-2.73 → pipulate-2.74}/__init__.py +2 -2
  5. {pipulate-2.73 → pipulate-2.74}/imports/voice_synthesis.py +172 -11
  6. {pipulate-2.73 → pipulate-2.74}/pipulate.egg-info/PKG-INFO +8 -8
  7. {pipulate-2.73 → pipulate-2.74}/pyproject.toml +1 -1
  8. {pipulate-2.73 → pipulate-2.74}/.agents/skills/gsc-readonly/SKILL.md +0 -0
  9. {pipulate-2.73 → pipulate-2.74}/.agents/skills/hello-workflow/SKILL.md +0 -0
  10. {pipulate-2.73 → pipulate-2.74}/.agents/skills/journal/SKILL.md +0 -0
  11. {pipulate-2.73 → pipulate-2.74}/.agents/skills/pipulate/SKILL.md +0 -0
  12. {pipulate-2.73 → pipulate-2.74}/.agents/skills/roles/SKILL.md +0 -0
  13. {pipulate-2.73 → pipulate-2.74}/.agents/skills/sheets-readonly/SKILL.md +0 -0
  14. {pipulate-2.73 → pipulate-2.74}/AGENTS.md +0 -0
  15. {pipulate-2.73 → pipulate-2.74}/AUDIT.md +0 -0
  16. {pipulate-2.73 → pipulate-2.74}/LICENSE +0 -0
  17. {pipulate-2.73 → pipulate-2.74}/MANIFEST.in +0 -0
  18. {pipulate-2.73 → pipulate-2.74}/apps/010_introduction.py +0 -0
  19. {pipulate-2.73 → pipulate-2.74}/apps/015_config.py +0 -0
  20. {pipulate-2.73 → pipulate-2.74}/apps/020_profiles.py +0 -0
  21. {pipulate-2.73 → pipulate-2.74}/apps/025_aspect.py +0 -0
  22. {pipulate-2.73 → pipulate-2.74}/apps/030_roles.py +0 -0
  23. {pipulate-2.73 → pipulate-2.74}/apps/040_hello_workflow.py +0 -0
  24. {pipulate-2.73 → pipulate-2.74}/apps/050_documentation.py +0 -0
  25. {pipulate-2.73 → pipulate-2.74}/apps/060_tasks.py +0 -0
  26. {pipulate-2.73 → pipulate-2.74}/apps/070_history.py +0 -0
  27. {pipulate-2.73 → pipulate-2.74}/apps/110_parameter_buster.py +0 -0
  28. {pipulate-2.73 → pipulate-2.74}/apps/120_link_graph.py +0 -0
  29. {pipulate-2.73 → pipulate-2.74}/apps/130_gap_analysis.py +0 -0
  30. {pipulate-2.73 → pipulate-2.74}/apps/200_workflow_genesis.py +0 -0
  31. {pipulate-2.73 → pipulate-2.74}/apps/210_widget_examples.py +0 -0
  32. {pipulate-2.73 → pipulate-2.74}/apps/220_roadmap.py +0 -0
  33. {pipulate-2.73 → pipulate-2.74}/apps/230_dev_assistant.py +0 -0
  34. {pipulate-2.73 → pipulate-2.74}/apps/240_simon_mcp.py +0 -0
  35. {pipulate-2.73 → pipulate-2.74}/apps/300_blank_placeholder.py +0 -0
  36. {pipulate-2.73 → pipulate-2.74}/apps/400_botify_trifecta.py +0 -0
  37. {pipulate-2.73 → pipulate-2.74}/apps/440_browser_automation.py +0 -0
  38. {pipulate-2.73 → pipulate-2.74}/apps/450_stream_simulator.py +0 -0
  39. {pipulate-2.73 → pipulate-2.74}/apps/510_text_field.py +0 -0
  40. {pipulate-2.73 → pipulate-2.74}/apps/520_text_area.py +0 -0
  41. {pipulate-2.73 → pipulate-2.74}/apps/530_dropdown.py +0 -0
  42. {pipulate-2.73 → pipulate-2.74}/apps/540_checkboxes.py +0 -0
  43. {pipulate-2.73 → pipulate-2.74}/apps/550_radios.py +0 -0
  44. {pipulate-2.73 → pipulate-2.74}/apps/560_range.py +0 -0
  45. {pipulate-2.73 → pipulate-2.74}/apps/570_switch.py +0 -0
  46. {pipulate-2.73 → pipulate-2.74}/apps/580_upload.py +0 -0
  47. {pipulate-2.73 → pipulate-2.74}/apps/610_markdown.py +0 -0
  48. {pipulate-2.73 → pipulate-2.74}/apps/620_mermaid.py +0 -0
  49. {pipulate-2.73 → pipulate-2.74}/apps/630_prism.py +0 -0
  50. {pipulate-2.73 → pipulate-2.74}/apps/640_javascript.py +0 -0
  51. {pipulate-2.73 → pipulate-2.74}/apps/710_pandas.py +0 -0
  52. {pipulate-2.73 → pipulate-2.74}/apps/720_rich.py +0 -0
  53. {pipulate-2.73 → pipulate-2.74}/apps/730_matplotlib.py +0 -0
  54. {pipulate-2.73 → pipulate-2.74}/apps/810_webbrowser.py +0 -0
  55. {pipulate-2.73 → pipulate-2.74}/apps/820_selenium.py +0 -0
  56. {pipulate-2.73 → pipulate-2.74}/apps/830_pico_slider.py +0 -0
  57. {pipulate-2.73 → pipulate-2.74}/cli.py +0 -0
  58. {pipulate-2.73 → pipulate-2.74}/config.py +0 -0
  59. {pipulate-2.73 → pipulate-2.74}/imports/__init__.py +0 -0
  60. {pipulate-2.73 → pipulate-2.74}/imports/ai_dictdb.py +0 -0
  61. {pipulate-2.73 → pipulate-2.74}/imports/ai_tool_discovery_simple_parser.py +0 -0
  62. {pipulate-2.73 → pipulate-2.74}/imports/append_only_conversation.py +0 -0
  63. {pipulate-2.73 → pipulate-2.74}/imports/ascii_displays.py +0 -0
  64. {pipulate-2.73 → pipulate-2.74}/imports/botify/__init__.py +0 -0
  65. {pipulate-2.73 → pipulate-2.74}/imports/botify/code_generators.py +0 -0
  66. {pipulate-2.73 → pipulate-2.74}/imports/botify/true_schema_discoverer.py +0 -0
  67. {pipulate-2.73 → pipulate-2.74}/imports/botify_code_generation.py +0 -0
  68. {pipulate-2.73 → pipulate-2.74}/imports/crud.py +0 -0
  69. {pipulate-2.73 → pipulate-2.74}/imports/database_safety_wrapper.py +0 -0
  70. {pipulate-2.73 → pipulate-2.74}/imports/dom_processing/__init__.py +0 -0
  71. {pipulate-2.73 → pipulate-2.74}/imports/dom_processing/ai_dom_beautifier.py +0 -0
  72. {pipulate-2.73 → pipulate-2.74}/imports/dom_processing/enhanced_dom_processor.py +0 -0
  73. {pipulate-2.73 → pipulate-2.74}/imports/durable_backup_system.py +0 -0
  74. {pipulate-2.73 → pipulate-2.74}/imports/mcp_orchestrator.py +0 -0
  75. {pipulate-2.73 → pipulate-2.74}/imports/server_logging.py +0 -0
  76. {pipulate-2.73 → pipulate-2.74}/imports/stream_orchestrator.py +0 -0
  77. {pipulate-2.73 → pipulate-2.74}/pipulate/__init__.py +0 -0
  78. {pipulate-2.73 → pipulate-2.74}/pipulate/core.py +0 -0
  79. {pipulate-2.73 → pipulate-2.74}/pipulate.egg-info/SOURCES.txt +0 -0
  80. {pipulate-2.73 → pipulate-2.74}/pipulate.egg-info/dependency_links.txt +0 -0
  81. {pipulate-2.73 → pipulate-2.74}/pipulate.egg-info/entry_points.txt +0 -0
  82. {pipulate-2.73 → pipulate-2.74}/pipulate.egg-info/requires.txt +0 -0
  83. {pipulate-2.73 → pipulate-2.74}/pipulate.egg-info/top_level.txt +0 -0
  84. {pipulate-2.73 → pipulate-2.74}/server.py +0 -0
  85. {pipulate-2.73 → pipulate-2.74}/setup.cfg +0 -0
  86. {pipulate-2.73 → pipulate-2.74}/tests/test_apply_airlock.py +0 -0
  87. {pipulate-2.73 → pipulate-2.74}/tests/test_mck_rep2.py +0 -0
  88. {pipulate-2.73 → pipulate-2.74}/tools/__init__.py +0 -0
  89. {pipulate-2.73 → pipulate-2.74}/tools/advanced_automation_tools.py +0 -0
  90. {pipulate-2.73 → pipulate-2.74}/tools/botify_tools.py +0 -0
  91. {pipulate-2.73 → pipulate-2.74}/tools/connector_tools.py +0 -0
  92. {pipulate-2.73 → pipulate-2.74}/tools/conversation_tools.py +0 -0
  93. {pipulate-2.73 → pipulate-2.74}/tools/dom_tools.py +0 -0
  94. {pipulate-2.73 → pipulate-2.74}/tools/keychain_tools.py +0 -0
  95. {pipulate-2.73 → pipulate-2.74}/tools/llm_optics.py +0 -0
  96. {pipulate-2.73 → pipulate-2.74}/tools/mcp_tools.py +0 -0
  97. {pipulate-2.73 → pipulate-2.74}/tools/scraper_tools.py +0 -0
  98. {pipulate-2.73 → pipulate-2.74}/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-01 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. 1508 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,9 @@ 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-01] [The Scrollback Rule: Engineering Full-Width Terminal Dividers for Replayable Logs](https://mikelev.in/futureproof/scrollback-rule-clear-x-receipts/index.md)
31
+ - [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)
32
+ - [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
33
  - [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
34
  - [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
35
  - [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)
@@ -44,14 +47,14 @@ URL as: `https://mikelev.in/futureproof/{slug}/index.md`
44
47
  - [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
48
  - [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
49
  - [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-19] [48k] dual-model-failover-independent-backoff
56
+ - [2026-09-19] [193k] playing-pinball-in-the-age-of-ai-replayable-workflow-guide
57
+ - [2026-09-19] [72k] color-blue-pipe-inspecting-code
55
58
  - [2026-09-19] [294k] edna-mode-rules-for-ai-workflows
56
59
  - [2026-09-18] [207k] the-one-door-threshold-replayable-ai-workflows
57
60
  - [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.74
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.74"
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__ = "Doorways"
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".
@@ -92,6 +92,13 @@ def _quiet_hub():
92
92
  # to fetch the voice and spend seconds before anyone had asked to speak or
93
93
  # been asked whether it may; that was also the import tax the tool roster
94
94
  # measured at ~3.8 s.
95
+ # AMENDED 2026-10-01 (THE VOICE ASKS ONCE, OUT LOUD): first_contact() below,
96
+ # run once per machine right before the first menu, prints what the voice is,
97
+ # fetches the model with setup_voice_model's own one-line disclosure, and lets
98
+ # the voice ask the question itself, because a voice nobody has heard is a
99
+ # voice nobody can answer for (CONSENT CANNOT PRECEDE ITS OBJECT). The download
100
+ # is disclosed, never asked, and still never at import. The walk's card asks
101
+ # before it downloads, as before, and stays quiet once any answer is recorded.
95
102
  # ---------------------------------------------------------------------------
96
103
  VOICE_CONSENT_FILE = Path.home() / ".config" / "pipulate" / "voice"
97
104
  VOICE_CARD = (
@@ -120,7 +127,7 @@ def voice_consent() -> str:
120
127
  return "unset"
121
128
 
122
129
 
123
- def ask_voice_consent(later_hint: str = "voice", force: bool = False) -> str:
130
+ def ask_voice_consent(later_hint: str = "talk", force: bool = False) -> str:
124
131
  """Ask on /dev/tty and record the answer. Returns 'yes', 'no' or 'unavailable'.
125
132
 
126
133
  Asks only when nothing is recorded, unless force is set (the `voice` word),
@@ -150,15 +157,155 @@ def ask_voice_consent(later_hint: str = "voice", force: bool = False) -> str:
150
157
  finally:
151
158
  tty.close()
152
159
  decision = "yes" if answer.strip().lower() in ("y", "yes") else "no"
160
+ error = _record_voice_answer(decision)
161
+ if error is not None:
162
+ print(f"(could not record the answer, so it will be asked again: {error})")
163
+ if decision == "no":
164
+ print(f"Staying quiet. To turn the voice on later, type: {later_hint}")
165
+ return decision
166
+
167
+ TALK_ON_WORDS = ("on", "yes", "y", "1", "true")
168
+ TALK_OFF_WORDS = ("off", "no", "n", "0", "false")
169
+
170
+
171
+ def _record_voice_answer(decision: str):
172
+ """Write 'yes' or 'no' as the recorded answer. Returns None, or the OSError.
173
+
174
+ THE ONE WRITER (2026-09-30): the card and `talk` both record here, so the
175
+ format voice_consent() reads has one spelling, the word and a newline.
176
+ """
153
177
  try:
154
178
  VOICE_CONSENT_FILE.parent.mkdir(parents=True, exist_ok=True)
155
179
  VOICE_CONSENT_FILE.write_text(decision + "\n", encoding="utf-8")
156
180
  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}")
181
+ return exc
182
+ return None
183
+
184
+
185
+ def talk_command(args) -> int:
186
+ """`talk` flips narration; `talk on` and `talk off` set it. Returns an exit code.
187
+
188
+ THE TALK FLIP (2026-09-30). The recorded answer is one word, yes or no,
189
+ in VOICE_CONSENT_FILE, and voice_consent() reads it fresh on every call,
190
+ so a flip holds at the next spoken line in every process, a running
191
+ server included, with nothing restarted. Nothing recorded runs the card
192
+ first, whatever the argument: the card carries the download disclosure,
193
+ and talk writes only over an answer the card has recorded, so a flip to
194
+ on never downloads the voice undisclosed. PIPULATE_VOICE set to a word
195
+ voice_consent() reads outranks the file, so talk names it and writes
196
+ nothing. The state printed is read back through the barrier after the
197
+ write, never inferred from the request. Exit 0 when a state was
198
+ printed, 1 when nothing could be recorded, 2 for a word talk does not
199
+ know.
200
+ """
201
+ words = [arg.strip().lower() for arg in args]
202
+ if len(words) > 1 or (words and words[0] not in TALK_ON_WORDS + TALK_OFF_WORDS):
203
+ print("usage: talk [on|off] (bare talk flips narration)")
204
+ return 2
205
+ want = None
206
+ if words:
207
+ want = "yes" if words[0] in TALK_ON_WORDS else "no"
208
+ raw = os.environ.get("PIPULATE_VOICE", "").strip()
209
+ if raw.lower() in ("0", "no", "off", "false", "1", "yes", "on", "true"):
210
+ state = "on" if voice_consent() == "yes" else "off"
211
+ print(f"narration: {state} (PIPULATE_VOICE={raw!r} decides in this shell, so the "
212
+ "recorded answer was not read or changed; unset it to use talk)")
213
+ return 0
214
+ if raw:
215
+ print(f"(PIPULATE_VOICE={raw!r} is not a word the speaker reads; the recorded answer decides)")
216
+ error = None
217
+ recorded = voice_consent()
218
+ if recorded == "unset":
219
+ if ask_voice_consent(later_hint="talk") == "unavailable":
220
+ print("talk: nothing is recorded yet and there is no keyboard to ask on; type talk at the command line.")
221
+ return 1
222
+ else:
223
+ decision = want or ("no" if recorded == "yes" else "yes")
224
+ if decision != recorded:
225
+ error = _record_voice_answer(decision)
226
+ if error is not None:
227
+ print(f"talk: could not record the answer: {error}")
228
+ after = voice_consent()
229
+ if after == "unset":
230
+ print("talk: no answer is recorded, so narration stays off.")
231
+ return 1
232
+ state = "on" if after == "yes" else "off"
233
+ note = ", unchanged" if after == recorded else ""
234
+ print(f"narration: {state}{note} (recorded in {VOICE_CONSENT_FILE})")
235
+ if after == "yes" and chip_voice_system is not None:
236
+ result = chip_voice_system.speak_text("This is Piper, reading a script. Narration is on.")
237
+ if not result.get("success"):
238
+ print(f"talk: nothing was heard: {result.get('error')}")
239
+ return 1 if error is not None else 0
240
+
241
+
242
+ FIRST_CONTACT_INTRO = ("This workshop can read its steps aloud with Piper, "
243
+ "a small voice that runs on this computer.")
244
+ FIRST_CONTACT_LINE = ("This is Piper, reading a script. If you can hear this, "
245
+ "press Enter. Type n to keep it quiet.")
246
+
247
+
248
+ def first_contact() -> str:
249
+ """Ask by voice, once per machine, right before the first menu.
250
+
251
+ THE VOICE ASKS ONCE, OUT LOUD (2026-10-01). flake.nix's runScript calls
252
+ this before the menu prints, only where no answer is recorded. One line
253
+ says what the voice is; setup_voice_model prints its own 60 MB line before
254
+ it fetches a model that is absent; then the voice speaks FIRST_CONTACT_LINE,
255
+ printed at the same moment, and one answer is read from the keyboard.
256
+ Enter keeps the voice and n turns it off. A voice that cannot load or play
257
+ records no and says why in one line, so the question does not repeat on
258
+ every entry and talk tries again later. Ctrl+C or an empty read records
259
+ nothing, so the question returns next time. The one line spoken before any
260
+ answer exists is the question itself, which is why synthesize_and_play
261
+ takes ceremony=True from here and from nowhere else. Returns env,
262
+ recorded, unavailable, silent, interrupted, yes or no.
263
+ """
264
+ if os.environ.get("PIPULATE_VOICE", "").strip():
265
+ return "env"
266
+ if voice_consent() != "unset":
267
+ return "recorded"
268
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
269
+ return "unavailable"
270
+ logger.setLevel(logging.CRITICAL)
271
+ voice = chip_voice_system
272
+ try:
273
+ print(FIRST_CONTACT_INTRO, flush=True)
274
+ played = False
275
+ if voice is not None and voice.ensure_voice():
276
+ print(FIRST_CONTACT_LINE, flush=True)
277
+ played = voice.synthesize_and_play(FIRST_CONTACT_LINE, ceremony=True)
278
+ if not played:
279
+ reason = (voice.last_error if voice is not None else None) or "the voice did not load"
280
+ reason = reason.splitlines()[0][:120]
281
+ error = _record_voice_answer("no")
282
+ print(f"The voice could not play here ({reason}), so it stays off. Type talk to try again.")
283
+ if error is not None:
284
+ print(f"(could not record that, so this runs again next time: {error})")
285
+ return "silent"
286
+ with open("/dev/tty", "r", encoding="utf-8") as tty:
287
+ print("Enter or n: ", end="", flush=True)
288
+ answer = tty.readline()
289
+ except KeyboardInterrupt:
290
+ print("\nNothing recorded; the question comes back next time.")
291
+ return "interrupted"
292
+ except OSError as exc:
293
+ print(f"\nNothing recorded ({exc}); the question comes back next time.")
294
+ return "interrupted"
295
+ if not answer:
296
+ print("\nNothing recorded; the question comes back next time.")
297
+ return "interrupted"
298
+ decision = "no" if answer.strip().lower().startswith("n") else "yes"
299
+ error = _record_voice_answer(decision)
300
+ if error is not None:
301
+ print(f"(could not record the answer, so it will be asked again: {error})")
302
+ if decision == "yes":
303
+ print("Voice on. Type talk any time to turn it off.")
304
+ else:
305
+ print("Staying quiet. Type talk any time to turn the voice on.")
160
306
  return decision
161
307
 
308
+
162
309
  class ChipVoiceSystem:
163
310
  """
164
311
  Voice synthesis system for Chip O'Theseus
@@ -233,8 +380,9 @@ class ChipVoiceSystem:
233
380
  def ensure_voice(self) -> bool:
234
381
  """Load the voice on first use, never at import. Returns readiness.
235
382
 
236
- Reached only after voice_consent() read yes, so the download the card
237
- announces happens after the answer and not before it.
383
+ Reached after voice_consent() read yes, or from first_contact() after
384
+ its printed line, where the download comes before the question so the
385
+ question can be heard.
238
386
  """
239
387
  if self.voice_ready:
240
388
  return True
@@ -286,7 +434,7 @@ class ChipVoiceSystem:
286
434
 
287
435
 
288
436
 
289
- def synthesize_and_play(self, text: str) -> bool:
437
+ def synthesize_and_play(self, text: str, ceremony: bool = False) -> bool:
290
438
  """
291
439
  Synthesize text and play audio (Mike's tested approach)
292
440
 
@@ -296,9 +444,11 @@ class ChipVoiceSystem:
296
444
  Returns:
297
445
  bool: True if successful, False otherwise
298
446
  """
299
- if voice_consent() != "yes":
447
+ if voice_consent() != "yes" and not ceremony:
300
448
  # THE BARRIER. Every path to sound passes here, and the answer is
301
449
  # read fresh each time, so a no recorded mid-session holds at once.
450
+ # ceremony=True has one caller, first_contact(), and one line: the
451
+ # question that asks whether the voice may speak, before any answer.
302
452
  self.last_error = "voice not allowed"
303
453
  logger.info("🎤 Voice not allowed; nothing spoken")
304
454
  return False
@@ -695,16 +845,27 @@ def test_memory_voice_integration():
695
845
  print("❌ No memories found to test")
696
846
  return False
697
847
 
848
+ if __name__ == "__main__" and sys.argv[1:2] == ["talk"]:
849
+ # `talk`, the word the menu lists under walk: flip, or set with on/off.
850
+ raise SystemExit(talk_command(sys.argv[2:]))
851
+ if __name__ == "__main__" and sys.argv[1:2] == ["first"]:
852
+ # flake.nix's runScript calls this once per machine, right before the first
853
+ # menu; it exits 0 whatever happened, so the menu always prints after it.
854
+ try:
855
+ first_contact()
856
+ except Exception as _exc:
857
+ print(f"(the voice question was skipped: {type(_exc).__name__}: {_exc})")
858
+ raise SystemExit(0)
698
859
  if __name__ == "__main__" and sys.argv[1:2] == ["ask"]:
699
- # `voice`, the word the card names: re-ask and record, then speak one
860
+ # `voice`: re-ask the whole card and record the answer, then speak one
700
861
  # sentence on yes so the audio path is heard rather than assumed.
701
862
  _env = os.environ.get("PIPULATE_VOICE", "").strip()
702
863
  if _env:
703
864
  print(f"voice: PIPULATE_VOICE={_env!r} decides; unset it to be asked.")
704
865
  raise SystemExit(0)
705
- _decision = ask_voice_consent(later_hint="voice", force=True)
866
+ _decision = ask_voice_consent(later_hint="talk", force=True)
706
867
  if _decision == "unavailable":
707
- print("voice: no terminal to ask on; run this from a terminal.")
868
+ print("voice: no keyboard to ask on; type voice at the command line.")
708
869
  raise SystemExit(1)
709
870
  print(f"voice: {_decision} (recorded in {VOICE_CONSENT_FILE})")
710
871
  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.74
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.74"
9
9
  authors = [
10
10
  { name="Mike Levin", email="pipulate@gmail.com" },
11
11
  ]
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
File without changes