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.
Files changed (98) hide show
  1. {pipulate-2.74 → pipulate-2.76}/.agents/skills/journal/references/index.md +6 -4
  2. {pipulate-2.74 → pipulate-2.76}/PKG-INFO +1 -1
  3. {pipulate-2.74 → pipulate-2.76}/__init__.py +2 -2
  4. {pipulate-2.74 → pipulate-2.76}/imports/voice_synthesis.py +161 -6
  5. {pipulate-2.74 → pipulate-2.76}/pipulate.egg-info/PKG-INFO +1 -1
  6. {pipulate-2.74 → pipulate-2.76}/pyproject.toml +1 -1
  7. {pipulate-2.74 → pipulate-2.76}/tools/scraper_tools.py +43 -25
  8. {pipulate-2.74 → pipulate-2.76}/.agents/skills/gsc-readonly/SKILL.md +0 -0
  9. {pipulate-2.74 → pipulate-2.76}/.agents/skills/hello-workflow/SKILL.md +0 -0
  10. {pipulate-2.74 → pipulate-2.76}/.agents/skills/journal/SKILL.md +0 -0
  11. {pipulate-2.74 → pipulate-2.76}/.agents/skills/pipulate/SKILL.md +0 -0
  12. {pipulate-2.74 → pipulate-2.76}/.agents/skills/roles/SKILL.md +0 -0
  13. {pipulate-2.74 → pipulate-2.76}/.agents/skills/sheets-readonly/SKILL.md +0 -0
  14. {pipulate-2.74 → pipulate-2.76}/AGENTS.md +0 -0
  15. {pipulate-2.74 → pipulate-2.76}/AUDIT.md +0 -0
  16. {pipulate-2.74 → pipulate-2.76}/LICENSE +0 -0
  17. {pipulate-2.74 → pipulate-2.76}/MANIFEST.in +0 -0
  18. {pipulate-2.74 → pipulate-2.76}/README.md +0 -0
  19. {pipulate-2.74 → pipulate-2.76}/apps/010_introduction.py +0 -0
  20. {pipulate-2.74 → pipulate-2.76}/apps/015_config.py +0 -0
  21. {pipulate-2.74 → pipulate-2.76}/apps/020_profiles.py +0 -0
  22. {pipulate-2.74 → pipulate-2.76}/apps/025_aspect.py +0 -0
  23. {pipulate-2.74 → pipulate-2.76}/apps/030_roles.py +0 -0
  24. {pipulate-2.74 → pipulate-2.76}/apps/040_hello_workflow.py +0 -0
  25. {pipulate-2.74 → pipulate-2.76}/apps/050_documentation.py +0 -0
  26. {pipulate-2.74 → pipulate-2.76}/apps/060_tasks.py +0 -0
  27. {pipulate-2.74 → pipulate-2.76}/apps/070_history.py +0 -0
  28. {pipulate-2.74 → pipulate-2.76}/apps/110_parameter_buster.py +0 -0
  29. {pipulate-2.74 → pipulate-2.76}/apps/120_link_graph.py +0 -0
  30. {pipulate-2.74 → pipulate-2.76}/apps/130_gap_analysis.py +0 -0
  31. {pipulate-2.74 → pipulate-2.76}/apps/200_workflow_genesis.py +0 -0
  32. {pipulate-2.74 → pipulate-2.76}/apps/210_widget_examples.py +0 -0
  33. {pipulate-2.74 → pipulate-2.76}/apps/220_roadmap.py +0 -0
  34. {pipulate-2.74 → pipulate-2.76}/apps/230_dev_assistant.py +0 -0
  35. {pipulate-2.74 → pipulate-2.76}/apps/240_simon_mcp.py +0 -0
  36. {pipulate-2.74 → pipulate-2.76}/apps/300_blank_placeholder.py +0 -0
  37. {pipulate-2.74 → pipulate-2.76}/apps/400_botify_trifecta.py +0 -0
  38. {pipulate-2.74 → pipulate-2.76}/apps/440_browser_automation.py +0 -0
  39. {pipulate-2.74 → pipulate-2.76}/apps/450_stream_simulator.py +0 -0
  40. {pipulate-2.74 → pipulate-2.76}/apps/510_text_field.py +0 -0
  41. {pipulate-2.74 → pipulate-2.76}/apps/520_text_area.py +0 -0
  42. {pipulate-2.74 → pipulate-2.76}/apps/530_dropdown.py +0 -0
  43. {pipulate-2.74 → pipulate-2.76}/apps/540_checkboxes.py +0 -0
  44. {pipulate-2.74 → pipulate-2.76}/apps/550_radios.py +0 -0
  45. {pipulate-2.74 → pipulate-2.76}/apps/560_range.py +0 -0
  46. {pipulate-2.74 → pipulate-2.76}/apps/570_switch.py +0 -0
  47. {pipulate-2.74 → pipulate-2.76}/apps/580_upload.py +0 -0
  48. {pipulate-2.74 → pipulate-2.76}/apps/610_markdown.py +0 -0
  49. {pipulate-2.74 → pipulate-2.76}/apps/620_mermaid.py +0 -0
  50. {pipulate-2.74 → pipulate-2.76}/apps/630_prism.py +0 -0
  51. {pipulate-2.74 → pipulate-2.76}/apps/640_javascript.py +0 -0
  52. {pipulate-2.74 → pipulate-2.76}/apps/710_pandas.py +0 -0
  53. {pipulate-2.74 → pipulate-2.76}/apps/720_rich.py +0 -0
  54. {pipulate-2.74 → pipulate-2.76}/apps/730_matplotlib.py +0 -0
  55. {pipulate-2.74 → pipulate-2.76}/apps/810_webbrowser.py +0 -0
  56. {pipulate-2.74 → pipulate-2.76}/apps/820_selenium.py +0 -0
  57. {pipulate-2.74 → pipulate-2.76}/apps/830_pico_slider.py +0 -0
  58. {pipulate-2.74 → pipulate-2.76}/cli.py +0 -0
  59. {pipulate-2.74 → pipulate-2.76}/config.py +0 -0
  60. {pipulate-2.74 → pipulate-2.76}/imports/__init__.py +0 -0
  61. {pipulate-2.74 → pipulate-2.76}/imports/ai_dictdb.py +0 -0
  62. {pipulate-2.74 → pipulate-2.76}/imports/ai_tool_discovery_simple_parser.py +0 -0
  63. {pipulate-2.74 → pipulate-2.76}/imports/append_only_conversation.py +0 -0
  64. {pipulate-2.74 → pipulate-2.76}/imports/ascii_displays.py +0 -0
  65. {pipulate-2.74 → pipulate-2.76}/imports/botify/__init__.py +0 -0
  66. {pipulate-2.74 → pipulate-2.76}/imports/botify/code_generators.py +0 -0
  67. {pipulate-2.74 → pipulate-2.76}/imports/botify/true_schema_discoverer.py +0 -0
  68. {pipulate-2.74 → pipulate-2.76}/imports/botify_code_generation.py +0 -0
  69. {pipulate-2.74 → pipulate-2.76}/imports/crud.py +0 -0
  70. {pipulate-2.74 → pipulate-2.76}/imports/database_safety_wrapper.py +0 -0
  71. {pipulate-2.74 → pipulate-2.76}/imports/dom_processing/__init__.py +0 -0
  72. {pipulate-2.74 → pipulate-2.76}/imports/dom_processing/ai_dom_beautifier.py +0 -0
  73. {pipulate-2.74 → pipulate-2.76}/imports/dom_processing/enhanced_dom_processor.py +0 -0
  74. {pipulate-2.74 → pipulate-2.76}/imports/durable_backup_system.py +0 -0
  75. {pipulate-2.74 → pipulate-2.76}/imports/mcp_orchestrator.py +0 -0
  76. {pipulate-2.74 → pipulate-2.76}/imports/server_logging.py +0 -0
  77. {pipulate-2.74 → pipulate-2.76}/imports/stream_orchestrator.py +0 -0
  78. {pipulate-2.74 → pipulate-2.76}/pipulate/__init__.py +0 -0
  79. {pipulate-2.74 → pipulate-2.76}/pipulate/core.py +0 -0
  80. {pipulate-2.74 → pipulate-2.76}/pipulate.egg-info/SOURCES.txt +0 -0
  81. {pipulate-2.74 → pipulate-2.76}/pipulate.egg-info/dependency_links.txt +0 -0
  82. {pipulate-2.74 → pipulate-2.76}/pipulate.egg-info/entry_points.txt +0 -0
  83. {pipulate-2.74 → pipulate-2.76}/pipulate.egg-info/requires.txt +0 -0
  84. {pipulate-2.74 → pipulate-2.76}/pipulate.egg-info/top_level.txt +0 -0
  85. {pipulate-2.74 → pipulate-2.76}/server.py +0 -0
  86. {pipulate-2.74 → pipulate-2.76}/setup.cfg +0 -0
  87. {pipulate-2.74 → pipulate-2.76}/tests/test_apply_airlock.py +0 -0
  88. {pipulate-2.74 → pipulate-2.76}/tests/test_mck_rep2.py +0 -0
  89. {pipulate-2.74 → pipulate-2.76}/tools/__init__.py +0 -0
  90. {pipulate-2.74 → pipulate-2.76}/tools/advanced_automation_tools.py +0 -0
  91. {pipulate-2.74 → pipulate-2.76}/tools/botify_tools.py +0 -0
  92. {pipulate-2.74 → pipulate-2.76}/tools/connector_tools.py +0 -0
  93. {pipulate-2.74 → pipulate-2.76}/tools/conversation_tools.py +0 -0
  94. {pipulate-2.74 → pipulate-2.76}/tools/dom_tools.py +0 -0
  95. {pipulate-2.74 → pipulate-2.76}/tools/keychain_tools.py +0 -0
  96. {pipulate-2.74 → pipulate-2.76}/tools/llm_optics.py +0 -0
  97. {pipulate-2.74 → pipulate-2.76}/tools/mcp_tools.py +0 -0
  98. {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-01 by `scripts/articles/generate_ai_context.py` and
3
+ > Auto-generated on 2026-10-02 by `scripts/articles/generate_ai_context.py` and
4
4
  > rewritten from scratch on every release. If this date looks stale, assume
5
- > the rest of the repo is newer than this map. 1508 entries indexed.
5
+ > the rest of the repo is newer than this map. 1510 entries indexed.
6
6
 
7
7
  This repository holds the *machinery*. The *reasoning*, the running journal
8
8
  that explains why every piece exists, lives on a separate website and not in
@@ -27,6 +27,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.74
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.74"
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__ = "Doorways"
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 no.
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
- if voice_consent() != "unset":
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(FIRST_CONTACT_INTRO, flush=True)
424
+ print(intro, flush=True)
274
425
  played = False
275
426
  if voice is not None and voice.ensure_voice():
276
- print(FIRST_CONTACT_LINE, flush=True)
277
- played = voice.synthesize_and_play(FIRST_CONTACT_LINE, ceremony=True)
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; it exits 0 whatever happened, so the menu always prints after it.
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.74
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.74"
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
- """Require an explicit CAPTURE token from the human's keyboard.
271
+ """Wait for Enter from the human's keyboard: the page has settled.
272
+
273
+ ENTER, NOT A WORD (2026-10-02, the operator's ruling). The fence used to
274
+ want the token CAPTURE, and one wrong letter ended the whole walk. Its
275
+ only job is a signal that the page has settled, and Enter is that signal;
276
+ Ctrl+C is the way out, as everywhere else. EOF still refuses, so a pipe
277
+ or a dead descriptor can never save a page.
272
278
 
273
279
  /DEV/TTY PREFERENCE (convicted 2026-07-29, public_walk stop 1): the
274
280
  inherited sys.stdin returned INSTANT EOF at the CAPTURE> prompt -- the
@@ -316,13 +322,25 @@ def _capture_checkpoint(stdin=None, stdout=None, before_prompt=None) -> dict:
316
322
  output_stream.write(f"\n(checkpoint narration unavailable: {exc})\n")
317
323
  else:
318
324
  output_stream.write(
319
- "\nWhen the page you want is ready, type CAPTURE and press Enter.\n"
325
+ "\nWhen the page you want is ready, press Enter here.\n"
326
+ "Ctrl+C stops without saving it.\n"
320
327
  )
321
- output_stream.write(
322
- "Any other response aborts without capturing artifacts.\n"
323
- "CAPTURE> "
324
- )
328
+ # A CALLER'S HOOK OWNS EVERYTHING BEFORE THE PROMPT (2026-10-02):
329
+ # the default sentences print only when no hook was given, and
330
+ # the prompt itself says what Enter does.
331
+ output_stream.write("Press Enter to save the page. ")
325
332
  output_stream.flush()
333
+ # TYPE-AHEAD IS NOT THE SIGNAL (2026-10-02, read off a walk whose
334
+ # third page printed "Press Enter to save the page. Saved." on one
335
+ # line): an Enter pressed while the page was still loading waited
336
+ # in the terminal and answered this prompt the moment it appeared.
337
+ # Keys typed before the prompt are dropped, so only an Enter after
338
+ # the bell counts. A stream that is not a terminal is left alone.
339
+ try:
340
+ import termios
341
+ termios.tcflush(input_stream.fileno(), termios.TCIFLUSH)
342
+ except Exception:
343
+ pass
326
344
  response = input_stream.readline()
327
345
  except KeyboardInterrupt:
328
346
  return {
@@ -340,23 +358,9 @@ def _capture_checkpoint(stdin=None, stdout=None, before_prompt=None) -> dict:
340
358
  "success": False,
341
359
  "error": "interactive checkpoint reached EOF",
342
360
  }
343
- # CASE-INSENSITIVE ON PURPOSE (banked 2026-08-01). The token's safety
344
- # comes entirely from being IMPOSSIBLE TO PRODUCE BY ACCIDENT: no pipe,
345
- # no EOF, no stray Enter, no dead descriptor and no automation emits
346
- # these seven letters. Lowercase is exactly as impossible as uppercase,
347
- # so the shift key was a tax with no revenue -- and it was collected at
348
- # the newcomer's first handshake, where a refusal reads as "this thing
349
- # is broken" rather than "you missed a key."
350
- # DELIBERATELY NOT A RETRY LOOP. A retry gives the fence a SECOND STATE,
351
- # and "nothing was written" stops being provable in one sentence. One
352
- # read, one comparison, one branch, and every non-match returns before
353
- # mkdir. A wrong answer still costs the ENTIRE ride, because a non-token
354
- # answer is the human's ABORT, and abort must be as instant as consent.
355
- if response.strip().upper() != "CAPTURE":
356
- return {
357
- "success": False,
358
- "error": "interactive checkpoint was not confirmed",
359
- }
361
+
362
+ # ANY LINE IS THE SIGNAL (2026-10-02): whatever was typed before Enter
363
+ # is ignored, so there is no wrong answer to end a walk on.
360
364
  return {"success": True}
361
365
  finally:
362
366
  if tty_handle is not None:
@@ -604,7 +608,9 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
604
608
  temp_profile = True
605
609
  logger.info(f"👻 Using temporary profile: {profile_path}")
606
610
 
607
- if verbose:
611
+ # A guided walk's own step line says the browser is opening, so this
612
+ # prints only for unattended captures (2026-10-02, say a thing once).
613
+ if verbose and not interactive:
608
614
  print("Opening the browser; waiting for the page...", flush=True)
609
615
  music_proc = _start_scrape_music(verbose=verbose)
610
616
  logger.info(f"🚀 Initializing undetected-chromedriver (Headless: {headless})...")
@@ -645,10 +651,22 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
645
651
  else:
646
652
  raise
647
653
 
654
+ # THE PAGE THE SERVER SENDS NOW (2026-10-02, read off a walk whose
655
+ # headers.json carried the Date and ETag of a fetch three hours older
656
+ # than the walk): a persistent profile keeps an HTTP cache, and a page
657
+ # sent without Cache-Control is reused from it, without asking the
658
+ # server, for a tenth of its age since Last-Modified. The walk saved
659
+ # that copy and called it the wire. The cache is off for every capture;
660
+ # cookies and sign-ins are kept elsewhere and are untouched.
661
+ try:
662
+ driver.execute_cdp_cmd("Network.enable", {})
663
+ driver.execute_cdp_cmd("Network.setCacheDisabled", {"cacheDisabled": True})
664
+ except Exception as exc:
665
+ logger.warning(f"⚠️ Could not turn off the browser cache; this page may come from it: {exc}")
648
666
  logger.info(f"Navigating to: {url}")
649
667
  driver.get(url)
650
668
 
651
- # A human-guided ride already has the CAPTURE fence as its settle
669
+ # A human-guided ride already has the Enter fence as its settle
652
670
  # detector. The 20-second staleness wait exists for unattended sigil
653
671
  # scrapes, where a security interstitial may reload underneath us.
654
672
  if not interactive:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes