pipulate 2.57__tar.gz → 2.59__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 (89) hide show
  1. {pipulate-2.57/pipulate.egg-info → pipulate-2.59}/PKG-INFO +1 -1
  2. {pipulate-2.57 → pipulate-2.59}/__init__.py +2 -2
  3. {pipulate-2.57 → pipulate-2.59}/apps/010_introduction.py +12 -4
  4. {pipulate-2.57 → pipulate-2.59}/imports/voice_synthesis.py +221 -19
  5. {pipulate-2.57 → pipulate-2.59}/pipulate/core.py +1 -1
  6. {pipulate-2.57 → pipulate-2.59/pipulate.egg-info}/PKG-INFO +1 -1
  7. {pipulate-2.57 → pipulate-2.59}/pyproject.toml +1 -1
  8. {pipulate-2.57 → pipulate-2.59}/server.py +1 -1
  9. {pipulate-2.57 → pipulate-2.59}/tools/connector_tools.py +4 -4
  10. {pipulate-2.57 → pipulate-2.59}/tools/mcp_tools.py +1 -1
  11. {pipulate-2.57 → pipulate-2.59}/tools/scraper_tools.py +93 -41
  12. {pipulate-2.57 → pipulate-2.59}/LICENSE +0 -0
  13. {pipulate-2.57 → pipulate-2.59}/MANIFEST.in +0 -0
  14. {pipulate-2.57 → pipulate-2.59}/README.md +0 -0
  15. {pipulate-2.57 → pipulate-2.59}/apps/015_config.py +0 -0
  16. {pipulate-2.57 → pipulate-2.59}/apps/020_profiles.py +0 -0
  17. {pipulate-2.57 → pipulate-2.59}/apps/025_aspect.py +0 -0
  18. {pipulate-2.57 → pipulate-2.59}/apps/030_roles.py +0 -0
  19. {pipulate-2.57 → pipulate-2.59}/apps/040_hello_workflow.py +0 -0
  20. {pipulate-2.57 → pipulate-2.59}/apps/050_documentation.py +0 -0
  21. {pipulate-2.57 → pipulate-2.59}/apps/060_tasks.py +0 -0
  22. {pipulate-2.57 → pipulate-2.59}/apps/070_history.py +0 -0
  23. {pipulate-2.57 → pipulate-2.59}/apps/110_parameter_buster.py +0 -0
  24. {pipulate-2.57 → pipulate-2.59}/apps/120_link_graph.py +0 -0
  25. {pipulate-2.57 → pipulate-2.59}/apps/130_gap_analysis.py +0 -0
  26. {pipulate-2.57 → pipulate-2.59}/apps/200_workflow_genesis.py +0 -0
  27. {pipulate-2.57 → pipulate-2.59}/apps/210_widget_examples.py +0 -0
  28. {pipulate-2.57 → pipulate-2.59}/apps/220_roadmap.py +0 -0
  29. {pipulate-2.57 → pipulate-2.59}/apps/230_dev_assistant.py +0 -0
  30. {pipulate-2.57 → pipulate-2.59}/apps/240_simon_mcp.py +0 -0
  31. {pipulate-2.57 → pipulate-2.59}/apps/300_blank_placeholder.py +0 -0
  32. {pipulate-2.57 → pipulate-2.59}/apps/400_botify_trifecta.py +0 -0
  33. {pipulate-2.57 → pipulate-2.59}/apps/440_browser_automation.py +0 -0
  34. {pipulate-2.57 → pipulate-2.59}/apps/450_stream_simulator.py +0 -0
  35. {pipulate-2.57 → pipulate-2.59}/apps/510_text_field.py +0 -0
  36. {pipulate-2.57 → pipulate-2.59}/apps/520_text_area.py +0 -0
  37. {pipulate-2.57 → pipulate-2.59}/apps/530_dropdown.py +0 -0
  38. {pipulate-2.57 → pipulate-2.59}/apps/540_checkboxes.py +0 -0
  39. {pipulate-2.57 → pipulate-2.59}/apps/550_radios.py +0 -0
  40. {pipulate-2.57 → pipulate-2.59}/apps/560_range.py +0 -0
  41. {pipulate-2.57 → pipulate-2.59}/apps/570_switch.py +0 -0
  42. {pipulate-2.57 → pipulate-2.59}/apps/580_upload.py +0 -0
  43. {pipulate-2.57 → pipulate-2.59}/apps/610_markdown.py +0 -0
  44. {pipulate-2.57 → pipulate-2.59}/apps/620_mermaid.py +0 -0
  45. {pipulate-2.57 → pipulate-2.59}/apps/630_prism.py +0 -0
  46. {pipulate-2.57 → pipulate-2.59}/apps/640_javascript.py +0 -0
  47. {pipulate-2.57 → pipulate-2.59}/apps/710_pandas.py +0 -0
  48. {pipulate-2.57 → pipulate-2.59}/apps/720_rich.py +0 -0
  49. {pipulate-2.57 → pipulate-2.59}/apps/730_matplotlib.py +0 -0
  50. {pipulate-2.57 → pipulate-2.59}/apps/810_webbrowser.py +0 -0
  51. {pipulate-2.57 → pipulate-2.59}/apps/820_selenium.py +0 -0
  52. {pipulate-2.57 → pipulate-2.59}/apps/830_pico_slider.py +0 -0
  53. {pipulate-2.57 → pipulate-2.59}/cli.py +0 -0
  54. {pipulate-2.57 → pipulate-2.59}/config.py +0 -0
  55. {pipulate-2.57 → pipulate-2.59}/imports/__init__.py +0 -0
  56. {pipulate-2.57 → pipulate-2.59}/imports/ai_dictdb.py +0 -0
  57. {pipulate-2.57 → pipulate-2.59}/imports/ai_tool_discovery_simple_parser.py +0 -0
  58. {pipulate-2.57 → pipulate-2.59}/imports/append_only_conversation.py +0 -0
  59. {pipulate-2.57 → pipulate-2.59}/imports/ascii_displays.py +0 -0
  60. {pipulate-2.57 → pipulate-2.59}/imports/botify/__init__.py +0 -0
  61. {pipulate-2.57 → pipulate-2.59}/imports/botify/code_generators.py +0 -0
  62. {pipulate-2.57 → pipulate-2.59}/imports/botify/true_schema_discoverer.py +0 -0
  63. {pipulate-2.57 → pipulate-2.59}/imports/botify_code_generation.py +0 -0
  64. {pipulate-2.57 → pipulate-2.59}/imports/crud.py +0 -0
  65. {pipulate-2.57 → pipulate-2.59}/imports/database_safety_wrapper.py +0 -0
  66. {pipulate-2.57 → pipulate-2.59}/imports/dom_processing/__init__.py +0 -0
  67. {pipulate-2.57 → pipulate-2.59}/imports/dom_processing/ai_dom_beautifier.py +0 -0
  68. {pipulate-2.57 → pipulate-2.59}/imports/dom_processing/enhanced_dom_processor.py +0 -0
  69. {pipulate-2.57 → pipulate-2.59}/imports/durable_backup_system.py +0 -0
  70. {pipulate-2.57 → pipulate-2.59}/imports/mcp_orchestrator.py +0 -0
  71. {pipulate-2.57 → pipulate-2.59}/imports/server_logging.py +0 -0
  72. {pipulate-2.57 → pipulate-2.59}/imports/stream_orchestrator.py +0 -0
  73. {pipulate-2.57 → pipulate-2.59}/pipulate/__init__.py +0 -0
  74. {pipulate-2.57 → pipulate-2.59}/pipulate.egg-info/SOURCES.txt +0 -0
  75. {pipulate-2.57 → pipulate-2.59}/pipulate.egg-info/dependency_links.txt +0 -0
  76. {pipulate-2.57 → pipulate-2.59}/pipulate.egg-info/entry_points.txt +0 -0
  77. {pipulate-2.57 → pipulate-2.59}/pipulate.egg-info/requires.txt +0 -0
  78. {pipulate-2.57 → pipulate-2.59}/pipulate.egg-info/top_level.txt +0 -0
  79. {pipulate-2.57 → pipulate-2.59}/setup.cfg +0 -0
  80. {pipulate-2.57 → pipulate-2.59}/tests/test_apply_airlock.py +0 -0
  81. {pipulate-2.57 → pipulate-2.59}/tests/test_mck_rep2.py +0 -0
  82. {pipulate-2.57 → pipulate-2.59}/tools/__init__.py +0 -0
  83. {pipulate-2.57 → pipulate-2.59}/tools/advanced_automation_tools.py +0 -0
  84. {pipulate-2.57 → pipulate-2.59}/tools/botify_tools.py +0 -0
  85. {pipulate-2.57 → pipulate-2.59}/tools/conversation_tools.py +0 -0
  86. {pipulate-2.57 → pipulate-2.59}/tools/dom_tools.py +0 -0
  87. {pipulate-2.57 → pipulate-2.59}/tools/keychain_tools.py +0 -0
  88. {pipulate-2.57 → pipulate-2.59}/tools/llm_optics.py +0 -0
  89. {pipulate-2.57 → pipulate-2.59}/tools/system_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipulate
3
- Version: 2.57
3
+ Version: 2.59
4
4
  Summary: The context compiler that helps you prompt well — local-first, Nix-reproducible, replayable. The successor to AI SEO software.
5
5
  Author-email: Mike Levin <pipulate@gmail.com>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -12,7 +12,7 @@ Usage:
12
12
  pipulate
13
13
  """
14
14
 
15
- __version__ = "2.57"
15
+ __version__ = "2.59"
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.57"
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__ = "NPvg.org Walk Walking"
26
+ __version_description__ = "NPvg.org New-B Empathy"
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".
@@ -155,10 +155,18 @@ class IntroductionPlugin:
155
155
 
156
156
 
157
157
  from imports.voice_synthesis import chip_voice_system
158
- if chip_voice_system and chip_voice_system.voice_ready:
159
- chip_voice_system.stop_speaking() # 🛑 INTERRUPT: Prevent voice overlapping on Back button
160
- logger.info(f"🎤 Speaking: {step_id}")
161
- asyncio.create_task(asyncio.to_thread(chip_voice_system.speak_text, text))
158
+ if chip_voice_system:
159
+ chip_voice_system.stop_speaking() # 🛑 INTERRUPT: Prevent voice overlapping on Back button
160
+ def _speak():
161
+ # THE FLAG WENT COLD (2026-09-18): the gate moved off the event
162
+ # loop. can_speak() reads the consent card and loads the model
163
+ # on first use, seconds long, so it runs here, in the thread; a
164
+ # declined or unanswered card returns False with no download,
165
+ # and speak_text keeps its own barrier behind this one.
166
+ if chip_voice_system.can_speak():
167
+ logger.info(f"🎤 Speaking: {step_id}")
168
+ chip_voice_system.speak_text(text)
169
+ asyncio.create_task(asyncio.to_thread(_speak))
162
170
 
163
171
  return ""
164
172
 
@@ -12,9 +12,12 @@ import wave
12
12
  import subprocess
13
13
  import tempfile
14
14
  import signal
15
+ import sys
15
16
  from pathlib import Path
16
17
  from typing import Optional, Dict, Any
18
+ import contextlib
17
19
  import logging
20
+ import warnings
18
21
 
19
22
  # Try to import voice synthesis dependencies
20
23
  try:
@@ -34,6 +37,128 @@ except ImportError:
34
37
 
35
38
  logger = logging.getLogger(__name__)
36
39
 
40
+
41
+ @contextlib.contextmanager
42
+ def _quiet_hub():
43
+ """Silence the hub for the length of the one download it ever makes.
44
+
45
+ THE QUIET WALK (2026-09-18, Mac receipt, deed 1480): the first consent
46
+ printed a three-line deprecation notice, a token nag and three progress
47
+ bars between the card and the first spoken step. The hub speaks through
48
+ two channels, its logger and the warnings module, and draws its bars
49
+ through its own switch; all three are turned down here and restored on
50
+ the way out, so nothing outside this block changes. The download itself
51
+ is announced by one plain line in the caller.
52
+ """
53
+ try:
54
+ from huggingface_hub.utils import (
55
+ are_progress_bars_disabled,
56
+ disable_progress_bars,
57
+ enable_progress_bars,
58
+ )
59
+ except ImportError:
60
+ are_progress_bars_disabled = disable_progress_bars = enable_progress_bars = None
61
+ hub_logger = logging.getLogger("huggingface_hub")
62
+ previous_level = hub_logger.level
63
+ hub_logger.setLevel(logging.ERROR)
64
+ bars_were_disabled = True if disable_progress_bars is None else are_progress_bars_disabled()
65
+ if not bars_were_disabled:
66
+ disable_progress_bars()
67
+ try:
68
+ with warnings.catch_warnings():
69
+ warnings.simplefilter("ignore")
70
+ yield
71
+ finally:
72
+ hub_logger.setLevel(previous_level)
73
+ if not bars_were_disabled:
74
+ enable_progress_bars()
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # THE VOICE ASKS BEFORE IT SPEAKS (2026-09-17). Two functions, one file.
78
+ # voice_consent() is the BARRIER: every path to sound reads it, fresh, on every
79
+ # call. The answer comes from PIPULATE_VOICE (an unattended shell declaring
80
+ # intent, the way PIPULATE_BOOT_MENU=0 does) or from ~/.config/pipulate/voice,
81
+ # outside the worktree, so it survives rm -rf on the folder and is asked once
82
+ # per machine rather than once per install.
83
+ # ask_voice_consent() is the CEREMONY: it prints the card and reads one line
84
+ # from /dev/tty. The two are SEPARATE on purpose. flake.nix's door-1 greeting
85
+ # runs as a backgrounded python with stdout on /dev/null while the server holds
86
+ # the foreground on the same terminal; a speaker that ASKED would print the
87
+ # card into /dev/null and take the keystrokes meant for a prompt nobody can
88
+ # see. So the speaker never asks. The rider asks at the start of a walk, and
89
+ # the `voice` word asks again later; both own a terminal.
90
+ # NOTHING IS DOWNLOADED BEFORE A YES: the model loads in ensure_voice(), on
91
+ # the first speak after consent, never at import. Importing this module used
92
+ # to fetch the voice and spend seconds before anyone had asked to speak or
93
+ # been asked whether it may; that was also the import tax the tool roster
94
+ # measured at ~3.8 s.
95
+ # ---------------------------------------------------------------------------
96
+ VOICE_CONSENT_FILE = Path.home() / ".config" / "pipulate" / "voice"
97
+ VOICE_CARD = (
98
+ "\nThe steps can be read aloud.\n\n"
99
+ "The voice is Piper, a small text-to-speech program that runs on this\n"
100
+ "computer; it was made for a Raspberry Pi. It reads sentences a person\n"
101
+ "typed in advance. No AI writes them, and nothing you say or type is\n"
102
+ "sent anywhere. Answering yes downloads the voice once, about 60 MB.\n\n"
103
+ "Read the steps aloud? [y/N] "
104
+ )
105
+
106
+
107
+ def voice_consent() -> str:
108
+ """'yes', 'no' or 'unset': the environment first, then the recorded answer."""
109
+ env = os.environ.get("PIPULATE_VOICE", "").strip().lower()
110
+ if env in ("0", "no", "off", "false"):
111
+ return "no"
112
+ if env in ("1", "yes", "on", "true"):
113
+ return "yes"
114
+ try:
115
+ recorded = VOICE_CONSENT_FILE.read_text(encoding="utf-8").strip().lower()
116
+ except OSError:
117
+ return "unset"
118
+ if recorded in ("yes", "no"):
119
+ return recorded
120
+ return "unset"
121
+
122
+
123
+ def ask_voice_consent(later_hint: str = "voice", force: bool = False) -> str:
124
+ """Ask on /dev/tty and record the answer. Returns 'yes', 'no' or 'unavailable'.
125
+
126
+ Asks only when nothing is recorded, unless force is set (the `voice` word),
127
+ and never when PIPULATE_VOICE decides. Without a terminal to ask on -- a
128
+ pipe, a compile-lane probe, a backgrounded job -- it prints nothing and
129
+ returns 'unavailable', so no prompt can ever block an unattended caller.
130
+ Enter alone means no. A Ctrl+C is not recorded, so the question returns.
131
+ """
132
+ env = os.environ.get("PIPULATE_VOICE", "").strip().lower()
133
+ if env in ("0", "no", "off", "false", "1", "yes", "on", "true"):
134
+ return voice_consent()
135
+ recorded = voice_consent()
136
+ if recorded != "unset" and not force:
137
+ return recorded
138
+ if not sys.stdout.isatty():
139
+ return "unavailable"
140
+ try:
141
+ tty = open("/dev/tty", "r", encoding="utf-8")
142
+ except OSError:
143
+ return "unavailable"
144
+ try:
145
+ print(VOICE_CARD, end="", flush=True)
146
+ answer = tty.readline()
147
+ except (OSError, KeyboardInterrupt):
148
+ print()
149
+ return "no"
150
+ finally:
151
+ tty.close()
152
+ decision = "yes" if answer.strip().lower() in ("y", "yes") else "no"
153
+ try:
154
+ VOICE_CONSENT_FILE.parent.mkdir(parents=True, exist_ok=True)
155
+ VOICE_CONSENT_FILE.write_text(decision + "\n", encoding="utf-8")
156
+ 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}")
160
+ return decision
161
+
37
162
  class ChipVoiceSystem:
38
163
  """
39
164
  Voice synthesis system for Chip O'Theseus
@@ -49,9 +174,9 @@ class ChipVoiceSystem:
49
174
  self.current_process = None # Track the running audio process
50
175
  self.last_error = None # Populated by synthesize_and_play on failure
51
176
 
52
- if VOICE_SYNTHESIS_AVAILABLE:
53
- self.setup_voice_model()
54
- else:
177
+ # No download and no model load here: see ensure_voice(). Importing
178
+ # this module must cost nothing and ask nothing.
179
+ if not VOICE_SYNTHESIS_AVAILABLE:
55
180
  logger.warning(f"🎤 Voice synthesis not available: {IMPORT_ERROR}")
56
181
 
57
182
  def setup_voice_model(self):
@@ -70,34 +195,76 @@ class ChipVoiceSystem:
70
195
  self.model_path = str(local_model_dir / model_path_in_repo)
71
196
  self.config_path = str(local_model_dir / config_path_in_repo)
72
197
 
198
+ downloaded = False
73
199
  # Download files if they don't exist
74
200
  if not os.path.exists(self.model_path):
201
+ print("Downloading the voice, about 60 MB, once...", flush=True)
75
202
  logger.info(f"🎤 Downloading voice model: {model_path_in_repo}...")
76
- hf_hub_download(
77
- repo_id=repo_id,
78
- filename=model_path_in_repo,
79
- local_dir=local_model_dir,
80
- local_dir_use_symlinks=False
81
- )
203
+ with _quiet_hub():
204
+ hf_hub_download(
205
+ repo_id=repo_id,
206
+ filename=model_path_in_repo,
207
+ local_dir=local_model_dir,
208
+ )
209
+ downloaded = True
82
210
 
83
211
  if not os.path.exists(self.config_path):
84
212
  logger.info(f"🎤 Downloading voice config: {config_path_in_repo}...")
85
- hf_hub_download(
86
- repo_id=repo_id,
87
- filename=config_path_in_repo,
88
- local_dir=local_model_dir,
89
- local_dir_use_symlinks=False
90
- )
213
+ with _quiet_hub():
214
+ hf_hub_download(
215
+ repo_id=repo_id,
216
+ filename=config_path_in_repo,
217
+ local_dir=local_model_dir,
218
+ )
219
+ downloaded = True
91
220
 
92
221
  # Load the voice model
93
222
  self.voice = PiperVoice.load(self.model_path, config_path=self.config_path)
94
223
  self.voice_ready = True
95
224
  logger.info("🎤 Voice model loaded successfully")
225
+ if downloaded:
226
+ print("Voice ready.", flush=True)
96
227
 
97
228
  except Exception as e:
98
229
  logger.error(f"🎤 Failed to setup voice model: {e}")
230
+ self.last_error = f"voice model setup failed: {e}"
99
231
  self.voice_ready = False
100
232
 
233
+ def ensure_voice(self) -> bool:
234
+ """Load the voice on first use, never at import. Returns readiness.
235
+
236
+ Reached only after voice_consent() read yes, so the download the card
237
+ announces happens after the answer and not before it.
238
+ """
239
+ if self.voice_ready:
240
+ return True
241
+ if not VOICE_SYNTHESIS_AVAILABLE:
242
+ self.last_error = f"voice synthesis not installed: {IMPORT_ERROR}"
243
+ return False
244
+ # THE PHONEME LINE (TODO of 2026-09-15, discharged 2026-09-18): piper
245
+ # warns, through a logger nobody configured, about one syllabic mark
246
+ # in the word "written", and Python's last-resort handler prints the
247
+ # bare message on every walk. The flake's door-1 greeting already sets
248
+ # this level; the speaker now sets it for every caller.
249
+ logging.getLogger("piper").setLevel(logging.ERROR)
250
+ self.setup_voice_model()
251
+ return self.voice_ready
252
+
253
+ def can_speak(self) -> bool:
254
+ """True only once a human has said yes AND the voice is loaded.
255
+
256
+ THE FLAG WENT COLD (2026-09-17, the census after the lazy load).
257
+ voice_ready read True at import on any machine with the model on disk,
258
+ and four files gate on it BEFORE they ever call speak_text:
259
+ apps/010_introduction.py, pipulate/core.py, server.py and
260
+ tools/mcp_tools.py. With the load deferred to first use the flag reads
261
+ False in every fresh process until something speaks, so none of those
262
+ gates opens. This is the question they meant to ask: consent first,
263
+ then the load, which downloads only after a yes. A status read that
264
+ must never download keeps reading voice_ready, which now means loaded.
265
+ """
266
+ return voice_consent() == "yes" and self.ensure_voice()
267
+
101
268
  def stop_speaking(self):
102
269
  """
103
270
  Silence! Kill the current audio process if it exists.
@@ -129,7 +296,13 @@ class ChipVoiceSystem:
129
296
  Returns:
130
297
  bool: True if successful, False otherwise
131
298
  """
132
- if not self.voice_ready:
299
+ if voice_consent() != "yes":
300
+ # THE BARRIER. Every path to sound passes here, and the answer is
301
+ # read fresh each time, so a no recorded mid-session holds at once.
302
+ self.last_error = "voice not allowed"
303
+ logger.info("🎤 Voice not allowed; nothing spoken")
304
+ return False
305
+ if not self.ensure_voice():
133
306
  logger.warning("🎤 Voice synthesis not ready")
134
307
  return False
135
308
 
@@ -318,10 +491,22 @@ class ChipVoiceSystem:
318
491
  Returns:
319
492
  Dict with success status and details
320
493
  """
321
- if not self.voice_ready:
494
+ consent = voice_consent()
495
+ if consent != "yes":
496
+ # DECLINED IS NOT FAILED. Callers read success=False either way;
497
+ # this key is how they tell a human's answer from a broken player.
498
+ return {
499
+ "success": False,
500
+ "declined": True,
501
+ "consent": consent,
502
+ "text": text,
503
+ "error": "voice declined" if consent == "no"
504
+ else "voice not yet allowed: the voice card has not been answered",
505
+ }
506
+ if not self.ensure_voice():
322
507
  return {
323
508
  "success": False,
324
- "error": "Voice synthesis not available",
509
+ "error": self.last_error or "Voice synthesis not available",
325
510
  "details": "Piper TTS model not loaded"
326
511
  }
327
512
 
@@ -467,7 +652,7 @@ def test_voice_synthesis():
467
652
  # Test basic voice synthesis
468
653
  test_text = "Hello Mike, this is Chip O'Theseus speaking. I can now remember what happened before the server restart!"
469
654
 
470
- if chip_voice_system and chip_voice_system.voice_ready:
655
+ if chip_voice_system and chip_voice_system.can_speak():
471
656
  result = chip_voice_system.speak_text(test_text)
472
657
  if result["success"]:
473
658
  print("✅ Voice synthesis test successful")
@@ -510,6 +695,23 @@ def test_memory_voice_integration():
510
695
  print("❌ No memories found to test")
511
696
  return False
512
697
 
698
+ if __name__ == "__main__" and sys.argv[1:2] == ["ask"]:
699
+ # `voice`, the word the card names: re-ask and record, then speak one
700
+ # sentence on yes so the audio path is heard rather than assumed.
701
+ _env = os.environ.get("PIPULATE_VOICE", "").strip()
702
+ if _env:
703
+ print(f"voice: PIPULATE_VOICE={_env!r} decides; unset it to be asked.")
704
+ raise SystemExit(0)
705
+ _decision = ask_voice_consent(later_hint="voice", force=True)
706
+ if _decision == "unavailable":
707
+ print("voice: no terminal to ask on; run this from a terminal.")
708
+ raise SystemExit(1)
709
+ print(f"voice: {_decision} (recorded in {VOICE_CONSENT_FILE})")
710
+ if _decision == "yes" and chip_voice_system is not None:
711
+ _result = chip_voice_system.speak_text("This is Piper, reading a script. The voice is on.")
712
+ if not _result.get("success"):
713
+ print(f"voice: nothing was heard: {_result.get('error')}")
714
+ raise SystemExit(0)
513
715
  if __name__ == "__main__":
514
716
  # Run tests if script is executed directly
515
717
  print("🎤🧠 Chip O'Theseus Voice System Tests")
@@ -555,7 +555,7 @@ class Pipulate:
555
555
  try:
556
556
  # Import here to avoid circular dependencies
557
557
  from imports.voice_synthesis import chip_voice_system
558
- if chip_voice_system and chip_voice_system.voice_ready:
558
+ if chip_voice_system and chip_voice_system.can_speak():
559
559
  # Acoustic Sanitization (Using the voice_text so it doesn't read URLs!)
560
560
  safe_text = voice_text.replace('\u0329', '')
561
561
  safe_text = safe_text.replace('—', ', ').replace('–', ', ')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipulate
3
- Version: 2.57
3
+ Version: 2.59
4
4
  Summary: The context compiler that helps you prompt well — local-first, Nix-reproducible, replayable. The successor to AI SEO software.
5
5
  Author-email: Mike Levin <pipulate@gmail.com>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "pipulate"
7
7
  # VERSION NOTE: Synced from pipulate/__init__.py.__version__ via python version_sync.py
8
- version = "2.57"
8
+ version = "2.59"
9
9
  authors = [
10
10
  { name="Mike Levin", email="pipulate@gmail.com" },
11
11
  ]
@@ -3156,7 +3156,7 @@ async def test_voice_synthesis_endpoint(request):
3156
3156
  return JSONResponse({"success": False, "error": "Voice synthesis not available"})
3157
3157
 
3158
3158
  # Check if voice system is ready
3159
- if not chip_voice_system.voice_ready:
3159
+ if not chip_voice_system.can_speak():
3160
3160
  logger.error("🎤 Voice system not ready")
3161
3161
  return JSONResponse({"success": False, "error": "Voice system not ready"})
3162
3162
 
@@ -1,5 +1,5 @@
1
1
  """
2
- connector_tools.py -- the registry face of scripts/connectors/*.py: one tool
2
+ connector_tools.py -- the registry face of connectors/*.py: one tool
3
3
  per connector, SAME argument shape, SAME backend, SAME docstring.
4
4
 
5
5
  This file exists for the two-arm experiment (shell verb vs registry tool) and
@@ -33,7 +33,7 @@ from pathlib import Path
33
33
  from tools import auto_tool
34
34
 
35
35
  REPO_ROOT = Path(__file__).resolve().parent.parent
36
- CONNECTORS = REPO_ROOT / "scripts" / "connectors"
36
+ CONNECTORS = REPO_ROOT / "connectors"
37
37
  TIMEOUT = 120
38
38
  KEY_TRAILER = (
39
39
  "\n\nRegistry form: --json-args keys mirror the connector's argument names --"
@@ -52,7 +52,7 @@ def _connector_doc(name):
52
52
 
53
53
 
54
54
  async def _run_connector(name, params):
55
- """Run scripts/connectors/<name>.py as a subprocess; return its three channels."""
55
+ """Run connectors/<name>.py as a subprocess; return its three channels."""
56
56
  if not isinstance(params, dict):
57
57
  return {"success": False, "error": "params must be a JSON object"}
58
58
  argv = [sys.executable, str(CONNECTORS / f"{name}.py")]
@@ -90,7 +90,7 @@ async def _run_connector(name, params):
90
90
 
91
91
  @auto_tool
92
92
  async def botify(params: dict) -> dict:
93
- """Registry face of scripts/connectors/botify.py; __doc__ is replaced at
93
+ """Registry face of connectors/botify.py; __doc__ is replaced at
94
94
  import with that file's own docstring so both arms read one description."""
95
95
  return await _run_connector("botify", params)
96
96
 
@@ -1464,7 +1464,7 @@ async def voice_synthesis(params: dict) -> dict:
1464
1464
  "error": "Voice synthesis not available - missing dependencies"
1465
1465
  }
1466
1466
 
1467
- if not chip_voice_system or not chip_voice_system.voice_ready:
1467
+ if not chip_voice_system:
1468
1468
  return {
1469
1469
  "success": False,
1470
1470
  "error": "Voice system not ready - check model initialization"
@@ -3,6 +3,7 @@ import asyncio
3
3
  import hashlib
4
4
  import json
5
5
  import os
6
+ import shlex
6
7
  import sys
7
8
  import shutil
8
9
  import tempfile
@@ -21,6 +22,18 @@ from selenium.webdriver.common.by import By
21
22
  from tools import auto_tool
22
23
  from . import dom_tools
23
24
 
25
+ # THE HEAL HAPPENS ONCE PER RUN (2026-09-18, Mac receipt, deed 1480). The
26
+ # flake pins no Chrome on a Mac, so the driver is matched to the host's
27
+ # Chrome at launch: the first uc.Chrome raises on the version mismatch, the
28
+ # window it had opened closes, and the retry below relaunches with
29
+ # version_main set to the browser's major version. That version was thrown
30
+ # away after every stop, so a three-stop walk paid the close-and-reopen at
31
+ # every stop and printed a WARNING three times for the ordinary case. It is
32
+ # remembered here for the life of the process: the next launch passes it in
33
+ # and never mismatches. A dict, not a bare name, so no function needs a
34
+ # global statement to write it.
35
+ _HEAL = {"version_main": None}
36
+
24
37
  async def generate_optics_subprocess(target_dir_path: str):
25
38
  """Isolated wrapper to call llm_optics.py as a subprocess, protecting the event loop."""
26
39
  script_path = Path(__file__).resolve().parent / "llm_optics.py"
@@ -59,35 +72,38 @@ def _guided_path_component(url: str) -> tuple[str, str]:
59
72
 
60
73
 
61
74
  # --- Optional audio during browser startup ---
62
- # Forked from stream.py's start_updating_music/stop_updating_music pattern:
63
- # a marker-tagged shell loop in its OWN process group (os.setsid), killed as
64
- # a group, with an idempotent pkill backstop keyed to the marker so it can
65
- # never touch any other aplay pipeline. The wav lives in repo negative space
66
- # (gitignored) or ~/.local/share/pipulate/; absent file = silent no-op.
75
+ # Operator-owned courtesy sounds live outside the repo. tick.wav loops while
76
+ # the browser is opening; ding.wav plays once when the page-load phase ends.
77
+ # No file means no sound. Linux uses aplay; Darwin uses afplay.
67
78
  SCRAPE_MUSIC_MARKER = "pipulate-scrape-music"
79
+ SOUND_DIR = Path.home() / ".local/share/pipulate"
68
80
 
69
81
 
70
- def _find_music_file():
71
- candidates = [
72
- Path(__file__).resolve().parent.parent / "jeopardy.wav",
73
- Path.home() / ".local/share/pipulate/jeopardy.wav",
74
- Path.home() / ".local/share/honeybot/jeopardy.wav",
75
- ]
76
- for c in candidates:
77
- if c.exists():
78
- return c
79
- return None
82
+ def _sound_file(name):
83
+ path = SOUND_DIR / name
84
+ return path if path.is_file() else None
85
+
86
+
87
+ def _player_args(sound):
88
+ if sound is None:
89
+ return None
90
+ if sys.platform == "darwin":
91
+ player = shutil.which("afplay")
92
+ return [player, str(sound)] if player else None
93
+ player = shutil.which("aplay")
94
+ return [player, "-q", "-D", "default", str(sound)] if player else None
80
95
 
81
96
 
82
97
  def _start_scrape_music(verbose=True):
83
98
  import subprocess
84
- music = _find_music_file()
85
- if not music:
86
- return None
87
99
 
100
+ args = _player_args(_sound_file("tick.wav"))
101
+ if not args:
102
+ return None
103
+ command = " ".join(shlex.quote(part) for part in args)
88
104
  try:
89
105
  return subprocess.Popen(
90
- ["sh", "-c", f'while :; do aplay -q -D default "{music}"; done # {SCRAPE_MUSIC_MARKER}'],
106
+ ["sh", "-c", f"while :; do {command}; done # {SCRAPE_MUSIC_MARKER}"],
91
107
  stdout=subprocess.DEVNULL,
92
108
  stderr=subprocess.DEVNULL,
93
109
  preexec_fn=os.setsid,
@@ -96,18 +112,36 @@ def _start_scrape_music(verbose=True):
96
112
  return None
97
113
 
98
114
 
99
- def _stop_scrape_music(proc):
115
+ def _stop_scrape_music(proc, ding=False):
100
116
  import signal
101
117
  import subprocess
118
+
102
119
  if proc is not None:
103
120
  try:
104
121
  os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
105
122
  except Exception:
106
123
  pass
107
124
  try:
108
- subprocess.run(["pkill", "-f", SCRAPE_MUSIC_MARKER], check=False)
125
+ subprocess.run(
126
+ ["pkill", "-f", SCRAPE_MUSIC_MARKER],
127
+ check=False,
128
+ stdout=subprocess.DEVNULL,
129
+ stderr=subprocess.DEVNULL,
130
+ )
109
131
  except Exception:
110
132
  pass
133
+ if ding:
134
+ args = _player_args(_sound_file("ding.wav"))
135
+ if args:
136
+ try:
137
+ subprocess.run(
138
+ args,
139
+ check=False,
140
+ stdout=subprocess.DEVNULL,
141
+ stderr=subprocess.DEVNULL,
142
+ )
143
+ except Exception:
144
+ pass
111
145
 
112
146
 
113
147
  def _simplify_html_for_llm(html_content, default_title=""):
@@ -223,7 +257,7 @@ def _document_candidates(cdp_events: list, domain: str, final_url: str = "") ->
223
257
  return on_host
224
258
 
225
259
 
226
- def _capture_checkpoint(stdin=None, stdout=None) -> dict:
260
+ def _capture_checkpoint(stdin=None, stdout=None, before_prompt=None) -> dict:
227
261
  """Require an explicit CAPTURE token from the human's keyboard.
228
262
 
229
263
  /DEV/TTY PREFERENCE (convicted 2026-07-29, public_walk stop 1): the
@@ -265,8 +299,16 @@ def _capture_checkpoint(stdin=None, stdout=None) -> dict:
265
299
  }
266
300
 
267
301
  try:
302
+ if before_prompt is not None:
303
+ try:
304
+ before_prompt()
305
+ except Exception as exc:
306
+ output_stream.write(f"\n(checkpoint narration unavailable: {exc})\n")
307
+ else:
308
+ output_stream.write(
309
+ "\nWhen the page you want is ready, type CAPTURE and press Enter.\n"
310
+ )
268
311
  output_stream.write(
269
- "\nWhen the page you want is ready, type CAPTURE and press Enter.\n"
270
312
  "Any other response aborts without capturing artifacts.\n"
271
313
  "CAPTURE> "
272
314
  )
@@ -560,7 +602,8 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
560
602
  driver = uc.Chrome(options=options,
561
603
  user_data_dir=str(profile_path),
562
604
  browser_executable_path=browser_path,
563
- driver_executable_path=driver_path)
605
+ driver_executable_path=driver_path,
606
+ version_main=_HEAL["version_main"])
564
607
  except Exception as e:
565
608
  error_msg = str(e)
566
609
  if "Current browser version is" in error_msg:
@@ -568,7 +611,10 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
568
611
  match = re.search(r'Current browser version is (\d+)', error_msg)
569
612
  if match:
570
613
  fallback_version = int(match.group(1))
571
- logger.warning(f"⚠️ Chrome version mismatch detected. Auto-healing with version_main={fallback_version}")
614
+ _HEAL["version_main"] = fallback_version
615
+ logger.info(f"Chrome {fallback_version}: driver matched; relaunching once for this run")
616
+ if verbose:
617
+ print(f"Chrome closed and is reopening once while its driver is matched to Chrome {fallback_version}; that is normal.", flush=True)
572
618
  # UC consumes the options object. We must forge a fresh one.
573
619
  fresh_options = uc.ChromeOptions()
574
620
  fresh_options.set_capability("goog:loggingPrefs", {"performance": "ALL"})
@@ -592,20 +638,25 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
592
638
  logger.info(f"Navigating to: {url}")
593
639
  driver.get(url)
594
640
 
595
- try:
596
- if verbose: logger.info("Waiting for security challenge to trigger a reload (Stage 1)...")
597
- initial_body = driver.find_element(By.TAG_NAME, 'body')
598
- WebDriverWait(driver, 20).until(EC.staleness_of(initial_body))
599
- if verbose: logger.success("✅ Page reload detected!")
600
-
601
- if verbose: logger.info("Waiting for main content to appear after reload (Stage 2)...")
602
- WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.CSS_SELECTOR, "body")))
603
- if verbose: logger.success("✅ Main content located!")
604
- except Exception as e:
605
- if verbose: logger.info(f"Did not detect a page reload for security challenge. Proceeding anyway.")
641
+ # A human-guided ride already has the CAPTURE fence as its settle
642
+ # detector. The 20-second staleness wait exists for unattended sigil
643
+ # scrapes, where a security interstitial may reload underneath us.
644
+ if not interactive:
645
+ try:
646
+ if verbose: logger.info("Waiting for security challenge to trigger a reload (Stage 1)...")
647
+ initial_body = driver.find_element(By.TAG_NAME, 'body')
648
+ WebDriverWait(driver, 20).until(EC.staleness_of(initial_body))
649
+ if verbose: logger.success("✅ Page reload detected!")
650
+
651
+ if verbose: logger.info("Waiting for main content to appear after reload (Stage 2)...")
652
+ WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.CSS_SELECTOR, "body")))
653
+ if verbose: logger.success("✅ Main content located!")
654
+ except Exception:
655
+ if verbose: logger.info("Did not detect a page reload for security challenge. Proceeding anyway.")
606
656
 
607
- # The wait is over one way or the other: cut the think-music sharply.
608
- _stop_scrape_music(music_proc)
657
+ # The page-load phase is over: stop ticking and, if the operator
658
+ # supplied one, play the ready sound exactly once.
659
+ _stop_scrape_music(music_proc, ding=True)
609
660
  music_proc = None
610
661
 
611
662
  if checkpoint is not None:
@@ -737,8 +788,8 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
737
788
  # ledger-only so it can be replayed offline over any cached capture.
738
789
  #
739
790
  # [-1] SURVIVES, and its rationale is still UNWITNESSED: the
740
- # staleness_of() wait above exists because a security challenge
741
- # serves an interstitial Document and then reloads, making the LAST
791
+ # non-interactive staleness_of() wait above exists because a security
792
+ # challenge serves an interstitial Document and then reloads, making the LAST
742
793
  # candidate the real page. No capture on this machine has ever been
743
794
  # challenged -- every scrape logs "Did not detect a page reload."
744
795
  # The reasoning is sound from the code and has no receipt behind it.
@@ -956,7 +1007,7 @@ async def selenium_automation(params: dict) -> dict:
956
1007
  return await _selenium_capture(params)
957
1008
 
958
1009
 
959
- async def guided_browser_capture(params: dict, stdin=None, stdout=None) -> dict:
1010
+ async def guided_browser_capture(params: dict, stdin=None, stdout=None, before_prompt=None) -> dict:
960
1011
  """Capture one human-guided page through one visible persistent driver."""
961
1012
  if not isinstance(params, dict):
962
1013
  return {
@@ -1029,5 +1080,6 @@ async def guided_browser_capture(params: dict, stdin=None, stdout=None) -> dict:
1029
1080
  checkpoint=lambda: _capture_checkpoint(
1030
1081
  stdin=input_stream,
1031
1082
  stdout=stdout,
1083
+ before_prompt=before_prompt,
1032
1084
  ),
1033
1085
  )
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