pipulate 2.74__tar.gz → 2.76__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.74 → pipulate-2.76}/.agents/skills/journal/references/index.md +6 -4
- {pipulate-2.74 → pipulate-2.76}/PKG-INFO +1 -1
- {pipulate-2.74 → pipulate-2.76}/__init__.py +2 -2
- {pipulate-2.74 → pipulate-2.76}/imports/voice_synthesis.py +161 -6
- {pipulate-2.74 → pipulate-2.76}/pipulate.egg-info/PKG-INFO +1 -1
- {pipulate-2.74 → pipulate-2.76}/pyproject.toml +1 -1
- {pipulate-2.74 → pipulate-2.76}/tools/scraper_tools.py +43 -25
- {pipulate-2.74 → pipulate-2.76}/.agents/skills/gsc-readonly/SKILL.md +0 -0
- {pipulate-2.74 → pipulate-2.76}/.agents/skills/hello-workflow/SKILL.md +0 -0
- {pipulate-2.74 → pipulate-2.76}/.agents/skills/journal/SKILL.md +0 -0
- {pipulate-2.74 → pipulate-2.76}/.agents/skills/pipulate/SKILL.md +0 -0
- {pipulate-2.74 → pipulate-2.76}/.agents/skills/roles/SKILL.md +0 -0
- {pipulate-2.74 → pipulate-2.76}/.agents/skills/sheets-readonly/SKILL.md +0 -0
- {pipulate-2.74 → pipulate-2.76}/AGENTS.md +0 -0
- {pipulate-2.74 → pipulate-2.76}/AUDIT.md +0 -0
- {pipulate-2.74 → pipulate-2.76}/LICENSE +0 -0
- {pipulate-2.74 → pipulate-2.76}/MANIFEST.in +0 -0
- {pipulate-2.74 → pipulate-2.76}/README.md +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/010_introduction.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/015_config.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/020_profiles.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/025_aspect.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/030_roles.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/040_hello_workflow.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/050_documentation.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/060_tasks.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/070_history.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/110_parameter_buster.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/120_link_graph.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/130_gap_analysis.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/200_workflow_genesis.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/210_widget_examples.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/220_roadmap.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/230_dev_assistant.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/240_simon_mcp.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/300_blank_placeholder.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/400_botify_trifecta.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/440_browser_automation.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/450_stream_simulator.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/510_text_field.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/520_text_area.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/530_dropdown.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/540_checkboxes.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/550_radios.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/560_range.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/570_switch.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/580_upload.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/610_markdown.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/620_mermaid.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/630_prism.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/640_javascript.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/710_pandas.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/720_rich.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/730_matplotlib.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/810_webbrowser.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/820_selenium.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/apps/830_pico_slider.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/cli.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/config.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/__init__.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/ai_dictdb.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/ai_tool_discovery_simple_parser.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/append_only_conversation.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/ascii_displays.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/botify/__init__.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/botify/code_generators.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/botify/true_schema_discoverer.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/botify_code_generation.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/crud.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/database_safety_wrapper.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/dom_processing/__init__.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/dom_processing/ai_dom_beautifier.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/dom_processing/enhanced_dom_processor.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/durable_backup_system.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/mcp_orchestrator.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/server_logging.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/imports/stream_orchestrator.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/pipulate/__init__.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/pipulate/core.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/pipulate.egg-info/SOURCES.txt +0 -0
- {pipulate-2.74 → pipulate-2.76}/pipulate.egg-info/dependency_links.txt +0 -0
- {pipulate-2.74 → pipulate-2.76}/pipulate.egg-info/entry_points.txt +0 -0
- {pipulate-2.74 → pipulate-2.76}/pipulate.egg-info/requires.txt +0 -0
- {pipulate-2.74 → pipulate-2.76}/pipulate.egg-info/top_level.txt +0 -0
- {pipulate-2.74 → pipulate-2.76}/server.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/setup.cfg +0 -0
- {pipulate-2.74 → pipulate-2.76}/tests/test_apply_airlock.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/tests/test_mck_rep2.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/tools/__init__.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/tools/advanced_automation_tools.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/tools/botify_tools.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/tools/connector_tools.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/tools/conversation_tools.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/tools/dom_tools.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/tools/keychain_tools.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/tools/llm_optics.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/tools/mcp_tools.py +0 -0
- {pipulate-2.74 → pipulate-2.76}/tools/system_tools.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# The Pipulate journal, indexed
|
|
2
2
|
|
|
3
|
-
> Auto-generated on 2026-10-
|
|
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,8 @@ 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)
|
|
30
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)
|
|
31
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)
|
|
32
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)
|
|
@@ -45,13 +47,13 @@ URL as: `https://mikelev.in/futureproof/{slug}/index.md`
|
|
|
45
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)
|
|
46
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)
|
|
47
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)
|
|
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)
|
|
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)
|
|
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
|
|
55
57
|
- [2026-09-19] [48k] dual-model-failover-independent-backoff
|
|
56
58
|
- [2026-09-19] [193k] playing-pinball-in-the-age-of-ai-replayable-workflow-guide
|
|
57
59
|
- [2026-09-19] [72k] color-blue-pipe-inspecting-code
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pipulate
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.76
|
|
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.
|
|
15
|
+
__version__ = "2.76"
|
|
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.74"
|
|
|
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__ = "Release Robustness"
|
|
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
|
|
@@ -244,6 +246,136 @@ FIRST_CONTACT_INTRO = ("This workshop can read its steps aloud with Piper, "
|
|
|
244
246
|
FIRST_CONTACT_LINE = ("This is Piper, reading a script. If you can hear this, "
|
|
245
247
|
"press Enter. Type n to keep it quiet.")
|
|
246
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
|
+
|
|
247
379
|
|
|
248
380
|
def first_contact() -> str:
|
|
249
381
|
"""Ask by voice, once per machine, right before the first menu.
|
|
@@ -259,22 +391,42 @@ def first_contact() -> str:
|
|
|
259
391
|
nothing, so the question returns next time. The one line spoken before any
|
|
260
392
|
answer exists is the question itself, which is why synthesize_and_play
|
|
261
393
|
takes ceremony=True from here and from nowhere else. Returns env,
|
|
262
|
-
recorded, unavailable, silent, interrupted, yes or
|
|
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.
|
|
263
406
|
"""
|
|
264
407
|
if os.environ.get("PIPULATE_VOICE", "").strip():
|
|
265
408
|
return "env"
|
|
266
|
-
|
|
409
|
+
door = read_door()
|
|
410
|
+
recorded = voice_consent()
|
|
411
|
+
if recorded != "unset" and (not door or door.get("welcomed")):
|
|
267
412
|
return "recorded"
|
|
268
413
|
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
269
414
|
return "unavailable"
|
|
270
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"
|
|
271
421
|
voice = chip_voice_system
|
|
422
|
+
intro, ask_lines, _ = door_card(door, local_ai_line() if door.get("workshop") else "")
|
|
272
423
|
try:
|
|
273
|
-
print(
|
|
424
|
+
print(intro, flush=True)
|
|
274
425
|
played = False
|
|
275
426
|
if voice is not None and voice.ensure_voice():
|
|
276
|
-
|
|
277
|
-
|
|
427
|
+
for line in ask_lines:
|
|
428
|
+
print(line, flush=True)
|
|
429
|
+
played = voice.synthesize_and_play(" ".join(ask_lines), ceremony=True)
|
|
278
430
|
if not played:
|
|
279
431
|
reason = (voice.last_error if voice is not None else None) or "the voice did not load"
|
|
280
432
|
reason = reason.splitlines()[0][:120]
|
|
@@ -282,6 +434,7 @@ def first_contact() -> str:
|
|
|
282
434
|
print(f"The voice could not play here ({reason}), so it stays off. Type talk to try again.")
|
|
283
435
|
if error is not None:
|
|
284
436
|
print(f"(could not record that, so this runs again next time: {error})")
|
|
437
|
+
_show_welcome(door, speak=False)
|
|
285
438
|
return "silent"
|
|
286
439
|
with open("/dev/tty", "r", encoding="utf-8") as tty:
|
|
287
440
|
print("Enter or n: ", end="", flush=True)
|
|
@@ -303,6 +456,7 @@ def first_contact() -> str:
|
|
|
303
456
|
print("Voice on. Type talk any time to turn it off.")
|
|
304
457
|
else:
|
|
305
458
|
print("Staying quiet. Type talk any time to turn the voice on.")
|
|
459
|
+
_show_welcome(door, speak=decision == "yes")
|
|
306
460
|
return decision
|
|
307
461
|
|
|
308
462
|
|
|
@@ -850,7 +1004,8 @@ if __name__ == "__main__" and sys.argv[1:2] == ["talk"]:
|
|
|
850
1004
|
raise SystemExit(talk_command(sys.argv[2:]))
|
|
851
1005
|
if __name__ == "__main__" and sys.argv[1:2] == ["first"]:
|
|
852
1006
|
# flake.nix's runScript calls this once per machine, right before the first
|
|
853
|
-
# menu
|
|
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.
|
|
854
1009
|
try:
|
|
855
1010
|
first_contact()
|
|
856
1011
|
except Exception as _exc:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pipulate
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.76
|
|
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.
|
|
8
|
+
version = "2.76"
|
|
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
|
|
File without changes
|