openvoiceui 1.0.0

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 (185) hide show
  1. package/.env.example +104 -0
  2. package/Dockerfile +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +638 -0
  5. package/SETUP.md +360 -0
  6. package/app.py +232 -0
  7. package/auto-approve-devices.js +111 -0
  8. package/cli/index.js +372 -0
  9. package/config/__init__.py +4 -0
  10. package/config/default.yaml +43 -0
  11. package/config/flags.yaml +67 -0
  12. package/config/loader.py +203 -0
  13. package/config/providers.yaml +71 -0
  14. package/config/speech_normalization.yaml +182 -0
  15. package/config/theme.json +4 -0
  16. package/data/greetings.json +25 -0
  17. package/default-pages/ai-image-creator.html +915 -0
  18. package/default-pages/bulk-image-uploader.html +492 -0
  19. package/default-pages/desktop.html +2865 -0
  20. package/default-pages/file-explorer.html +854 -0
  21. package/default-pages/interactive-map.html +655 -0
  22. package/default-pages/style-guide.html +1005 -0
  23. package/default-pages/website-setup.html +1623 -0
  24. package/deploy/openclaw/Dockerfile +46 -0
  25. package/deploy/openvoiceui.service +30 -0
  26. package/deploy/setup-nginx.sh +50 -0
  27. package/deploy/setup-sudo.sh +306 -0
  28. package/deploy/skill-runner/Dockerfile +19 -0
  29. package/deploy/skill-runner/requirements.txt +14 -0
  30. package/deploy/skill-runner/server.py +269 -0
  31. package/deploy/supertonic/Dockerfile +22 -0
  32. package/deploy/supertonic/server.py +79 -0
  33. package/docker-compose.pinokio.yml +11 -0
  34. package/docker-compose.yml +59 -0
  35. package/greetings.json +25 -0
  36. package/index.html +65 -0
  37. package/inject-device-identity.js +142 -0
  38. package/package.json +82 -0
  39. package/profiles/default.json +114 -0
  40. package/profiles/manager.py +354 -0
  41. package/profiles/schema.json +337 -0
  42. package/prompts/voice-system-prompt.md +149 -0
  43. package/providers/__init__.py +39 -0
  44. package/providers/base.py +63 -0
  45. package/providers/llm/__init__.py +12 -0
  46. package/providers/llm/base.py +71 -0
  47. package/providers/llm/clawdbot_provider.py +112 -0
  48. package/providers/llm/zai_provider.py +115 -0
  49. package/providers/registry.py +320 -0
  50. package/providers/stt/__init__.py +12 -0
  51. package/providers/stt/base.py +58 -0
  52. package/providers/stt/webspeech_provider.py +49 -0
  53. package/providers/stt/whisper_provider.py +100 -0
  54. package/providers/tts/__init__.py +20 -0
  55. package/providers/tts/base.py +91 -0
  56. package/providers/tts/groq_provider.py +74 -0
  57. package/providers/tts/supertonic_provider.py +72 -0
  58. package/requirements.txt +38 -0
  59. package/routes/__init__.py +10 -0
  60. package/routes/admin.py +515 -0
  61. package/routes/canvas.py +1315 -0
  62. package/routes/chat.py +51 -0
  63. package/routes/conversation.py +2158 -0
  64. package/routes/elevenlabs_hybrid.py +306 -0
  65. package/routes/greetings.py +98 -0
  66. package/routes/icons.py +279 -0
  67. package/routes/image_gen.py +364 -0
  68. package/routes/instructions.py +190 -0
  69. package/routes/music.py +838 -0
  70. package/routes/onboarding.py +43 -0
  71. package/routes/pi.py +62 -0
  72. package/routes/profiles.py +215 -0
  73. package/routes/report_issue.py +68 -0
  74. package/routes/static_files.py +533 -0
  75. package/routes/suno.py +664 -0
  76. package/routes/theme.py +81 -0
  77. package/routes/transcripts.py +199 -0
  78. package/routes/vision.py +348 -0
  79. package/routes/workspace.py +288 -0
  80. package/server.py +1510 -0
  81. package/services/__init__.py +1 -0
  82. package/services/auth.py +143 -0
  83. package/services/canvas_versioning.py +239 -0
  84. package/services/db_pool.py +107 -0
  85. package/services/gateway.py +16 -0
  86. package/services/gateway_manager.py +333 -0
  87. package/services/gateways/__init__.py +12 -0
  88. package/services/gateways/base.py +110 -0
  89. package/services/gateways/compat.py +264 -0
  90. package/services/gateways/openclaw.py +1134 -0
  91. package/services/health.py +100 -0
  92. package/services/memory_client.py +455 -0
  93. package/services/paths.py +26 -0
  94. package/services/speech_normalizer.py +285 -0
  95. package/services/tts.py +270 -0
  96. package/setup-config.js +262 -0
  97. package/sounds/air_horn.mp3 +0 -0
  98. package/sounds/bruh.mp3 +0 -0
  99. package/sounds/crowd_cheer.mp3 +0 -0
  100. package/sounds/gunshot.mp3 +0 -0
  101. package/sounds/impact.mp3 +0 -0
  102. package/sounds/lets_go.mp3 +0 -0
  103. package/sounds/record_stop.mp3 +0 -0
  104. package/sounds/rewind.mp3 +0 -0
  105. package/sounds/sad_trombone.mp3 +0 -0
  106. package/sounds/scratch_long.mp3 +0 -0
  107. package/sounds/yeah.mp3 +0 -0
  108. package/src/adapters/ClawdBotAdapter.js +264 -0
  109. package/src/adapters/_template.js +133 -0
  110. package/src/adapters/elevenlabs-classic.js +841 -0
  111. package/src/adapters/elevenlabs-hybrid.js +812 -0
  112. package/src/adapters/hume-evi.js +676 -0
  113. package/src/admin.html +1339 -0
  114. package/src/app.js +8802 -0
  115. package/src/core/Config.js +173 -0
  116. package/src/core/EmotionEngine.js +307 -0
  117. package/src/core/EventBridge.js +180 -0
  118. package/src/core/EventBus.js +117 -0
  119. package/src/core/VoiceSession.js +607 -0
  120. package/src/face/BaseFace.js +259 -0
  121. package/src/face/EyeFace.js +208 -0
  122. package/src/face/HaloSmokeFace.js +509 -0
  123. package/src/face/manifest.json +27 -0
  124. package/src/face/previews/eyes.svg +16 -0
  125. package/src/face/previews/orb.svg +29 -0
  126. package/src/features/MusicPlayer.js +620 -0
  127. package/src/features/Soundboard.js +128 -0
  128. package/src/providers/DeepgramSTT.js +472 -0
  129. package/src/providers/DeepgramStreamingSTT.js +766 -0
  130. package/src/providers/GroqSTT.js +559 -0
  131. package/src/providers/TTSPlayer.js +323 -0
  132. package/src/providers/WebSpeechSTT.js +479 -0
  133. package/src/providers/tts/BaseTTSProvider.js +81 -0
  134. package/src/providers/tts/HumeProvider.js +77 -0
  135. package/src/providers/tts/SupertonicProvider.js +174 -0
  136. package/src/providers/tts/index.js +140 -0
  137. package/src/shell/adapter-registry.js +154 -0
  138. package/src/shell/caller-bridge.js +35 -0
  139. package/src/shell/camera-bridge.js +28 -0
  140. package/src/shell/canvas-bridge.js +32 -0
  141. package/src/shell/commercial-bridge.js +44 -0
  142. package/src/shell/face-bridge.js +44 -0
  143. package/src/shell/music-bridge.js +60 -0
  144. package/src/shell/orchestrator.js +233 -0
  145. package/src/shell/profile-discovery.js +303 -0
  146. package/src/shell/sounds-bridge.js +28 -0
  147. package/src/shell/transcript-bridge.js +61 -0
  148. package/src/shell/waveform-bridge.js +33 -0
  149. package/src/styles/base.css +2862 -0
  150. package/src/styles/face.css +417 -0
  151. package/src/styles/pi-overrides.css +89 -0
  152. package/src/styles/theme-dark.css +67 -0
  153. package/src/test-tts.html +175 -0
  154. package/src/ui/AppShell.js +544 -0
  155. package/src/ui/ProfileSwitcher.js +228 -0
  156. package/src/ui/SessionControl.js +240 -0
  157. package/src/ui/face/FacePicker.js +195 -0
  158. package/src/ui/face/FaceRenderer.js +309 -0
  159. package/src/ui/settings/PlaylistEditor.js +366 -0
  160. package/src/ui/settings/SettingsPanel.css +684 -0
  161. package/src/ui/settings/SettingsPanel.js +419 -0
  162. package/src/ui/settings/TTSVoicePreview.js +210 -0
  163. package/src/ui/themes/ThemeManager.js +213 -0
  164. package/src/ui/visualizers/BaseVisualizer.js +29 -0
  165. package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
  166. package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
  167. package/static/emulators/jsdos/js-dos.css +1 -0
  168. package/static/emulators/jsdos/js-dos.js +22 -0
  169. package/static/favicon.svg +55 -0
  170. package/static/icons/apple-touch-icon.png +0 -0
  171. package/static/icons/favicon-32.png +0 -0
  172. package/static/icons/icon-192.png +0 -0
  173. package/static/icons/icon-512.png +0 -0
  174. package/static/install.html +449 -0
  175. package/static/manifest.json +26 -0
  176. package/static/sw.js +21 -0
  177. package/tts_providers/__init__.py +136 -0
  178. package/tts_providers/base_provider.py +319 -0
  179. package/tts_providers/groq_provider.py +155 -0
  180. package/tts_providers/hume_provider.py +226 -0
  181. package/tts_providers/providers_config.json +119 -0
  182. package/tts_providers/qwen3_provider.py +371 -0
  183. package/tts_providers/resemble_provider.py +315 -0
  184. package/tts_providers/supertonic_provider.py +557 -0
  185. package/tts_providers/supertonic_tts.py +399 -0
@@ -0,0 +1 @@
1
+ # services package
@@ -0,0 +1,143 @@
1
+ """
2
+ Clerk JWT authentication middleware for OpenVoiceUI.
3
+
4
+ Verifies Clerk session tokens from:
5
+ 1. Authorization: Bearer <token> header
6
+ 2. __session cookie (set automatically by Clerk for browser requests)
7
+
8
+ Usage:
9
+ from services.auth import verify_clerk_token, get_token_from_request
10
+
11
+ token = get_token_from_request()
12
+ user_id = verify_clerk_token(token) # returns str or None
13
+ """
14
+ import logging
15
+ import os
16
+ import time
17
+ from functools import lru_cache
18
+ from typing import Optional
19
+
20
+ import jwt
21
+ import requests
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Configuration
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def _derive_clerk_domain(key: str) -> str:
30
+ """Derive the Clerk frontend domain from a publishable key (pk_live_/pk_test_)."""
31
+ import base64
32
+ try:
33
+ suffix = key.split('_', 2)[-1]
34
+ padding = (4 - len(suffix) % 4) % 4
35
+ decoded = base64.b64decode(suffix + '=' * padding).decode('utf-8').rstrip('$')
36
+ return decoded
37
+ except Exception:
38
+ return ''
39
+
40
+ _raw_clerk_key = (os.getenv('CLERK_PUBLISHABLE_KEY') or os.getenv('VITE_CLERK_PUBLISHABLE_KEY', '')).strip()
41
+ _CLERK_FRONTEND_DOMAIN = os.getenv('CLERK_FRONTEND_API') or (_derive_clerk_domain(_raw_clerk_key) if _raw_clerk_key else '')
42
+ _JWKS_URL = f'https://{_CLERK_FRONTEND_DOMAIN}/.well-known/jwks.json'
43
+ _JWKS_CACHE_TTL = 3600 # refresh keys every 60 minutes
44
+
45
+ # Allowlist of Clerk user IDs permitted to access this deployment.
46
+ # Set ALLOWED_USER_IDS=user_abc123,user_xyz789 in .env
47
+ # If the env var is empty or unset, the check is SKIPPED (open to any valid Clerk user).
48
+ # Always set this in production — agents have full access.
49
+ _raw_allowed = os.getenv('ALLOWED_USER_IDS', '')
50
+ _ALLOWED_USER_IDS: set[str] = {uid.strip() for uid in _raw_allowed.split(',') if uid.strip()}
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # JWKS cache
54
+ # ---------------------------------------------------------------------------
55
+
56
+ _jwks_cache: dict = {'keys': None, 'fetched_at': 0}
57
+
58
+
59
+ def _get_jwks() -> list:
60
+ """Return cached JWKS key list, refreshing if stale."""
61
+ now = time.time()
62
+ if _jwks_cache['keys'] is None or (now - _jwks_cache['fetched_at']) > _JWKS_CACHE_TTL:
63
+ try:
64
+ resp = requests.get(_JWKS_URL, timeout=10)
65
+ resp.raise_for_status()
66
+ _jwks_cache['keys'] = resp.json().get('keys', [])
67
+ _jwks_cache['fetched_at'] = now
68
+ logger.debug('JWKS refreshed (%d keys)', len(_jwks_cache['keys']))
69
+ except Exception as exc:
70
+ logger.error('Failed to fetch JWKS from %s: %s', _JWKS_URL, exc)
71
+ # Return stale keys if available
72
+ if _jwks_cache['keys']:
73
+ return _jwks_cache['keys']
74
+ return []
75
+ return _jwks_cache['keys']
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Token verification
80
+ # ---------------------------------------------------------------------------
81
+
82
+ def verify_clerk_token(token: str) -> Optional[str]:
83
+ """
84
+ Verify a Clerk JWT and return the user_id (sub claim) if valid.
85
+
86
+ Returns None if the token is missing, malformed, or invalid.
87
+ """
88
+ if not token:
89
+ return None
90
+
91
+ keys = _get_jwks()
92
+ if not keys:
93
+ logger.warning('No JWKS keys available — cannot verify token')
94
+ return None
95
+
96
+ for key_data in keys:
97
+ try:
98
+ public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key_data)
99
+ payload = jwt.decode(
100
+ token,
101
+ public_key,
102
+ algorithms=['RS256'],
103
+ options={'verify_aud': False}, # Clerk tokens don't use aud in all configs
104
+ )
105
+ user_id = payload.get('sub')
106
+ if not user_id:
107
+ return None
108
+ # Log user_id on every successful auth so it can be captured for ALLOWED_USER_IDS
109
+ logger.info('Clerk auth: user_id=%s', user_id)
110
+ # Enforce allowlist if configured
111
+ if _ALLOWED_USER_IDS and user_id not in _ALLOWED_USER_IDS:
112
+ logger.warning('Clerk auth: user_id=%s not in ALLOWED_USER_IDS — access denied', user_id)
113
+ return None
114
+ return user_id
115
+ except jwt.ExpiredSignatureError:
116
+ logger.debug('Token expired')
117
+ return None
118
+ except jwt.InvalidTokenError:
119
+ continue # try next key
120
+
121
+ logger.debug('Token did not validate against any JWKS key')
122
+ return None
123
+
124
+
125
+ def get_token_from_request() -> Optional[str]:
126
+ """
127
+ Extract Clerk session token from the current Flask request.
128
+
129
+ Checks in order:
130
+ 1. Authorization: Bearer <token>
131
+ 2. __session cookie
132
+ """
133
+ from flask import request
134
+
135
+ auth_header = request.headers.get('Authorization', '')
136
+ if auth_header.startswith('Bearer '):
137
+ return auth_header[7:].strip()
138
+
139
+ cookie_token = request.cookies.get('__session')
140
+ if cookie_token:
141
+ return cookie_token
142
+
143
+ return None
@@ -0,0 +1,239 @@
1
+ """
2
+ Canvas Page Versioning — automatic version history for canvas pages.
3
+
4
+ Watches the canvas-pages directory for file changes and saves previous
5
+ versions to a .versions/ subdirectory. This catches ALL writes regardless
6
+ of source (agent tool, API, manual edit).
7
+
8
+ Usage:
9
+ from services.canvas_versioning import start_version_watcher, list_versions, restore_version
10
+
11
+ Version files are stored as:
12
+ canvas-pages/.versions/<page-stem>.<unix-timestamp>.html
13
+
14
+ Auto-cleanup keeps the last MAX_VERSIONS_PER_PAGE versions per page.
15
+ """
16
+
17
+ import hashlib
18
+ import logging
19
+ import os
20
+ import shutil
21
+ import threading
22
+ import time
23
+ from pathlib import Path
24
+
25
+ from services.paths import CANVAS_PAGES_DIR
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Configuration
31
+ # ---------------------------------------------------------------------------
32
+
33
+ MAX_VERSIONS_PER_PAGE = 20 # Keep last N versions per page
34
+ CHECK_INTERVAL_SECONDS = 15 # How often to scan for changes
35
+ VERSIONS_DIRNAME = '.versions' # Subdirectory name inside canvas-pages
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Internal state
39
+ # ---------------------------------------------------------------------------
40
+
41
+ _file_hashes: dict[str, str] = {} # filename -> content hash
42
+ _file_contents: dict[str, bytes] = {} # filename -> last-known content (for saving)
43
+ _watcher_thread: threading.Thread | None = None
44
+ _stop_event = threading.Event()
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Helpers
48
+ # ---------------------------------------------------------------------------
49
+
50
+ def _versions_dir() -> Path:
51
+ """Return the .versions/ directory path, creating it if needed."""
52
+ vdir = CANVAS_PAGES_DIR / VERSIONS_DIRNAME
53
+ vdir.mkdir(parents=True, exist_ok=True)
54
+ return vdir
55
+
56
+
57
+ def _content_hash(data: bytes) -> str:
58
+ """SHA-256 hash of file content."""
59
+ return hashlib.sha256(data).hexdigest()
60
+
61
+
62
+ def _save_version(filename: str, old_content: bytes) -> Path | None:
63
+ """Save old_content as a timestamped version file."""
64
+ try:
65
+ stem = Path(filename).stem
66
+ timestamp = int(time.time())
67
+ version_name = f'{stem}.{timestamp}.html'
68
+ version_path = _versions_dir() / version_name
69
+ version_path.write_bytes(old_content)
70
+ logger.info(f'Canvas version saved: {version_name} ({len(old_content)} bytes)')
71
+ _cleanup_versions(stem)
72
+ return version_path
73
+ except Exception as exc:
74
+ logger.error(f'Failed to save canvas version for {filename}: {exc}')
75
+ return None
76
+
77
+
78
+ def _cleanup_versions(page_stem: str) -> None:
79
+ """Keep only the latest MAX_VERSIONS_PER_PAGE versions for a page."""
80
+ vdir = _versions_dir()
81
+ versions = sorted(
82
+ vdir.glob(f'{page_stem}.*.html'),
83
+ key=lambda p: p.stat().st_mtime,
84
+ reverse=True,
85
+ )
86
+ for old_version in versions[MAX_VERSIONS_PER_PAGE:]:
87
+ try:
88
+ old_version.unlink()
89
+ logger.debug(f'Pruned old version: {old_version.name}')
90
+ except Exception:
91
+ pass
92
+
93
+
94
+ def _initial_scan() -> None:
95
+ """Scan all existing pages and record their hashes (no versioning on startup)."""
96
+ if not CANVAS_PAGES_DIR.exists():
97
+ return
98
+ for page_path in CANVAS_PAGES_DIR.glob('*.html'):
99
+ try:
100
+ content = page_path.read_bytes()
101
+ _file_hashes[page_path.name] = _content_hash(content)
102
+ _file_contents[page_path.name] = content
103
+ except Exception as exc:
104
+ logger.debug(f'Initial scan: could not read {page_path.name}: {exc}')
105
+
106
+
107
+ def _check_for_changes() -> None:
108
+ """Scan canvas-pages for modified files and save versions."""
109
+ if not CANVAS_PAGES_DIR.exists():
110
+ return
111
+
112
+ current_files = set()
113
+ for page_path in CANVAS_PAGES_DIR.glob('*.html'):
114
+ filename = page_path.name
115
+ current_files.add(filename)
116
+ try:
117
+ content = page_path.read_bytes()
118
+ new_hash = _content_hash(content)
119
+
120
+ if filename in _file_hashes:
121
+ if new_hash != _file_hashes[filename]:
122
+ # File changed — save the OLD content as a version
123
+ old_content = _file_contents.get(filename)
124
+ if old_content:
125
+ _save_version(filename, old_content)
126
+ _file_hashes[filename] = new_hash
127
+ _file_contents[filename] = content
128
+ else:
129
+ # New file — just record it (no previous version to save)
130
+ _file_hashes[filename] = new_hash
131
+ _file_contents[filename] = content
132
+
133
+ except Exception as exc:
134
+ logger.debug(f'Version check: could not read {filename}: {exc}')
135
+
136
+ # Note: we don't remove deleted files from tracking — they might come back
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Background watcher
141
+ # ---------------------------------------------------------------------------
142
+
143
+ def _watcher_loop() -> None:
144
+ """Background thread that periodically checks for file changes."""
145
+ logger.info(f'Canvas version watcher started (interval={CHECK_INTERVAL_SECONDS}s, max_versions={MAX_VERSIONS_PER_PAGE})')
146
+ _initial_scan()
147
+
148
+ while not _stop_event.is_set():
149
+ try:
150
+ _check_for_changes()
151
+ except Exception as exc:
152
+ logger.error(f'Canvas version watcher error: {exc}')
153
+ _stop_event.wait(CHECK_INTERVAL_SECONDS)
154
+
155
+ logger.info('Canvas version watcher stopped')
156
+
157
+
158
+ def start_version_watcher() -> None:
159
+ """Start the background version watcher thread."""
160
+ global _watcher_thread
161
+ if _watcher_thread and _watcher_thread.is_alive():
162
+ logger.debug('Canvas version watcher already running')
163
+ return
164
+ _stop_event.clear()
165
+ _watcher_thread = threading.Thread(
166
+ target=_watcher_loop,
167
+ name='canvas-version-watcher',
168
+ daemon=True,
169
+ )
170
+ _watcher_thread.start()
171
+
172
+
173
+ def stop_version_watcher() -> None:
174
+ """Stop the background version watcher thread."""
175
+ _stop_event.set()
176
+ if _watcher_thread:
177
+ _watcher_thread.join(timeout=5)
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Public API (used by canvas.py routes)
182
+ # ---------------------------------------------------------------------------
183
+
184
+ def list_versions(page_id: str) -> list[dict]:
185
+ """List all saved versions for a page, newest first."""
186
+ vdir = _versions_dir()
187
+ versions = []
188
+ for vpath in sorted(vdir.glob(f'{page_id}.*.html'), key=lambda p: p.stat().st_mtime, reverse=True):
189
+ # Parse timestamp from filename: page-id.1709510400.html
190
+ parts = vpath.stem.rsplit('.', 1)
191
+ if len(parts) == 2:
192
+ try:
193
+ ts = int(parts[1])
194
+ except ValueError:
195
+ ts = int(vpath.stat().st_mtime)
196
+ else:
197
+ ts = int(vpath.stat().st_mtime)
198
+
199
+ versions.append({
200
+ 'filename': vpath.name,
201
+ 'timestamp': ts,
202
+ 'iso': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts)),
203
+ 'size': vpath.stat().st_size,
204
+ })
205
+ return versions
206
+
207
+
208
+ def restore_version(page_id: str, timestamp: int) -> bool:
209
+ """Restore a specific version, saving the current as a new version first."""
210
+ version_file = _versions_dir() / f'{page_id}.{timestamp}.html'
211
+ current_file = CANVAS_PAGES_DIR / f'{page_id}.html'
212
+
213
+ if not version_file.exists():
214
+ logger.error(f'Version not found: {version_file}')
215
+ return False
216
+
217
+ # Save current as a version before restoring
218
+ if current_file.exists():
219
+ current_content = current_file.read_bytes()
220
+ _save_version(current_file.name, current_content)
221
+
222
+ # Restore the old version
223
+ restored_content = version_file.read_bytes()
224
+ current_file.write_bytes(restored_content)
225
+
226
+ # Update tracking
227
+ _file_hashes[current_file.name] = _content_hash(restored_content)
228
+ _file_contents[current_file.name] = restored_content
229
+
230
+ logger.info(f'Restored canvas page {page_id} to version {timestamp}')
231
+ return True
232
+
233
+
234
+ def get_version_content(page_id: str, timestamp: int) -> bytes | None:
235
+ """Get the content of a specific version."""
236
+ version_file = _versions_dir() / f'{page_id}.{timestamp}.html'
237
+ if version_file.exists():
238
+ return version_file.read_bytes()
239
+ return None
@@ -0,0 +1,107 @@
1
+ # services/db_pool.py
2
+ """SQLite connection pool with WAL mode enabled.
3
+
4
+ Recipe R2 — SQLite WAL + Connection Pool
5
+ Fixes SQLite write contention under concurrent Flask request handling.
6
+
7
+ Usage:
8
+ from services.db_pool import SQLitePool
9
+ db = SQLitePool("usage.db")
10
+
11
+ # Write (auto-commit, retries on busy)
12
+ db.execute("INSERT INTO conversation_log VALUES (?)", (message,))
13
+
14
+ # Read (concurrent reads via WAL)
15
+ results = db.query("SELECT * FROM conversation_log LIMIT 10")
16
+
17
+ # Manual connection (for row_factory etc.)
18
+ with db.get_connection() as conn:
19
+ conn.row_factory = sqlite3.Row
20
+ rows = conn.execute("SELECT * FROM foo").fetchall()
21
+ """
22
+ import sqlite3
23
+ import threading
24
+ from contextlib import contextmanager
25
+ from queue import Queue, Empty
26
+ import time
27
+
28
+
29
+ class SQLitePool:
30
+ """Thread-safe SQLite connection pool with WAL mode."""
31
+
32
+ def __init__(self, db_path: str, pool_size: int = 5):
33
+ self.db_path = str(db_path)
34
+ self.pool_size = pool_size
35
+ self._pool = Queue(maxsize=pool_size)
36
+ self._lock = threading.Lock()
37
+
38
+ # Initialize pool with WAL-enabled connections
39
+ for _ in range(pool_size):
40
+ self._pool.put(self._create_connection())
41
+
42
+ def _create_connection(self) -> sqlite3.Connection:
43
+ """Create a new connection with WAL mode and optimized settings."""
44
+ conn = sqlite3.connect(
45
+ self.db_path,
46
+ timeout=30.0,
47
+ check_same_thread=False,
48
+ )
49
+
50
+ # Enable WAL mode for concurrent reads without blocking writers
51
+ conn.execute("PRAGMA journal_mode=WAL")
52
+ # NORMAL is safe with WAL and much faster than FULL
53
+ conn.execute("PRAGMA synchronous=NORMAL")
54
+ # 64 MB page cache per connection
55
+ conn.execute("PRAGMA cache_size=-64000")
56
+ # 30 s busy timeout (belt-and-suspenders alongside pool timeout)
57
+ conn.execute("PRAGMA busy_timeout=30000")
58
+
59
+ return conn
60
+
61
+ @contextmanager
62
+ def get_connection(self):
63
+ """Get a connection from the pool (blocks up to 35 s before raising)."""
64
+ try:
65
+ conn = self._pool.get(timeout=35)
66
+ except Empty:
67
+ raise RuntimeError(
68
+ "SQLitePool: timed out waiting for a free connection. "
69
+ f"Pool size is {self.pool_size}."
70
+ )
71
+ try:
72
+ yield conn
73
+ finally:
74
+ self._pool.put(conn)
75
+
76
+ def execute(self, query: str, params: tuple = ()):
77
+ """Execute a write query with automatic retry on busy."""
78
+ max_retries = 3
79
+ for attempt in range(max_retries):
80
+ try:
81
+ with self.get_connection() as conn:
82
+ cursor = conn.execute(query, params)
83
+ conn.commit()
84
+ return cursor
85
+ except sqlite3.OperationalError as e:
86
+ if "locked" in str(e) and attempt < max_retries - 1:
87
+ time.sleep(0.1 * (attempt + 1))
88
+ continue
89
+ raise
90
+
91
+ def query(self, query: str, params: tuple = ()) -> list:
92
+ """Execute a read query and return all rows."""
93
+ with self.get_connection() as conn:
94
+ cursor = conn.execute(query, params)
95
+ return cursor.fetchall()
96
+
97
+ def close(self):
98
+ """Drain the pool and close all connections."""
99
+ closed = 0
100
+ while not self._pool.empty():
101
+ try:
102
+ conn = self._pool.get_nowait()
103
+ conn.close()
104
+ closed += 1
105
+ except Empty:
106
+ break
107
+ return closed
@@ -0,0 +1,16 @@
1
+ """
2
+ Backwards-compatibility shim.
3
+
4
+ The gateway implementation has moved to:
5
+ services/gateways/openclaw.py — OpenClaw persistent WS gateway
6
+ services/gateway_manager.py — registry, plugin loader, router
7
+
8
+ New code should import from gateway_manager:
9
+ from services.gateway_manager import gateway_manager
10
+
11
+ Existing code that imports gateway_connection continues to work unchanged:
12
+ from services.gateway import gateway_connection
13
+ """
14
+ from services.gateway_manager import gateway_manager as gateway_connection
15
+
16
+ __all__ = ['gateway_connection']