voice_control 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 21230246c534702dc5e7304fce70009b50a1db239ed73b78e4d6eaaf41aa8fc4
4
+ data.tar.gz: 86c1e751e45bb9f59b286be138896edb4a95c84f61b6dd36fce2b2d9ac83962c
5
+ SHA512:
6
+ metadata.gz: f2d7e8be4cb052ed7b96874e80b71c50ba8c20bdcee9b2b110699dc999c27d85adf7cc726c8456c2a0fb7b81dba31ed72c2046dad3b3e9908607a050537ebed9
7
+ data.tar.gz: 0e13f05400026ea9a8a99742fe0981c49d76259db2d55b1fcbcf305367965006f11b1b7196c40051539b3e140368a00ba679b3cc5c2ed523a371922ada6e619a
data/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ - Allow installation on Ruby 3.2+; CI runs on Ruby 4.0.
6
+ - Stay within Jev's 255-choice limit: skip the longest commands beyond 254 and log a warning instead of failing the request.
7
+ - Add `speech_language` to configure the browser speech recognition language.
8
+ - Rename the gem and repository to `voice_control`, with `VoiceControl` Ruby/JavaScript APIs, `voice_control:install`, and the `voice-control-widget` component.
9
+ - Add `pages:` command scopes for exact paths and regexes, with navigation-aware help and a custom-router `VoiceControl.refresh()` hook.
10
+ - Add a page-wide dynamic-action opt-out meta tag; enforce ignored forms for externally associated controls.
11
+ - Reject delayed command execution after navigation and links whose navigation behavior changed while pending.
12
+ - Document notifications without reload and add a scoped workspace-summary demo command.
13
+ - Bound widget requests with configurable `request_timeout`; report uncertain execution without retrying actions.
14
+ - Add optional `notify:` completion notices to result helpers, preserved briefly across navigation and reloads.
15
+ - Restore continuous listening after command-triggered reloads and browser back/forward cache restoration.
16
+ - Run real Turbo navigation, reload, timeout, and widget regressions in Chromium, Firefox, and WebKit CI.
17
+ - Add Ruby and JavaScript coverage reports, behavioral tests, and CI coverage gates above 95%.
18
+ - Keep both “Logout” and “Log out” out of suggested commands.
19
+
20
+ - Add `VoiceControl::Result.reload` to refresh the current page after successful commands.
21
+
22
+ - Deduplicate equivalent links in browser actions, help, and command choices.
23
+
24
+ - Constrain JSON to 2.x for Rails 8.0/8.1 compatibility in fresh installs and CI.
25
+
26
+ - Add a small launcher option, explain initializer settings, and expand the README with command examples.
27
+
28
+ - Discover only controls with readable text or labels, and show command descriptions alongside Jev probability IDs.
29
+
30
+ - Prevent canceled interpretations and incomplete speech fragments from executing; restore listeners after widget reinsertion.
31
+ - Reject changed form ownership/submit behavior and oversized signed state before issuing unusable tickets.
32
+ - Add a development-only execution cache fallback, dependency-free browser regression fixtures, and Rails 8.0/8.1 CI coverage.
33
+ - Reorganize the README and ship guides for commands, browser actions, configuration, deployment, and privacy.
34
+
35
+ - Resolve exact browser click labels before Jev, and show Jev's action answer, probabilities, and readable candidates in debug mode.
36
+
37
+ - Add “submit” for the active form, with native validation, submitter semantics, and stale-form protection.
38
+
39
+ - Add browser back/forward commands and separate interpretation/execution progress messages.
40
+
41
+ - Add keyboard help navigation, configurable left/right positioning, and a hold-to-talk shortcut.
42
+ - Support native date/time entry and contextual labels for generic table-row actions.
43
+
44
+ - Highlight action targets, clear named/selected fields with Undo, and scroll the page or discovered headings.
45
+ - Add typo-tolerant help search and a Copy details button for the latest debug report.
46
+
47
+ - Discover checkbox check/uncheck and radio choose actions, including spoken aliases and checkbox undo.
48
+ - Add opt-in command diagnostics, a green/cream widget, and a Google-inspired demo design.
49
+
50
+ - Add a Rails engine with configurable parent controller and authorization.
51
+ - Define grouped Ruby commands with typed arguments, extractors, defaults and validation.
52
+ - Route commands through Jev and collect missing arguments with signed continuations.
53
+ - Add automatic execution with CSRF, session binding and cache-based replay protection.
54
+ - Add a Shadow DOM voice/typing widget with Turbo support, browser events and help.
55
+ - Include an install generator and a credential-free dummy app.
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,56 @@
1
+ # Contributing
2
+
3
+ Use Ruby 3.2+ and Rails 8.x. From this gem's directory:
4
+
5
+ ```sh
6
+ bundle install
7
+ bundle exec rake test
8
+ bundle exec rubocop --ignore-parent-exclusion
9
+ npm ci --ignore-scripts
10
+ npm run test:coverage
11
+ npm run lint
12
+ bin/demo
13
+ ```
14
+
15
+ Minitest uses an isolated SQLite dummy app and stubs Jev HTTP calls. It does not require a provider key or make paid calls. Add a failing regression before fixing behavior. Keep application-specific services and policies outside the gem. Runtime JavaScript is shipped directly; no Node build is required.
16
+
17
+ ## Coverage
18
+
19
+ Run `bundle exec rake test` for Ruby coverage and `npm run test:coverage` for JavaScript coverage (Node 24.15+ or 26+; CI uses Node 24). Both commands fail if line or branch coverage falls below **95.01%**. JavaScript also enforces 95.01% statements and functions.
20
+
21
+ Open `coverage/ruby/index.html` and `coverage/javascript/index.html` for uncovered lines and branches. Machine-readable reports are at `coverage/ruby/coverage.json` and `coverage/javascript/coverage-summary.json`; CI uploads the reports even when a test or threshold fails.
22
+
23
+ Ruby coverage includes all executable Ruby under `lib`, `app`, and `config`, including generator code and its initializer template. JavaScript coverage includes all `assets/**/*.js`. Tests, the dummy app, examples, dependencies, and reports are outside those production scopes; unloaded production files still count. Keep the scopes intact and add behavioral tests instead of coverage-ignore comments. Run the full suite for a representative report; individual Ruby test files are still subject to the aggregate coverage gate.
24
+
25
+ The JavaScript suite uses Vitest, jsdom, and V8. It exercises the real widget and DOM events with stubbed HTTP/speech and simulated layout, animation, and scrolling APIs. These percentages do not prove microphone recognition or real-browser rendering: keep running the browser checks below for native form submission, navigation, and layout behavior.
26
+
27
+ ## Browser checks
28
+
29
+ Run the Playwright suite in Chromium, Firefox, and WebKit:
30
+
31
+ ```sh
32
+ npx playwright install chromium firefox webkit
33
+ npm run test:browser
34
+ ```
35
+
36
+ The suite starts its own local fixture server and runs in CI. It covers native form submission, cancellation, stale forms, reconnection, debug rendering, request timeouts, optional completion notices, and consecutive speech commands across real Turbo navigation and full reloads. Speech events and provider responses are simulated; no Jev key or microphone is required. Test real recognition and permissions separately.
37
+
38
+ Open `playwright-report/index.html` for results; failing tests retain traces under `test-results/`. CI uploads both directories. To inspect the dependency-free regression fixture manually, run `python3 -m http.server 4205 --bind 127.0.0.1` and open [the fixture](http://127.0.0.1:4205/test/browser/index.html).
39
+
40
+ JavaScript lint configuration is provided in `eslint.config.mjs`; use ESLint 9 or 10 and Prettier 3 when editing assets. Test Turbo navigation in the dummy app and custom router behavior in your integration. Run against real Redis when changing replay protection; separate processes must claim a ticket only once.
41
+
42
+ CI tests Rails 8.0 and 8.1. To select a version locally, use a separate checkout without its ignored lockfile and run `RAILS_VERSION='~> 8.0.0' bundle install`, then `RAILS_VERSION='~> 8.0.0' bundle exec rake test`. The gemspec constrains JSON below 3 for both supported Rails versions. Use fresh dependency resolution when changing dependencies; an existing local lockfile can hide CI failures.
43
+
44
+ ## Packaging
45
+
46
+ ```sh
47
+ gem build voice_control.gemspec
48
+ gem install --local voice_control-0.1.0.gem
49
+ gem contents voice_control --show-install-dir # After installing your local build
50
+ ```
51
+
52
+ Inspect the archive: runtime files, examples, and documentation should be present; demo databases, logs, credentials, test output, and development dependencies must not ship. Install the built gem in a disposable Rails application, run `voice_control:install`, and verify authorization, CSRF, rendering, and a complete command. Source-path tests alone do not verify packaging.
53
+
54
+ Document public configuration and behavior changes, update CHANGELOG.md, and include reproduction steps for browser issues. Do not add framework-wide abstractions for a single command. A bug report should contain Ruby/Rails/browser versions, the minimal vocabulary, expected/actual behavior, and redacted command diagnostics.
55
+
56
+ The repository is currently private and the gem is not published to RubyGems. A private vulnerability-reporting channel must be established before public release.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Igor Kasyanchuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,343 @@
1
+ # voice_control
2
+
3
+ [![Watch the voice_control demo on YouTube](docs/demo.png)](https://youtu.be/Zz1Ibx8R7WI)
4
+
5
+ [▶ Watch the demo on YouTube](https://youtu.be/Zz1Ibx8R7WI)
6
+
7
+ **You say it. Your app does it.**
8
+
9
+ Voice and typed commands for Rails. Define actions in Ruby, keep your authorization, and give users a shortcut to the work they already do.
10
+
11
+ ```ruby
12
+ VoiceControl.configure do |config|
13
+ config.command :open_users,
14
+ description: "Open users", aliases: ["show people"] do
15
+ execute { |_args, _context| VoiceControl::Result.navigate(main_app.users_path) }
16
+ end
17
+ end
18
+ ```
19
+
20
+ Say **“show people.”** Your app opens its users page.
21
+
22
+ | You provide | voice_control handles |
23
+ | --- | --- |
24
+ | Ruby actions and permissions | Command matching, typed arguments, and follow-up questions |
25
+ | Existing buttons and forms | Optional page discovery: click, fill, select, check, and submit |
26
+ | Your Rails app | A Shadow DOM widget, searchable help, Turbo support, and JavaScript hooks |
27
+
28
+ **No runtime database, migrations, Node build, or CSS framework.** Rails 8.x, Ruby 3.2+, MIT. A short-lived execution cache is required; production uses a shared atomic cache.
29
+
30
+ The gem requires JSON 2.x because the supported Rails 8.0/8.1 versions use JSON APIs changed in JSON 3. Bundler applies this constraint automatically.
31
+
32
+ **Status:** unreleased. Installation currently uses this source checkout or a locally built gem. Commands execute immediately once their arguments are complete; no confirmation dialog is added. Start with navigation and actions you can safely expose to the authorized user.
33
+
34
+ [Quick start](#quick-start) · [Configuration](#configuration) · [Demo](#try-it-locally) · [Ruby DSL](docs/commands.md) · [Browser actions](docs/browser-actions.md) · [Deployment](docs/deployment.md)
35
+
36
+ ## Quick start
37
+
38
+ Add it to your application's Gemfile:
39
+
40
+ ```ruby
41
+ # Gemfile
42
+ gem "voice_control"
43
+ ```
44
+
45
+ The gem is not published to RubyGems yet. Until release, [build and install a local copy](CONTRIBUTING.md#packaging), then use `bundle install --local` for the first command below.
46
+
47
+ ```sh
48
+ bundle install
49
+ bin/rails generate voice_control:install
50
+ ```
51
+
52
+ The generator creates an initializer and mounts `/voice_control`. Edit the generated initializer:
53
+
54
+ ```ruby
55
+ VoiceControl.configure do |config|
56
+ config.api_key = ENV.fetch("JEV_API_KEY")
57
+ config.authorize = -> { current_user&.admin? } # Use your app's access check.
58
+ config.browser_actions = true
59
+ config.launcher_size = :small # Optional: smaller floating microphone button.
60
+
61
+ config.group "Navigation" do
62
+ config.command :home, description: "Open home", aliases: ["go home"] do
63
+ execute { |_args, _context| VoiceControl::Result.navigate(main_app.root_path) }
64
+ end
65
+ end
66
+ end
67
+ ```
68
+
69
+ Replace the initializer's example block rather than appending another `home` command. Supply your [Jev](https://docs.typesafe.ai/introduction/quickstart) key in the server environment and restart Rails. The key never reaches the browser.
70
+
71
+ Add the widget before `</body>` in your layout. Keep the existing CSRF tags in `<head>`:
72
+
73
+ ```erb
74
+ <!-- In <head>, if your layout does not already include it: -->
75
+ <%= csrf_meta_tags %>
76
+
77
+ <!-- Before </body>: -->
78
+ <%= voice_control_widget %>
79
+ ```
80
+
81
+ Sign in with an authorized account. Click the microphone or press **⌘⇧U / Ctrl+Shift+U**, then type **“go home.”** Open **?** to see available commands. Help selections work without a Jev key; free-form interpretation needs one. Authorization defaults to denying access until configured.
82
+
83
+ Assets are served by the engine. No importmap, bundler, or asset-manifest changes are needed. Standard cookie sessions and CSRF protection are required; API-only apps need those Rails facilities enabled. In development, a disabled `NullStore` gets a local MemoryStore fallback. [Configure a shared cache before production.](docs/deployment.md)
84
+
85
+ ## Try it locally
86
+
87
+ Clone the repository (access is required while it is private), then start the demo:
88
+
89
+ ```sh
90
+ git clone git@github.com:igorkasyanchuk/voice_control.git
91
+ cd voice_control
92
+ bundle install
93
+ bin/demo
94
+ ```
95
+
96
+ Open [127.0.0.1:4100](http://127.0.0.1:4100). No credentials are needed: the demo uses deterministic local matching without a key, and Jev when `JEV_API_KEY` is present.
97
+
98
+ | Say or type | Where |
99
+ | --- | --- |
100
+ | `open users` | Any demo page |
101
+ | `give user 42 100 tokens` | Any demo page; updates the user's balance |
102
+ | `change my plan` | Any demo page; asks which plan |
103
+ | `fill workspace name with Studio North` | Settings |
104
+ | `submit` | After selecting a Settings field |
105
+ | `summarize workspace` | Settings only; shows details and a notice without reloading |
106
+ | `undo that` | After an unsaved field edit |
107
+
108
+ The standalone demo includes user CRUD, settings, account plans, and 24 seeded users. Its SQLite database persists changes between runs; SQLite is a development dependency, not a gem runtime requirement. [Demo guide →](docs/demo.md)
109
+
110
+ ## More command examples
111
+
112
+ Add these inside your `VoiceControl.configure` block. Route examples assume your application has `users_path` and `reports_path`; replace them with your own routes.
113
+
114
+ **Search with a text argument:** “find users” asks for a search term; “find users named Alex” opens the results directly.
115
+
116
+ ```ruby
117
+ config.command :find_users, description: "Find users by name",
118
+ aliases: ["search users", "find users"], examples: ["find users named Alex"] do
119
+ argument :query, :string, prompt: "Who are you looking for?",
120
+ extract: ->(text, _context) { text[/\bnamed\s+(.+)\z/i, 1] }
121
+
122
+ execute do |args, _context|
123
+ VoiceControl::Result.navigate(main_app.users_path(q: args[:query]))
124
+ end
125
+ end
126
+ ```
127
+
128
+ **Choose from a fixed list:** “show weekly report” opens it; “show report” asks which period.
129
+
130
+ ```ruby
131
+ config.group "Reports" do
132
+ config.command :show_report, description: "Open a daily, weekly or monthly report",
133
+ aliases: ["show report"], examples: ["show weekly report"] do
134
+ argument :period, :enum, values: %w[daily weekly monthly],
135
+ prompt: "Daily, weekly or monthly?",
136
+ extract: ->(text, _context) { text[/\b(daily|weekly|monthly)\b/i, 1] }
137
+
138
+ execute do |args, _context|
139
+ VoiceControl::Result.navigate(main_app.reports_path(period: args[:period]))
140
+ end
141
+ end
142
+ end
143
+ ```
144
+
145
+ **Return a message without navigating:** “what is my account ID” displays the signed-in account's ID. This example assumes your controller provides `current_user`.
146
+
147
+ ```ruby
148
+ config.command :account_id, description: "Show my account ID",
149
+ aliases: ["what is my account ID"] do
150
+ execute do |_args, _context|
151
+ VoiceControl::Result.message("Your account ID is #{current_user.id}.")
152
+ end
153
+ end
154
+ ```
155
+
156
+ **Trigger your JavaScript:** “show shortcuts” dispatches an event that your app can use to open a dialog.
157
+
158
+ ```ruby
159
+ config.command :show_shortcuts, description: "Show keyboard shortcuts",
160
+ aliases: ["show shortcuts"] do
161
+ execute { |_args, _context| VoiceControl::Result.event("app:show-shortcuts") }
162
+ end
163
+ ```
164
+
165
+ ```javascript
166
+ // Add a <dialog id="keyboard-shortcuts"> to your app, then register this once.
167
+ window.addEventListener("app:show-shortcuts", () => {
168
+ document.querySelector("#keyboard-shortcuts")?.showModal();
169
+ });
170
+ ```
171
+
172
+ ## Use your existing business logic
173
+
174
+ Commands are Ruby callbacks, not generated code. Add record-level permission checks for mutations:
175
+
176
+ ```ruby
177
+ config.command :archive_project,
178
+ description: "Archive a project",
179
+ visible: -> { current_user.admin? },
180
+ authorize: ->(args, _context) { policy(Project.find(args[:id])).archive? } do
181
+ argument :id, :integer,
182
+ extract: ->(text, _context) { text[/\bproject (\d+)\b/i, 1] },
183
+ validate: ->(id) { id.positive? },
184
+ prompt: "Which project ID?"
185
+
186
+ execute do |args, _context|
187
+ Project.find(args[:id]).archive! # Your existing operation.
188
+ VoiceControl::Result.message("Project archived.")
189
+ end
190
+ end
191
+ ```
192
+
193
+ This example assumes your app provides `Project#archive!` and a Pundit-style `policy`. Callbacks run in the request controller; `main_app`, `current_user`, and your application helpers are available there. Arguments use symbol keys, context uses string keys. Your action owns transactions, tenant scoping, and domain idempotency.
194
+
195
+ Return `VoiceControl::Result.reload` after a successful mutation when the whole page should reflect the change, such as an account plan update. It reloads the current URL, including its query and fragment. Use `Result.message` for feedback without reloading, or `Result.event` for a targeted JavaScript update.
196
+
197
+ Add `notify:` to any result helper for an optional five-second completion notice. No reload is required:
198
+
199
+ ```ruby
200
+ VoiceControl::Result.message("Preferences saved.", notify: "Saved successfully.")
201
+ VoiceControl::Result.event("account:updated", { plan: "premium" }, notify: "Plan updated.")
202
+ ```
203
+
204
+ Both keep the current page in place. Use `VoiceControl::Result.reload(notify: "Plan updated.")` when you also want a reload; the notice carries across it. Results show a separate notice only when you opt in. [Result options →](docs/commands.md#results)
205
+
206
+ ## Limit commands to relevant pages
207
+
208
+ Use `pages:` to offer a command only on matching paths:
209
+
210
+ ```ruby
211
+ config.command :show_user_help, description: "Show user management help",
212
+ pages: ["/users", %r{\A/users/\d+/edit\z}] do
213
+ execute { |_args, _context| VoiceControl::Result.message("Edit the fields, then say submit.") }
214
+ end
215
+ ```
216
+
217
+ Strings match exactly; regexes let you match routes containing IDs. Commands without `pages:` remain global, which is useful for navigation. Help and Jev receive only commands available on the current page. Page scopes are routing hints supplied by the browser; keep authorization in `authorize:`. [Scope rules →](docs/commands.md#page-scopes)
218
+
219
+ [Arguments, groups, authorization, and result types →](docs/commands.md)
220
+
221
+ ## Work with the page
222
+
223
+ With `browser_actions = true`, voice_control discovers supported visible controls at command time. You do not register each button in Ruby.
224
+
225
+ - **Click and navigate:** “click Performance,” “go back,” “scroll to Billing.”
226
+ - **Edit:** “enter 500 into Token balance,” “select Premium from Plan,” “check notifications.”
227
+ - **Use the selected field:** “enter Hello,” “clear this field,” “submit.”
228
+ - **Undo:** restore the last unsaved VoiceControl field edit.
229
+
230
+ Unique exact click labels resolve directly. Other phrases use Jev; ambiguous matches ask which command you meant. Discovery refreshes after Turbo navigation and DOM changes. Removed or changed controls are rejected before execution.
231
+
232
+ Native dropdowns, checkboxes, radios, date/time inputs, and forms are supported. Iframes, other shadow roots, custom dropdowns, and multi-selects need application-specific commands. [Full browser-action guide →](docs/browser-actions.md)
233
+
234
+ Disable automatic browser actions on a page, or exclude just part of its DOM:
235
+
236
+ ```html
237
+ <!-- In the page's head: Ruby commands remain available. -->
238
+ <meta name="voice-control-browser-actions" content="off">
239
+
240
+ <!-- Or on a control/container: excludes it and its descendants. -->
241
+ <section data-voice-control-ignore>Private controls go here</section>
242
+ ```
243
+
244
+ These exclusions apply to discovery, help, and pending dynamic actions. [Exclusions and limits →](docs/browser-actions.md#exclude-pages-or-controls)
245
+
246
+ **Command limit.** Jev accepts at most 255 choices per request, so at most 254 commands (your vocabulary plus discovered page actions) are matched at once. Beyond that, the commands with the longest descriptions/aliases are skipped for that request and a warning listing them is written to the Rails log (debug mode also shows `skipped_commands`). Skipped commands still work through help and exact click labels. Keep large pages under the limit with `pages:` scopes and `data-voice-control-ignore`.
247
+
248
+ ## Know what is sent and what can run
249
+
250
+ Jev receives the transcript, configured page context (current URL/path by default), and available command descriptions. Dynamic discovery also sends control labels, IDs/names, types, and dropdown labels. Existing field values, Ruby code, and page HTML are not sent; anything the user types or speaks as a command is part of the transcript. Speech recognition may use the browser vendor's service.
251
+
252
+ Use `data-voice-control-ignore` for private UI regions and a context callback to remove sensitive URL parameters. Jev chooses an allowed command; the server validates arguments and checks permissions before issuing and executing a signed, session-bound ticket. Duplicate tickets use an atomic cache claim. A confidence score is a routing signal, not an authorization check or a guarantee of intent.
253
+
254
+ [Privacy, deployment, cache setup, and failure semantics →](docs/deployment.md)
255
+
256
+ ## Configuration
257
+
258
+ All configuration options are below. Merge the settings you need into `config/initializers/voice_control.rb`, keep your command definitions in the same block, and restart Rails. Values match the defaults except the API key, which is read from your environment.
259
+
260
+ ```ruby
261
+ VoiceControl.configure do |config|
262
+ # Server-side Jev key; a string or zero-argument callable. Default: nil.
263
+ config.api_key = ENV["JEV_API_KEY"]
264
+
265
+ # Jev model used to match natural-language commands.
266
+ config.model = "jev-latest"
267
+
268
+ # Engine endpoints inherit this controller's authentication and callbacks.
269
+ config.parent_controller = "ApplicationController"
270
+
271
+ # Widget/endpoint access; replace with your check, e.g. -> { current_user&.admin? }.
272
+ config.authorize = -> { false }
273
+
274
+ # Bind tickets to the signed-in user as well as the session; adapt for your auth system.
275
+ config.identity = -> { respond_to?(:current_user, true) ? current_user&.id : nil }
276
+
277
+ # Filter page context before matching; defaults to pass-through. Use .slice("path") to restrict it.
278
+ config.context = ->(client_context) { client_context }
279
+
280
+ # Discover labeled controls for click/fill/select/check/submit and page navigation commands.
281
+ # Sends labels/IDs and dropdown labels to Jev; excludes existing field values.
282
+ config.browser_actions = false
283
+
284
+ # Open/close shortcut; mod means Cmd on macOS, Ctrl elsewhere. nil disables it.
285
+ config.keyboard_shortcut = "mod+shift+u"
286
+
287
+ # Hold to speak, release to submit; nil disables this shortcut.
288
+ config.push_to_talk_shortcut = "mod+shift+space"
289
+
290
+ # Speech recognition language (BCP 47 tag, e.g. "uk-UA"); widget text and built-in page phrases stay English.
291
+ config.speech_language = "en-US"
292
+
293
+ # Widget corner: :bottom_right or :bottom_left.
294
+ config.widget_position = :bottom_right
295
+
296
+ # Button/icon size: :normal = 56px/24px; :small = 44px/20px. Panel size stays unchanged.
297
+ config.launcher_size = :normal
298
+
299
+ # Close and stop listening after this many milliseconds of inactivity; use a positive value.
300
+ config.idle_timeout = 120_000
301
+
302
+ # Browser request deadline in milliseconds (1_000..300_000); timed-out actions are never retried.
303
+ config.request_timeout = 30_000
304
+
305
+ # Matches below this score ask for disambiguation; calibrate for your vocabulary.
306
+ config.confidence_threshold = 0.35
307
+
308
+ # Show the latest transcript, Jev result and execution details; enable only for trusted users.
309
+ config.debug = false
310
+
311
+ # Default: Rails.cache, with a development-only MemoryStore fallback for NullStore.
312
+ # Override with a shared atomic cache for production replay protection.
313
+ # config.execution_store = -> { Rails.cache }
314
+
315
+ # nil uses Jev; a custom callable accepts transcript:, context:, commands: and returns a match.
316
+ config.interpreter = nil
317
+
318
+ # Unexpected-error callback; defaults to logging the error class and command, not the transcript.
319
+ config.on_error = ->(error, details) { Rails.logger.error("VoiceControl #{error.class} command=#{details[:command]}") }
320
+ end
321
+ ```
322
+
323
+ For a manually mounted web component, use `data-launcher-size="small"` for the compact launcher. See the [configuration reference](docs/configuration.md) for the custom interpreter contract and [deployment guide](docs/deployment.md) for cache and privacy setup.
324
+
325
+ ## Find the details
326
+
327
+ | Guide | Includes |
328
+ | --- | --- |
329
+ | [Commands](docs/commands.md) | DSL, argument types, visibility, authorization, results |
330
+ | [Browser actions](docs/browser-actions.md) | Supported controls, selection, submit, Undo, limits, debug JSON |
331
+ | [Integration](docs/integration.md) | Turbo, React, context, keyboard shortcuts, push-to-talk |
332
+ | [Configuration](docs/configuration.md) | Every option and default |
333
+ | [Deployment](docs/deployment.md) | Shared cache, privacy, CSP, rate limiting, troubleshooting |
334
+ | [Demo](docs/demo.md) | Local interpreter, Jev setup, SQLite data |
335
+ | [Contributing](CONTRIBUTING.md) | Tests, >95% coverage gates, browser checks, packaging |
336
+
337
+ **Debug a command:** set `config.debug = true`, restart Rails, and expand **Command details**. Inspect the matching source, chosen action, confidence, candidates, and Jev's choice/probabilities. Copy details for a reproducible report. Debug is off by default.
338
+
339
+ Voice uses browser Speech Recognition in `config.speech_language` (default `en-US`); widget text and built-in page phrases are English. Typing is the fallback when voice is unavailable. See [browser support and microphone requirements](docs/integration.md#browser-support).
340
+
341
+ ## License
342
+
343
+ [MIT](LICENSE). Copyright © 2026 Igor Kasyanchuk.
data/SECURITY.md ADDED
@@ -0,0 +1,15 @@
1
+ # Security
2
+
3
+ VoiceControl is currently unreleased. Do not use public issues to disclose exploitable vulnerabilities, credentials, transcripts, or private page data. Before public publication, the maintainer must establish and publish a private reporting channel; this checkout does not yet advertise one.
4
+
5
+ ## Integration boundary
6
+
7
+ - Overall authorization defaults to deny. Add record-level authorization and tenant scoping to mutation commands.
8
+ - Jev matches intent; it does not authorize operations. Commands execute immediately after validation, without a confirmation dialog.
9
+ - Tickets are signed, expire, and bind to the session and configured identity. They are readable by the client, not encrypted.
10
+ - Production replay protection requires a shared atomic cache. Eviction, outages, or resets are application operational concerns; business-critical actions also need durable idempotency.
11
+ - Browser manifests and page context are untrusted. Underlying HTTP endpoints must enforce their own permissions.
12
+ - Transcripts/context/control labels may be sent to Jev. Browser speech recognition may use another remote service. Exclude sensitive labels, filter context and Rails logs, and keep provider keys server-side.
13
+ - Closing the widget cannot undo a server operation already in progress.
14
+
15
+ See [deployment guidance](docs/deployment.md) for configuration, logging, CSP, rate limiting, and failure semantics. Report findings with a minimal reproduction and affected versions, without real user data.
@@ -0,0 +1,20 @@
1
+ module VoiceControl
2
+ class AssetsController < ActionController::Base
3
+ skip_forgery_protection
4
+ def javascript
5
+ send_asset("widget.js", "text/javascript")
6
+ end
7
+
8
+ def stylesheet
9
+ send_asset("widget.css", "text/css")
10
+ end
11
+
12
+ private
13
+
14
+ def send_asset(name, type)
15
+ path = Engine.root.join("assets", name)
16
+ expires_in Rails.env.development? ? 0.seconds : 1.hour, public: true
17
+ send_data File.binread(path), type: type, disposition: "inline" if stale?(etag: Digest::SHA256.file(path).hexdigest, public: true)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,77 @@
1
+ module VoiceControl
2
+ class CommandsController < VoiceControl.configuration.parent_controller.constantize
3
+ wrap_parameters false
4
+ protect_from_forgery with: :exception
5
+ before_action :voice_control_authorize
6
+ before_action :voice_control_no_store
7
+ before_action :voice_control_start_timer
8
+ rescue_from StandardError, with: :voice_control_error
9
+ rescue_from VoiceControl::Forbidden, with: :voice_control_forbidden
10
+ rescue_from VoiceControl::InvalidInput, with: :voice_control_invalid
11
+
12
+ def index
13
+ render json: { commands: conversation.catalog(page_path: params[:path]).map(&:as_json) }
14
+ end
15
+
16
+ def create
17
+ context = params[:context]
18
+ context = context.to_unsafe_h if context.is_a?(ActionController::Parameters)
19
+ browser_page = params[:browser_page]
20
+ browser_page = browser_page.to_unsafe_h if browser_page.is_a?(ActionController::Parameters)
21
+ render_result conversation.interpret(transcript: params[:transcript] || "", client_context: context || {},
22
+ command_key: params[:command], continuation: params[:continuation], browser_page: browser_page)
23
+ end
24
+
25
+ def execute
26
+ render_result conversation.execute(params[:ticket])
27
+ end
28
+
29
+ private
30
+
31
+ def voice_control_start_timer
32
+ @voice_control_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
+ end
34
+
35
+ def render_result(result, status: :ok)
36
+ if VoiceControl.configuration.debug == true && @voice_control_started_at
37
+ result = result.merge(debug: (@conversation&.diagnostics || {}).merge(
38
+ stage: action_name == "execute" ? "execute" : "interpret", outcome: result[:kind],
39
+ message: result[:message], duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @voice_control_started_at) * 1000).round))
40
+ end
41
+ render json: result, status: status
42
+ end
43
+
44
+ def conversation
45
+ @conversation ||= Conversation.new(self)
46
+ end
47
+
48
+ def voice_control_authorize
49
+ raise Forbidden unless instance_exec(&VoiceControl.configuration.authorize)
50
+ end
51
+
52
+ def voice_control_no_store
53
+ response.headers["Cache-Control"] = "no-store"
54
+ end
55
+
56
+ def voice_control_forbidden
57
+ render_result({ kind: "error", message: "You cannot run this command." }, status: :forbidden)
58
+ end
59
+
60
+ def voice_control_invalid(error)
61
+ render_result({ kind: "error", message: error.message }, status: :unprocessable_content)
62
+ end
63
+
64
+ def voice_control_error(error)
65
+ if error.is_a?(ActionController::InvalidAuthenticityToken)
66
+ render_result({ kind: "error", message: "Your session changed. Reload the page." }, status: :unprocessable_content)
67
+ else
68
+ begin
69
+ VoiceControl.configuration.on_error.call(error, { command: conversation.command_key || params[:command], controller: self })
70
+ rescue StandardError => reporting_error
71
+ Rails.logger.error("VoiceControl error callback failed: #{reporting_error.class}")
72
+ end
73
+ render_result({ kind: "error", message: "Something went wrong. Check the result before trying again." }, status: :internal_server_error)
74
+ end
75
+ end
76
+ end
77
+ end