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.
- {pipulate-2.73 → pipulate-2.75}/.agents/skills/journal/references/index.md +12 -7
- {pipulate-2.73 → pipulate-2.75}/PKG-INFO +8 -8
- {pipulate-2.73 → pipulate-2.75}/README.md +7 -7
- {pipulate-2.73 → pipulate-2.75}/__init__.py +2 -2
- {pipulate-2.73 → pipulate-2.75}/imports/voice_synthesis.py +327 -11
- {pipulate-2.73 → pipulate-2.75}/pipulate.egg-info/PKG-INFO +8 -8
- {pipulate-2.73 → pipulate-2.75}/pyproject.toml +1 -1
- {pipulate-2.73 → pipulate-2.75}/tools/scraper_tools.py +43 -25
- {pipulate-2.73 → pipulate-2.75}/.agents/skills/gsc-readonly/SKILL.md +0 -0
- {pipulate-2.73 → pipulate-2.75}/.agents/skills/hello-workflow/SKILL.md +0 -0
- {pipulate-2.73 → pipulate-2.75}/.agents/skills/journal/SKILL.md +0 -0
- {pipulate-2.73 → pipulate-2.75}/.agents/skills/pipulate/SKILL.md +0 -0
- {pipulate-2.73 → pipulate-2.75}/.agents/skills/roles/SKILL.md +0 -0
- {pipulate-2.73 → pipulate-2.75}/.agents/skills/sheets-readonly/SKILL.md +0 -0
- {pipulate-2.73 → pipulate-2.75}/AGENTS.md +0 -0
- {pipulate-2.73 → pipulate-2.75}/AUDIT.md +0 -0
- {pipulate-2.73 → pipulate-2.75}/LICENSE +0 -0
- {pipulate-2.73 → pipulate-2.75}/MANIFEST.in +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/010_introduction.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/015_config.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/020_profiles.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/025_aspect.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/030_roles.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/040_hello_workflow.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/050_documentation.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/060_tasks.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/070_history.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/110_parameter_buster.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/120_link_graph.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/130_gap_analysis.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/200_workflow_genesis.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/210_widget_examples.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/220_roadmap.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/230_dev_assistant.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/240_simon_mcp.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/300_blank_placeholder.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/400_botify_trifecta.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/440_browser_automation.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/450_stream_simulator.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/510_text_field.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/520_text_area.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/530_dropdown.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/540_checkboxes.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/550_radios.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/560_range.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/570_switch.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/580_upload.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/610_markdown.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/620_mermaid.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/630_prism.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/640_javascript.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/710_pandas.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/720_rich.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/730_matplotlib.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/810_webbrowser.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/820_selenium.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/apps/830_pico_slider.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/cli.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/config.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/__init__.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/ai_dictdb.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/ai_tool_discovery_simple_parser.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/append_only_conversation.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/ascii_displays.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/botify/__init__.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/botify/code_generators.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/botify/true_schema_discoverer.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/botify_code_generation.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/crud.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/database_safety_wrapper.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/dom_processing/__init__.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/dom_processing/ai_dom_beautifier.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/dom_processing/enhanced_dom_processor.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/durable_backup_system.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/mcp_orchestrator.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/server_logging.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/imports/stream_orchestrator.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/pipulate/__init__.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/pipulate/core.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/pipulate.egg-info/SOURCES.txt +0 -0
- {pipulate-2.73 → pipulate-2.75}/pipulate.egg-info/dependency_links.txt +0 -0
- {pipulate-2.73 → pipulate-2.75}/pipulate.egg-info/entry_points.txt +0 -0
- {pipulate-2.73 → pipulate-2.75}/pipulate.egg-info/requires.txt +0 -0
- {pipulate-2.73 → pipulate-2.75}/pipulate.egg-info/top_level.txt +0 -0
- {pipulate-2.73 → pipulate-2.75}/server.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/setup.cfg +0 -0
- {pipulate-2.73 → pipulate-2.75}/tests/test_apply_airlock.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/tests/test_mck_rep2.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/tools/__init__.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/tools/advanced_automation_tools.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/tools/botify_tools.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/tools/connector_tools.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/tools/conversation_tools.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/tools/dom_tools.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/tools/keychain_tools.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/tools/llm_optics.py +0 -0
- {pipulate-2.73 → pipulate-2.75}/tools/mcp_tools.py +0 -0
- {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-
|
|
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.
|
|
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.
|
|
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?
|
|
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
|
|
117
|
-
2. Open a new
|
|
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 │ │ 🖥️
|
|
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` |
|
|
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
|
|
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
|
|
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?
|
|
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
|
|
60
|
-
2. Open a new
|
|
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 │ │ 🖥️
|
|
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` |
|
|
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
|
|
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
|
|
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.
|
|
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__ = "
|
|
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 = "
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
237
|
-
|
|
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
|
|
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="
|
|
1021
|
+
_decision = ask_voice_consent(later_hint="talk", force=True)
|
|
706
1022
|
if _decision == "unavailable":
|
|
707
|
-
print("voice: no
|
|
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.
|
|
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?
|
|
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
|
|
117
|
-
2. Open a new
|
|
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 │ │ 🖥️
|
|
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` |
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
"""
|
|
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,
|
|
325
|
+
"\nWhen the page you want is ready, press Enter here.\n"
|
|
326
|
+
"Ctrl+C stops without saving it.\n"
|
|
320
327
|
)
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
344
|
-
#
|
|
345
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|