dexterm-cli 0.0.0-stage → 0.1.2
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.
- package/LICENSE +661 -0
- package/dist/packages/agents/src/audit.js +122 -0
- package/dist/packages/agents/src/bus.js +394 -0
- package/dist/packages/agents/src/executor.js +202 -0
- package/dist/packages/agents/src/gate.js +179 -0
- package/dist/packages/agents/src/identity.js +218 -0
- package/dist/packages/agents/src/index.js +15 -0
- package/dist/packages/agents/src/proposals.js +153 -0
- package/dist/packages/agents/src/schema.js +87 -0
- package/dist/packages/agents/src/sdk.js +140 -0
- package/dist/packages/agents/src/tiers.js +119 -0
- package/dist/packages/agents/src/tools.js +72 -0
- package/dist/packages/agents/src/ws.js +158 -0
- package/dist/packages/cli/assets/claude/analyst-system.md +76 -0
- package/dist/packages/cli/assets/claude/plugin/.claude-plugin/plugin.json +5 -0
- package/dist/packages/cli/assets/claude/plugin/skills/REGISTRY.json +16 -0
- package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +75 -0
- package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +74 -0
- package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +63 -0
- package/dist/packages/cli/assets/claude/plugin/skills/trench-scan/SKILL.md +44 -0
- package/dist/packages/cli/assets/claude/schemas/larp.schema.json +117 -0
- package/dist/packages/cli/assets/claude/schemas/trench-scan.schema.json +71 -0
- package/dist/packages/cli/assets/claude/schemas/verdict.schema.json +115 -0
- package/dist/packages/cli/bin/dexterm.mjs +11 -0
- package/dist/packages/cli/src/agent-cmd.js +336 -0
- package/dist/packages/cli/src/agent-roster.js +26 -0
- package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
- package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
- package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
- package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
- package/dist/packages/cli/src/alerts/creds.js +83 -0
- package/dist/packages/cli/src/alerts/format.js +41 -0
- package/dist/packages/cli/src/alerts/index.js +10 -0
- package/dist/packages/cli/src/alerts/notifier.js +53 -0
- package/dist/packages/cli/src/alerts/route.js +19 -0
- package/dist/packages/cli/src/alerts/types.js +1 -0
- package/dist/packages/cli/src/alerts-cmd.js +212 -0
- package/dist/packages/cli/src/analyst/backend.js +526 -0
- package/dist/packages/cli/src/analyst/results.js +38 -0
- package/dist/packages/cli/src/analyst/session.js +319 -0
- package/dist/packages/cli/src/audit.js +51 -0
- package/dist/packages/cli/src/backtest-cmd.js +208 -0
- package/dist/packages/cli/src/boot-state.js +62 -0
- package/dist/packages/cli/src/coin-target.js +2 -0
- package/dist/packages/cli/src/custom.js +261 -0
- package/dist/packages/cli/src/display.js +191 -0
- package/dist/packages/cli/src/doctor.js +375 -0
- package/dist/packages/cli/src/export.js +235 -0
- package/dist/packages/cli/src/feed.js +634 -0
- package/dist/packages/cli/src/fixtures.js +275 -0
- package/dist/packages/cli/src/follow-cmd.js +221 -0
- package/dist/packages/cli/src/images-config.js +59 -0
- package/dist/packages/cli/src/index.js +31 -0
- package/dist/packages/cli/src/kb-skill-cmd.js +240 -0
- package/dist/packages/cli/src/larp-record.js +21 -0
- package/dist/packages/cli/src/live/copy.js +80 -0
- package/dist/packages/cli/src/live/flow.js +233 -0
- package/dist/packages/cli/src/live/index.js +511 -0
- package/dist/packages/cli/src/live/intel.js +227 -0
- package/dist/packages/cli/src/live/newpairs.js +334 -0
- package/dist/packages/cli/src/live/screens/newpairs-live.js +219 -0
- package/dist/packages/cli/src/live/screens/token-live.js +459 -0
- package/dist/packages/cli/src/live/screens/watchlist-live.js +149 -0
- package/dist/packages/cli/src/live/token.js +585 -0
- package/dist/packages/cli/src/live/trade.js +929 -0
- package/dist/packages/cli/src/live/watchlist.js +178 -0
- package/dist/packages/cli/src/main.js +5785 -0
- package/dist/packages/cli/src/mcp-cmd.js +326 -0
- package/dist/packages/cli/src/narrative-cmd.js +76 -0
- package/dist/packages/cli/src/pack-cmd.js +323 -0
- package/dist/packages/cli/src/plan-cmd.js +278 -0
- package/dist/packages/cli/src/popup.js +810 -0
- package/dist/packages/cli/src/rules-cmd.js +131 -0
- package/dist/packages/cli/src/rules.js +358 -0
- package/dist/packages/cli/src/scan/command.js +57 -0
- package/dist/packages/cli/src/scan/engine.js +419 -0
- package/dist/packages/cli/src/scan/observe.js +64 -0
- package/dist/packages/cli/src/scan/ui.js +98 -0
- package/dist/packages/cli/src/screener/filter.js +241 -0
- package/dist/packages/cli/src/screener/presets.js +80 -0
- package/dist/packages/cli/src/screener/rows.js +146 -0
- package/dist/packages/cli/src/screener/rug.js +50 -0
- package/dist/packages/cli/src/screener/sort.js +52 -0
- package/dist/packages/cli/src/screener/view.js +183 -0
- package/dist/packages/cli/src/screens/analyst-session.js +343 -0
- package/dist/packages/cli/src/screens/analyst.js +326 -0
- package/dist/packages/cli/src/screens/boot.js +74 -0
- package/dist/packages/cli/src/screens/bubbles.js +255 -0
- package/dist/packages/cli/src/screens/coin.js +33 -0
- package/dist/packages/cli/src/screens/follows.js +201 -0
- package/dist/packages/cli/src/screens/kol.js +90 -0
- package/dist/packages/cli/src/screens/narrative.js +90 -0
- package/dist/packages/cli/src/screens/newpairs.js +150 -0
- package/dist/packages/cli/src/screens/overlay.js +257 -0
- package/dist/packages/cli/src/screens/plans.js +126 -0
- package/dist/packages/cli/src/screens/positions.js +176 -0
- package/dist/packages/cli/src/screens/rules.js +170 -0
- package/dist/packages/cli/src/screens/screener.js +459 -0
- package/dist/packages/cli/src/screens/social-panel.js +107 -0
- package/dist/packages/cli/src/screens/ticket.js +342 -0
- package/dist/packages/cli/src/screens/token.js +312 -0
- package/dist/packages/cli/src/screens/trending.js +181 -0
- package/dist/packages/cli/src/screens/watchlist.js +104 -0
- package/dist/packages/cli/src/skill-eval-build.js +109 -0
- package/dist/packages/cli/src/social/demo.js +96 -0
- package/dist/packages/cli/src/social/view.js +250 -0
- package/dist/packages/cli/src/state.js +463 -0
- package/dist/packages/cli/src/theme-cmd.js +126 -0
- package/dist/packages/cli/src/trade.js +319 -0
- package/dist/packages/cli/src/update.js +294 -0
- package/dist/packages/cli/src/version.js +3 -0
- package/dist/packages/cli/src/voice-cmd.js +119 -0
- package/dist/packages/cli/src/voice-hud.js +42 -0
- package/dist/packages/cli/src/wallet-cmd.js +440 -0
- package/dist/packages/cli/src/wallet-command.js +31 -0
- package/dist/packages/cli/src/watch-cmd.js +190 -0
- package/dist/packages/cli/src/widgets.js +208 -0
- package/dist/packages/cli/src/workspace-cmd.js +68 -0
- package/dist/packages/cli/src/x-cmd.js +154 -0
- package/dist/packages/custom/src/config.js +213 -0
- package/dist/packages/custom/src/index.js +10 -0
- package/dist/packages/custom/src/keymap.js +107 -0
- package/dist/packages/custom/src/layout.js +235 -0
- package/dist/packages/custom/src/packs.js +633 -0
- package/dist/packages/custom/src/rules.js +704 -0
- package/dist/packages/custom/src/schema.js +538 -0
- package/dist/packages/custom/src/themes.js +176 -0
- package/dist/packages/custom/src/widget-html.js +222 -0
- package/dist/packages/custom/src/workspace.js +141 -0
- package/dist/packages/data/src/analyst-actions.js +133 -0
- package/dist/packages/data/src/budget.js +88 -0
- package/dist/packages/data/src/cache.js +142 -0
- package/dist/packages/data/src/candles.js +168 -0
- package/dist/packages/data/src/clock.js +64 -0
- package/dist/packages/data/src/coin-target.js +82 -0
- package/dist/packages/data/src/credits.js +137 -0
- package/dist/packages/data/src/detail.js +97 -0
- package/dist/packages/data/src/fast-lane.js +321 -0
- package/dist/packages/data/src/fixtures.js +135 -0
- package/dist/packages/data/src/health.js +245 -0
- package/dist/packages/data/src/http.js +277 -0
- package/dist/packages/data/src/images/cache.js +141 -0
- package/dist/packages/data/src/images/decode-worker.js +28 -0
- package/dist/packages/data/src/images/decode.js +81 -0
- package/dist/packages/data/src/images/fetch.js +162 -0
- package/dist/packages/data/src/images/gif.js +214 -0
- package/dist/packages/data/src/images/index.js +10 -0
- package/dist/packages/data/src/images/jpeg.js +544 -0
- package/dist/packages/data/src/images/png.js +310 -0
- package/dist/packages/data/src/images/service.js +334 -0
- package/dist/packages/data/src/images/types.js +27 -0
- package/dist/packages/data/src/index.js +211 -0
- package/dist/packages/data/src/intel/helius-history.js +70 -0
- package/dist/packages/data/src/intel/holder-class.js +125 -0
- package/dist/packages/data/src/intel/rules.js +421 -0
- package/dist/packages/data/src/intel/scan.js +170 -0
- package/dist/packages/data/src/larp/evidence.js +288 -0
- package/dist/packages/data/src/larp/link-baseline.js +196 -0
- package/dist/packages/data/src/larp/links.js +137 -0
- package/dist/packages/data/src/larp/robots.js +90 -0
- package/dist/packages/data/src/larp/safe-fetch.js +389 -0
- package/dist/packages/data/src/limiter.js +341 -0
- package/dist/packages/data/src/model.js +12 -0
- package/dist/packages/data/src/paper.js +331 -0
- package/dist/packages/data/src/rpc-config.js +126 -0
- package/dist/packages/data/src/rpc-pool.js +166 -0
- package/dist/packages/data/src/social/chain.js +83 -0
- package/dist/packages/data/src/social/dedupe.js +205 -0
- package/dist/packages/data/src/social/feed.js +462 -0
- package/dist/packages/data/src/social/firewall.js +110 -0
- package/dist/packages/data/src/social/index.js +11 -0
- package/dist/packages/data/src/social/kol.js +269 -0
- package/dist/packages/data/src/social/model.js +11 -0
- package/dist/packages/data/src/social/rank.js +184 -0
- package/dist/packages/data/src/social/rss.js +113 -0
- package/dist/packages/data/src/social/summary.js +104 -0
- package/dist/packages/data/src/social/venues.js +186 -0
- package/dist/packages/data/src/sources/dexscreener.js +184 -0
- package/dist/packages/data/src/sources/geckoterminal.js +133 -0
- package/dist/packages/data/src/sources/goplus.js +161 -0
- package/dist/packages/data/src/sources/jupiter-swap.js +236 -0
- package/dist/packages/data/src/sources/jupiter.js +198 -0
- package/dist/packages/data/src/sources/pda.js +113 -0
- package/dist/packages/data/src/sources/pump-curve.js +58 -0
- package/dist/packages/data/src/sources/pumpportal.js +252 -0
- package/dist/packages/data/src/sources/rugcheck.js +71 -0
- package/dist/packages/data/src/sources/solana-rpc.js +299 -0
- package/dist/packages/data/src/sources/solana-ws.js +287 -0
- package/dist/packages/data/src/store.js +289 -0
- package/dist/packages/data/src/trade/bridge.js +464 -0
- package/dist/packages/data/src/trade/caps.js +323 -0
- package/dist/packages/data/src/trade/copy/auto.js +385 -0
- package/dist/packages/data/src/trade/copy/follows.js +103 -0
- package/dist/packages/data/src/trade/copy/index.js +9 -0
- package/dist/packages/data/src/trade/copy/mirror.js +91 -0
- package/dist/packages/data/src/trade/copy/stats.js +120 -0
- package/dist/packages/data/src/trade/copy/tracker.js +158 -0
- package/dist/packages/data/src/trade/copy/trades.js +87 -0
- package/dist/packages/data/src/trade/copy/types.js +6 -0
- package/dist/packages/data/src/trade/executor.js +933 -0
- package/dist/packages/data/src/trade/kill-switch.js +47 -0
- package/dist/packages/data/src/trade/link-store.js +52 -0
- package/dist/packages/data/src/trade/plans/confirm.js +157 -0
- package/dist/packages/data/src/trade/plans/engine.js +784 -0
- package/dist/packages/data/src/trade/plans/executor-live.js +220 -0
- package/dist/packages/data/src/trade/plans/executor-paper.js +44 -0
- package/dist/packages/data/src/trade/plans/executor.js +11 -0
- package/dist/packages/data/src/trade/plans/index.js +12 -0
- package/dist/packages/data/src/trade/plans/journal.js +77 -0
- package/dist/packages/data/src/trade/plans/mandate.js +129 -0
- package/dist/packages/data/src/trade/plans/plan.js +179 -0
- package/dist/packages/data/src/trade/plans/service.js +168 -0
- package/dist/packages/data/src/trade/plans/types.js +6 -0
- package/dist/packages/data/src/trade/rpc-exec.js +229 -0
- package/dist/packages/data/src/trade/signer/client.js +197 -0
- package/dist/packages/data/src/trade/signer/fast-wallet.js +154 -0
- package/dist/packages/data/src/trade/signer/host.js +97 -0
- package/dist/packages/data/src/trade/signer/main.js +121 -0
- package/dist/packages/data/src/trade/signer/probe-builder.js +85 -0
- package/dist/packages/data/src/trade/signer/protocol.js +92 -0
- package/dist/packages/data/src/trade/signer/service.js +400 -0
- package/dist/packages/data/src/trade/signer/sol-usd-feed.js +98 -0
- package/dist/packages/data/src/trade/sol-usd.js +86 -0
- package/dist/packages/data/src/trade/swap-build.js +108 -0
- package/dist/packages/data/src/trade/token-delta.js +201 -0
- package/dist/packages/data/src/trade/tx-decode.js +632 -0
- package/dist/packages/data/src/util.js +24 -0
- package/dist/packages/data/src/watchlist.js +176 -0
- package/dist/packages/data/src/x/api.js +256 -0
- package/dist/packages/data/src/x/budget.js +66 -0
- package/dist/packages/data/src/x/config.js +49 -0
- package/dist/packages/data/src/x/index.js +10 -0
- package/dist/packages/data/src/x/mock-server.js +217 -0
- package/dist/packages/data/src/x/oauth.js +242 -0
- package/dist/packages/data/src/x/pkce.js +21 -0
- package/dist/packages/data/src/x/source.js +285 -0
- package/dist/packages/data/src/x/token-store.js +134 -0
- package/dist/packages/data/src/x/transport.js +71 -0
- package/dist/packages/data/vendor/solana-web3/LICENSE +20 -0
- package/dist/packages/data/vendor/solana-web3/NOTICE.md +13 -0
- package/dist/packages/data/vendor/solana-web3/index.iife.min.js +20 -0
- package/dist/packages/mcp/assets/cases.json +1194 -0
- package/dist/packages/mcp/assets/kb/entries/cabal-and-coordinated-launches.md +28 -0
- package/dist/packages/mcp/assets/kb/entries/common-rug-patterns.md +40 -0
- package/dist/packages/mcp/assets/kb/entries/dbc-mechanics.md +30 -0
- package/dist/packages/mcp/assets/kb/entries/dev-wallet-behaviour.md +33 -0
- package/dist/packages/mcp/assets/kb/entries/glossary.md +35 -0
- package/dist/packages/mcp/assets/kb/entries/holder-concentration.md +30 -0
- package/dist/packages/mcp/assets/kb/entries/jito-bundles.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/launchlab-curve.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/lp-burn-vs-lock.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/metadata-and-copycats.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/mint-freeze-authority.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/narrative-lifecycle.md +32 -0
- package/dist/packages/mcp/assets/kb/entries/pump-bonding-curve.md +33 -0
- package/dist/packages/mcp/assets/kb/entries/pumpswap-migration.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/sniping-and-snipers.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/token2022-extensions-risk.md +32 -0
- package/dist/packages/mcp/assets/kb/entries/wash-trading-fake-volume.md +33 -0
- package/dist/packages/mcp/assets/skills/rug-check.md +52 -0
- package/dist/packages/mcp/assets/skills/token-dossier.md +57 -0
- package/dist/packages/mcp/assets/skills/trench-scan.md +40 -0
- package/dist/packages/mcp/bin/dexterm-mcp.mjs +16 -0
- package/dist/packages/mcp/src/dossier.js +78 -0
- package/dist/packages/mcp/src/firewall.js +21 -0
- package/dist/packages/mcp/src/index.js +22 -0
- package/dist/packages/mcp/src/server.js +936 -0
- package/dist/packages/mcp/src/user-content.js +434 -0
- package/dist/packages/packs/src/backtest.js +183 -0
- package/dist/packages/packs/src/digest.js +95 -0
- package/dist/packages/packs/src/firewall.js +57 -0
- package/dist/packages/packs/src/index.js +20 -0
- package/dist/packages/packs/src/larp-eval.js +100 -0
- package/dist/packages/packs/src/narrative.js +309 -0
- package/dist/packages/packs/src/reason.js +135 -0
- package/dist/packages/packs/src/rug-model.js +137 -0
- package/dist/packages/packs/src/schema.js +54 -0
- package/dist/packages/packs/src/skill-backtest.js +300 -0
- package/dist/packages/packs/src/token-dossier.js +174 -0
- package/dist/packages/packs/src/verdicts.js +17 -0
- package/dist/packages/packs/src/wallet-dossier.js +105 -0
- package/dist/packages/popup/src/config.js +71 -0
- package/dist/packages/popup/src/dock.js +271 -0
- package/dist/packages/popup/src/host.js +210 -0
- package/dist/packages/popup/src/index.js +12 -0
- package/dist/packages/popup/src/loopback.js +53 -0
- package/dist/packages/popup/src/open.js +183 -0
- package/dist/packages/popup/src/pages/bubbles-lib.js +160 -0
- package/dist/packages/popup/src/pages/bubbles.css +23 -0
- package/dist/packages/popup/src/pages/bubbles.html +36 -0
- package/dist/packages/popup/src/pages/bubbles.js +214 -0
- package/dist/packages/popup/src/pages/chart-lib.js +262 -0
- package/dist/packages/popup/src/pages/chart.css +50 -0
- package/dist/packages/popup/src/pages/chart.html +56 -0
- package/dist/packages/popup/src/pages/chart.js +554 -0
- package/dist/packages/popup/src/pages/common.css +34 -0
- package/dist/packages/popup/src/pages/common.js +187 -0
- package/dist/packages/popup/src/pages/feeds.html +20 -0
- package/dist/packages/popup/src/pages/feeds.js +77 -0
- package/dist/packages/popup/src/pages/narrative.html +20 -0
- package/dist/packages/popup/src/pages/narrative.js +61 -0
- package/dist/packages/popup/src/pages/plans.css +30 -0
- package/dist/packages/popup/src/pages/plans.html +26 -0
- package/dist/packages/popup/src/pages/plans.js +82 -0
- package/dist/packages/popup/src/pages/roster.css +41 -0
- package/dist/packages/popup/src/pages/roster.html +25 -0
- package/dist/packages/popup/src/pages/roster.js +75 -0
- package/dist/packages/popup/src/pages/token.html +20 -0
- package/dist/packages/popup/src/pages/token.js +112 -0
- package/dist/packages/popup/src/pages/widget-sdk.js +224 -0
- package/dist/packages/popup/src/pages/widget.css +5 -0
- package/dist/packages/popup/src/pages/widget.html +21 -0
- package/dist/packages/popup/src/pages/widget.js +85 -0
- package/dist/packages/popup/src/protocol.js +31 -0
- package/dist/packages/popup/src/selftest.js +156 -0
- package/dist/packages/popup/src/server.js +582 -0
- package/dist/packages/popup/src/sinkproxy.js +167 -0
- package/dist/packages/popup/src/widget-html.js +231 -0
- package/dist/packages/popup/vendor/lightweight-charts/LICENSE +201 -0
- package/dist/packages/popup/vendor/lightweight-charts/NOTICE +2 -0
- package/dist/packages/popup/vendor/lightweight-charts/README.md +30 -0
- package/dist/packages/popup/vendor/lightweight-charts/VERSION +1 -0
- package/dist/packages/popup/vendor/lightweight-charts/lightweight-charts.standalone.production.js +7 -0
- package/dist/packages/recorder/bin/dexterm-recorder.mjs +11 -0
- package/dist/packages/recorder/src/cli.js +113 -0
- package/dist/packages/recorder/src/export.js +71 -0
- package/dist/packages/recorder/src/index.js +20 -0
- package/dist/packages/recorder/src/labels.js +151 -0
- package/dist/packages/recorder/src/open.js +29 -0
- package/dist/packages/recorder/src/recorder.js +553 -0
- package/dist/packages/recorder/src/schedule.js +43 -0
- package/dist/packages/recorder/src/schema.js +135 -0
- package/dist/packages/recorder/src/snapshot.js +191 -0
- package/dist/packages/recorder/src/sweep.js +52 -0
- package/dist/packages/recorder/src/verdicts.js +52 -0
- package/dist/packages/tui/src/bubbles.js +415 -0
- package/dist/packages/tui/src/canvas.js +370 -0
- package/dist/packages/tui/src/chrome.js +85 -0
- package/dist/packages/tui/src/color.js +133 -0
- package/dist/packages/tui/src/flush.js +62 -0
- package/dist/packages/tui/src/fmt.js +188 -0
- package/dist/packages/tui/src/fx-trade.js +278 -0
- package/dist/packages/tui/src/fx.js +527 -0
- package/dist/packages/tui/src/images/blocks.js +141 -0
- package/dist/packages/tui/src/images/coin.js +22 -0
- package/dist/packages/tui/src/images/detect.js +140 -0
- package/dist/packages/tui/src/images/index.js +7 -0
- package/dist/packages/tui/src/images/layer.js +146 -0
- package/dist/packages/tui/src/images/pixels.js +157 -0
- package/dist/packages/tui/src/images/protocols.js +87 -0
- package/dist/packages/tui/src/index.js +18 -0
- package/dist/packages/tui/src/keymap.js +357 -0
- package/dist/packages/tui/src/layout.js +11 -0
- package/dist/packages/tui/src/raster-font.js +195 -0
- package/dist/packages/tui/src/raster.js +213 -0
- package/dist/packages/tui/src/sound.js +177 -0
- package/dist/packages/tui/src/theme.js +182 -0
- package/dist/packages/tui/src/widgets.js +598 -0
- package/dist/packages/voice/src/controller.js +407 -0
- package/dist/packages/voice/src/doctor.js +131 -0
- package/dist/packages/voice/src/download.js +184 -0
- package/dist/packages/voice/src/engines.js +84 -0
- package/dist/packages/voice/src/index.js +20 -0
- package/dist/packages/voice/src/router.js +502 -0
- package/dist/packages/voice/src/stt/apple-helper.swift.js +103 -0
- package/dist/packages/voice/src/stt/apple.js +179 -0
- package/dist/packages/voice/src/stt/fake.js +34 -0
- package/dist/packages/voice/src/stt/whisper.js +112 -0
- package/dist/packages/voice/src/summary.js +44 -0
- package/dist/packages/voice/src/tts/espeak.js +131 -0
- package/dist/packages/voice/src/tts/fake.js +30 -0
- package/dist/packages/voice/src/tts/piper.js +206 -0
- package/dist/packages/voice/src/tts/say.js +132 -0
- package/dist/packages/voice/src/tts/voices.js +206 -0
- package/dist/packages/voice/src/types.js +3 -0
- package/package.json +38 -4
- package/README.md +0 -3
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Apple Speech STT engine (task-1282). On-device SFSpeechRecognizer via a tiny Swift helper compiled
|
|
2
|
+
// on the fly with `swiftc` — no key, no network, streaming partials (the spike's recommended primary
|
|
3
|
+
// engine). The compiled helper is cached under ~/.dexterm/voice-helper; it is rebuilt only when the
|
|
4
|
+
// source hash changes.
|
|
5
|
+
//
|
|
6
|
+
// LIVE CAVEAT (disclosed in the handoff): this path needs macOS Speech + Microphone authorization,
|
|
7
|
+
// which can only be granted at the Mac by a person the first time it runs. available() reports whether
|
|
8
|
+
// swiftc is present and the helper compiles; it cannot assert that TCC authorization will be granted.
|
|
9
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { APPLE_HELPER_SWIFT } from "./apple-helper.swift.js";
|
|
15
|
+
const CACHE_DIR = process.env.DEXTERM_VOICE_HELPER_DIR || join(homedir(), '.dexterm', 'voice-helper');
|
|
16
|
+
const SRC = join(CACHE_DIR, 'recognize.swift');
|
|
17
|
+
const BIN = join(CACHE_DIR, 'recognize');
|
|
18
|
+
const SWIFTC = process.env.DEXTERM_SWIFTC || 'swiftc';
|
|
19
|
+
function which(bin) {
|
|
20
|
+
if (bin.includes('/'))
|
|
21
|
+
return existsSync(bin);
|
|
22
|
+
for (const p of (process.env.PATH ?? '').split(':'))
|
|
23
|
+
if (p && existsSync(join(p, bin)))
|
|
24
|
+
return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
/** Compile the helper if the cached binary is missing or older than the current source. Returns BIN or null. */
|
|
28
|
+
export function ensureHelper() {
|
|
29
|
+
if (!which(SWIFTC))
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
33
|
+
const hash = createHash('sha256').update(APPLE_HELPER_SWIFT).digest('hex');
|
|
34
|
+
const stamp = join(CACHE_DIR, '.srchash');
|
|
35
|
+
const fresh = existsSync(BIN) && existsSync(stamp) && readFileSync(stamp, 'utf8') === hash && statSync(BIN).size > 0;
|
|
36
|
+
if (fresh)
|
|
37
|
+
return BIN;
|
|
38
|
+
writeFileSync(SRC, APPLE_HELPER_SWIFT);
|
|
39
|
+
const r = compileSync(SRC, BIN);
|
|
40
|
+
if (!r)
|
|
41
|
+
return null;
|
|
42
|
+
writeFileSync(stamp, hash);
|
|
43
|
+
return BIN;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function compileSync(src, out) {
|
|
50
|
+
// The compile is a one-time cost (cached by source hash); doing it synchronously keeps ensureHelper
|
|
51
|
+
// simple. It is only reached from available() / listen(), both already async from the caller's view.
|
|
52
|
+
try {
|
|
53
|
+
execFileSync(SWIFTC, ['-O', '-o', out, src], { timeout: 60_000, stdio: 'ignore' });
|
|
54
|
+
return existsSync(out);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export class AppleStt {
|
|
61
|
+
name = 'apple';
|
|
62
|
+
label = 'Apple Speech (on-device, no key)';
|
|
63
|
+
async available() {
|
|
64
|
+
if (process.platform !== 'darwin')
|
|
65
|
+
return false;
|
|
66
|
+
if (!which(SWIFTC))
|
|
67
|
+
return false;
|
|
68
|
+
return ensureHelper() !== null;
|
|
69
|
+
}
|
|
70
|
+
async listen() {
|
|
71
|
+
const bin = ensureHelper();
|
|
72
|
+
if (!bin)
|
|
73
|
+
throw new Error('Apple Speech helper unavailable (need swiftc)');
|
|
74
|
+
const t0 = Date.now();
|
|
75
|
+
const child = spawn(bin, [], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
76
|
+
let partial = '';
|
|
77
|
+
let firstPartialMs = null;
|
|
78
|
+
let finalText = null;
|
|
79
|
+
let finalConfidence = 1;
|
|
80
|
+
let err = '';
|
|
81
|
+
let buf = '';
|
|
82
|
+
let readySeen = false;
|
|
83
|
+
let readyResolve = null;
|
|
84
|
+
const ready = new Promise((r) => { readyResolve = r; });
|
|
85
|
+
child.stdout?.setEncoding('utf8');
|
|
86
|
+
child.stdout?.on('data', (d) => {
|
|
87
|
+
buf += d;
|
|
88
|
+
let i;
|
|
89
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
90
|
+
const line = buf.slice(0, i);
|
|
91
|
+
buf = buf.slice(i + 1);
|
|
92
|
+
if (!line.trim())
|
|
93
|
+
continue;
|
|
94
|
+
let ev;
|
|
95
|
+
try {
|
|
96
|
+
ev = JSON.parse(line);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (ev.type === 'partial' && typeof ev.text === 'string') {
|
|
102
|
+
partial = ev.text;
|
|
103
|
+
if (firstPartialMs === null && ev.text)
|
|
104
|
+
firstPartialMs = Date.now() - t0;
|
|
105
|
+
}
|
|
106
|
+
else if (ev.type === 'final') {
|
|
107
|
+
finalText = String(ev.text ?? partial);
|
|
108
|
+
if (typeof ev.confidence === 'number')
|
|
109
|
+
finalConfidence = ev.confidence;
|
|
110
|
+
}
|
|
111
|
+
else if (ev.type === 'ready') {
|
|
112
|
+
readySeen = true;
|
|
113
|
+
readyResolve?.();
|
|
114
|
+
}
|
|
115
|
+
else if (ev.type === 'error') {
|
|
116
|
+
err = String(ev.message ?? 'speech error');
|
|
117
|
+
readyResolve?.();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
child.stderr?.on('data', (d) => { err += d.toString(); });
|
|
122
|
+
let closed = false;
|
|
123
|
+
let spawnError = null;
|
|
124
|
+
const settled = new Promise((resolve) => {
|
|
125
|
+
child.on('close', () => { closed = true; readyResolve?.(); resolve(); });
|
|
126
|
+
// A failed spawn (EACCES / ENOENT / EAGAIN) must never become an uncaughtException that takes the
|
|
127
|
+
// whole terminal down (task-1339, re-verify N5): record it so stop() rejects cleanly instead.
|
|
128
|
+
child.on('error', (e) => { spawnError = e; closed = true; if (!err)
|
|
129
|
+
err = `mic helper failed to start: ${e.message}`; readyResolve?.(); resolve(); });
|
|
130
|
+
});
|
|
131
|
+
// Spawn failures surface on the next tick: give them one turn so listen() rejects (the controller
|
|
132
|
+
// then says "mic/STT could not start") rather than reporting a mic that was never open.
|
|
133
|
+
await new Promise((r) => setImmediate(r));
|
|
134
|
+
if (spawnError)
|
|
135
|
+
throw new Error(err || `mic helper failed to start: ${spawnError.message}`);
|
|
136
|
+
// Force-terminate the helper so the mic process can never outlive the session (task-1282 rework D5).
|
|
137
|
+
const kill = () => {
|
|
138
|
+
if (closed)
|
|
139
|
+
return;
|
|
140
|
+
try {
|
|
141
|
+
child.kill('SIGINT');
|
|
142
|
+
}
|
|
143
|
+
catch { /* ignore */ }
|
|
144
|
+
// If SIGINT did not land promptly, escalate so the OS mic indicator can never be left on.
|
|
145
|
+
setTimeout(() => { if (!closed) {
|
|
146
|
+
try {
|
|
147
|
+
child.kill('SIGKILL');
|
|
148
|
+
}
|
|
149
|
+
catch { /* ignore */ }
|
|
150
|
+
} }, 500).unref?.();
|
|
151
|
+
};
|
|
152
|
+
// The helper authorizes, loads the on-device model and opens the audio engine before it emits
|
|
153
|
+
// `ready`. Reporting "listening" earlier let the user talk (and press stop) into a mic that was not
|
|
154
|
+
// open yet: an empty transcript and a silent no-op. Wait for it (bounded; first run after a
|
|
155
|
+
// permission prompt can take a few seconds).
|
|
156
|
+
await Promise.race([ready, new Promise((r) => setTimeout(r, 10_000))]);
|
|
157
|
+
if (closed || !readySeen) {
|
|
158
|
+
kill();
|
|
159
|
+
throw new Error(err || (closed ? 'mic helper exited before it was ready' : 'mic helper did not become ready in 10 s (check System Settings > Privacy > Microphone / Speech Recognition for your terminal)'));
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
stop: async () => {
|
|
163
|
+
try {
|
|
164
|
+
child.stdin?.write('stop\n');
|
|
165
|
+
child.stdin?.end();
|
|
166
|
+
}
|
|
167
|
+
catch { /* ignore */ }
|
|
168
|
+
await Promise.race([settled, new Promise((r) => setTimeout(r, 5000))]);
|
|
169
|
+
if (!closed)
|
|
170
|
+
kill(); // the helper ignored `stop` within 3 s: kill it rather than orphan the mic
|
|
171
|
+
const text = (finalText ?? partial).trim();
|
|
172
|
+
if (!text && err)
|
|
173
|
+
throw new Error(err);
|
|
174
|
+
return { text, confidence: text ? finalConfidence : 0, firstPartialMs, speakerCount: 1 };
|
|
175
|
+
},
|
|
176
|
+
cancel: () => { kill(); },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// An STT engine that captures no audio — listen()/stop() replay a scripted transcript. It powers the
|
|
2
|
+
// headless tests and the latency harness, and the `--text`/`--script` demo, so the whole pipeline is
|
|
3
|
+
// exercisable with no mic and no model.
|
|
4
|
+
export class FakeStt {
|
|
5
|
+
name = 'fake';
|
|
6
|
+
label = 'fake (scripted, for tests)';
|
|
7
|
+
queue;
|
|
8
|
+
deflt;
|
|
9
|
+
/** `scripts` are consumed one per listen(); once exhausted, `fallback` is reused. */
|
|
10
|
+
constructor(scripts = [], fallback = { text: '' }) {
|
|
11
|
+
this.queue = scripts.slice();
|
|
12
|
+
this.deflt = fallback;
|
|
13
|
+
}
|
|
14
|
+
async available() { return true; }
|
|
15
|
+
async listen() {
|
|
16
|
+
const s = this.queue.shift() ?? this.deflt;
|
|
17
|
+
let settled = null;
|
|
18
|
+
const resolveResult = () => (settled ??= {
|
|
19
|
+
text: s.text,
|
|
20
|
+
confidence: s.confidence ?? 1,
|
|
21
|
+
lowConfTokens: s.lowConfTokens ?? [],
|
|
22
|
+
firstPartialMs: s.firstPartialMs ?? null,
|
|
23
|
+
speakerCount: s.speakerCount ?? 1,
|
|
24
|
+
echoFlagged: s.echoFlagged ?? false,
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
stop: () => new Promise((resolve) => {
|
|
28
|
+
const delay = Math.max(0, s.transcribeMs ?? 0);
|
|
29
|
+
setTimeout(() => resolve(resolveResult()), delay);
|
|
30
|
+
}),
|
|
31
|
+
cancel: () => { settled = resolveResult(); },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// whisper.cpp STT engine (task-1282). Local, no key, no network once the model is downloaded. This is
|
|
2
|
+
// the portable fallback when Apple Speech is unavailable. The spike measured `base.en` at ~3.5–4.8 s
|
|
3
|
+
// batch on a CPU-only Intel Mac with good accuracy; `large-v3-turbo` was unusable (55–93 s) — so this
|
|
4
|
+
// engine defaults to `base.en` and the doctor steers the user to it, never to a turbo model on CPU.
|
|
5
|
+
//
|
|
6
|
+
// Capture: `sox`/`rec` or `ffmpeg -f avfoundation` writes a 16 kHz mono WAV while the key is held;
|
|
7
|
+
// stop() finalises the WAV and runs whisper-cli on it. The WAV lives in the OS temp dir and is
|
|
8
|
+
// DELETED immediately after transcription unless DEXTERM_VOICE_RECORD=1 — nothing is kept by default.
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { existsSync, rmSync, mkdtempSync } from 'node:fs';
|
|
11
|
+
import { homedir, tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
export const WHISPER_BIN = process.env.DEXTERM_WHISPER_BIN || 'whisper-cli';
|
|
14
|
+
/** Default model — base.en, the spike's accurate+usable choice. Never large-v3-turbo on CPU. */
|
|
15
|
+
export const WHISPER_MODEL = process.env.DEXTERM_WHISPER_MODEL || join(homedir(), '.dexterm', 'models', 'ggml-base.en.bin');
|
|
16
|
+
/** Where the user is told to put the model (doctor fix line). */
|
|
17
|
+
export const WHISPER_MODEL_DIR = join(homedir(), '.dexterm', 'models');
|
|
18
|
+
/**
|
|
19
|
+
* Pick a mic-capture tool, best-first. sox (`rec`) is cross-platform; `arecord` (alsa-utils) is the
|
|
20
|
+
* native Linux capture tool (task-1408) and finalises its WAV header on SIGINT; ffmpeg is last and its
|
|
21
|
+
* input format is platform-specific (avfoundation on macOS, alsa on Linux). All write a 16 kHz mono
|
|
22
|
+
* 16-bit WAV, which is what whisper.cpp wants. Returns null when nothing is installed.
|
|
23
|
+
*/
|
|
24
|
+
export function pickRecorder() {
|
|
25
|
+
if (which('rec'))
|
|
26
|
+
return { cmd: 'rec', args: (wav) => ['-q', '-c', '1', '-r', '16000', '-b', '16', wav], stopSignal: 'SIGINT' };
|
|
27
|
+
if (which('sox'))
|
|
28
|
+
return { cmd: 'sox', args: (wav) => ['-q', '-d', '-c', '1', '-r', '16000', '-b', '16', wav], stopSignal: 'SIGINT' };
|
|
29
|
+
if (process.platform !== 'darwin' && which('arecord')) {
|
|
30
|
+
return { cmd: 'arecord', args: (wav) => ['-q', '-f', 'S16_LE', '-c', '1', '-r', '16000', wav], stopSignal: 'SIGINT' };
|
|
31
|
+
}
|
|
32
|
+
if (which('ffmpeg')) {
|
|
33
|
+
const linux = process.platform === 'linux';
|
|
34
|
+
const fmt = linux ? 'alsa' : 'avfoundation';
|
|
35
|
+
const dev = process.env.DEXTERM_FFMPEG_MIC || (linux ? 'default' : ':default');
|
|
36
|
+
return { cmd: 'ffmpeg', args: (wav) => ['-hide_banner', '-loglevel', 'error', '-f', fmt, '-i', dev, '-ac', '1', '-ar', '16000', '-y', wav], stopSignal: 'SIGINT' };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** Synchronous PATH check without spawning (cheap; used by available() and pickRecorder). */
|
|
41
|
+
function which(bin) {
|
|
42
|
+
const paths = (process.env.PATH ?? '').split(':');
|
|
43
|
+
for (const p of paths) {
|
|
44
|
+
if (p && existsSync(join(p, bin)))
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
export class WhisperStt {
|
|
50
|
+
name = 'whisper';
|
|
51
|
+
label = 'whisper.cpp base.en (local, no key)';
|
|
52
|
+
model;
|
|
53
|
+
recordToDisk = process.env.DEXTERM_VOICE_RECORD === '1';
|
|
54
|
+
constructor(model = WHISPER_MODEL) { this.model = model; }
|
|
55
|
+
async available() {
|
|
56
|
+
return (which(WHISPER_BIN) || existsSync(WHISPER_BIN)) && existsSync(this.model) && pickRecorder() !== null;
|
|
57
|
+
}
|
|
58
|
+
async listen() {
|
|
59
|
+
const rec = pickRecorder();
|
|
60
|
+
if (!rec)
|
|
61
|
+
throw new Error('no mic capture tool (install sox or ffmpeg)');
|
|
62
|
+
const dir = mkdtempSync(join(tmpdir(), 'dexterm-voice-'));
|
|
63
|
+
const wav = join(dir, 'capture.wav');
|
|
64
|
+
const t0 = Date.now();
|
|
65
|
+
const recorder = spawn(rec.cmd, rec.args(wav), { stdio: 'ignore' });
|
|
66
|
+
let cancelled = false;
|
|
67
|
+
const cleanup = () => {
|
|
68
|
+
if (this.recordToDisk)
|
|
69
|
+
return; // explicit opt-in to keep the audio
|
|
70
|
+
try {
|
|
71
|
+
rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
catch { /* ignore */ }
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
stop: async () => {
|
|
77
|
+
// Ask the recorder to finalise the WAV header, then wait for it to exit.
|
|
78
|
+
try {
|
|
79
|
+
recorder.kill(rec.stopSignal);
|
|
80
|
+
}
|
|
81
|
+
catch { /* already gone */ }
|
|
82
|
+
await new Promise((r) => { recorder.on('close', () => r()); setTimeout(r, 2000); });
|
|
83
|
+
if (cancelled || !existsSync(wav)) {
|
|
84
|
+
cleanup();
|
|
85
|
+
return { text: '', confidence: 0 };
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const text = await transcribe(wav, this.model);
|
|
89
|
+
return { text, confidence: text ? 0.9 : 0, firstPartialMs: null, speakerCount: 1 };
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
cleanup();
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
cancel: () => { cancelled = true; try {
|
|
96
|
+
recorder.kill('SIGKILL');
|
|
97
|
+
}
|
|
98
|
+
catch { /* ignore */ } cleanup(); },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Run whisper-cli on a finished WAV and return the transcript text. Rejects on a non-zero exit. */
|
|
103
|
+
function transcribe(wav, model) {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const p = spawn(WHISPER_BIN, ['-m', model, '-f', wav, '-nt', '-l', 'en', '-t', '4'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
106
|
+
let out = '', err = '';
|
|
107
|
+
p.stdout.on('data', (d) => (out += d));
|
|
108
|
+
p.stderr.on('data', (d) => (err += d));
|
|
109
|
+
p.on('error', reject);
|
|
110
|
+
p.on('close', (code) => code === 0 ? resolve(out.replace(/\s+/g, ' ').trim()) : reject(new Error(`whisper exit ${code}: ${err.slice(-200)}`)));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Turn an analyst answer into its spoken form. It used to read only the FIRST ~2 sentences; since 2026-10-12
|
|
2
|
+
// the whole answer is read, so the default is now every sentence, capped for sanity (a long-form dossier). The analyst's answer arrives either as a verdict + summary lines (structured
|
|
3
|
+
// skills) or as free prose (chat follow-ups); this flattens both and trims to a speakable length.
|
|
4
|
+
//
|
|
5
|
+
// Pure and dependency-free so it is unit-tested headless.
|
|
6
|
+
const MAX_CHARS = 1800;
|
|
7
|
+
/** Collapse whitespace and strip markup/markdown that would be read aloud as noise. */
|
|
8
|
+
function tidy(s) {
|
|
9
|
+
return s
|
|
10
|
+
.replace(/[*_`#>]/g, ' ') // markdown emphasis / headings / code / quotes
|
|
11
|
+
.replace(/\[[^\]]*\]\([^)]*\)/g, (m) => m.replace(/\]\([^)]*\)/, ']').replace(/[[\]]/g, '')) // [label](url) -> label
|
|
12
|
+
.replace(/https?:\/\/\S+/g, 'a link')
|
|
13
|
+
.replace(/\s+/g, ' ')
|
|
14
|
+
.trim();
|
|
15
|
+
}
|
|
16
|
+
/** Split into sentences on ., !, ? boundaries, keeping it simple and robust. */
|
|
17
|
+
export function splitSentences(text) {
|
|
18
|
+
const t = tidy(text);
|
|
19
|
+
if (!t)
|
|
20
|
+
return [];
|
|
21
|
+
const parts = t.match(/[^.!?]+[.!?]+|[^.!?]+$/g);
|
|
22
|
+
return (parts ?? [t]).map((s) => s.trim()).filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build the spoken summary. A verdict (e.g. "AVOID", "LARP") is spoken first as its own short clause,
|
|
26
|
+
* then the whole answer (every summary/prose sentence unless `maxSentences` limits it). Capped at MAX_CHARS.
|
|
27
|
+
*/
|
|
28
|
+
export function spokenSummary(src, maxSentences = Infinity) {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
const verdict = (src.verdict ?? '').trim();
|
|
31
|
+
if (verdict)
|
|
32
|
+
chunks.push(`Verdict: ${verdict}.`);
|
|
33
|
+
const body = (src.summary && src.summary.length ? src.summary.join('. ') : (src.text ?? '')).trim();
|
|
34
|
+
const sentences = splitSentences(body).slice(0, maxSentences);
|
|
35
|
+
chunks.push(...sentences);
|
|
36
|
+
let out = tidy(chunks.join(' '));
|
|
37
|
+
if (out.length > MAX_CHARS) {
|
|
38
|
+
// Cut at the last sentence boundary that fits, else hard-trim.
|
|
39
|
+
const cut = out.slice(0, MAX_CHARS);
|
|
40
|
+
const lastStop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
|
|
41
|
+
out = (lastStop > 80 ? cut.slice(0, lastStop + 1) : cut.trimEnd() + '…');
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Portable (Linux/Windows) TTS via espeak-ng (task-1393 Part 2). espeak-ng is free, local, ships in or
|
|
2
|
+
// installs trivially on every Linux distro, and runs on Windows too. It is the "works everywhere" baseline
|
|
3
|
+
// off macOS; a Piper neural voice is a higher-quality upgrade that needs a downloaded model (see doctor).
|
|
4
|
+
//
|
|
5
|
+
// It carries the SAME contract as the macOS engine: text is SANITISED and delivered on STDIN (espeak-ng
|
|
6
|
+
// treats `[[…]]` as phoneme input, so stripping it is a safety requirement, not a nicety), a new speak()
|
|
7
|
+
// cleanly interrupts the current one (single queue), and the default voice is an explicit deep British
|
|
8
|
+
// male (`en-gb+m3`) — never a surprise.
|
|
9
|
+
//
|
|
10
|
+
// VERIFICATION NOTE: the logic is unit-tested against a fake binary; it was NOT run against real
|
|
11
|
+
// espeak-ng audio because the Linux (WSL) box was unreachable during this task. See the handoff.
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { sanitizeSpeech } from "./voices.js";
|
|
16
|
+
/** espeak-ng first (actively maintained), then the older espeak. Overridable for tests. */
|
|
17
|
+
export const ESPEAK_BIN = process.env.DEXTERM_ESPEAK_BIN || '';
|
|
18
|
+
/** The default portable voice: a British male. `+m3` is a deeper male variant. */
|
|
19
|
+
export const DEFAULT_ESPEAK_VOICE = 'en-gb+m3';
|
|
20
|
+
/** espeak words-per-minute default (close to the macOS 190 feel). */
|
|
21
|
+
export const DEFAULT_ESPEAK_RATE = 175;
|
|
22
|
+
function which(bin) {
|
|
23
|
+
if (!bin)
|
|
24
|
+
return null;
|
|
25
|
+
if (bin.includes('/') || bin.includes('\\'))
|
|
26
|
+
return existsSync(bin) ? bin : null;
|
|
27
|
+
for (const p of (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':')) {
|
|
28
|
+
if (!p)
|
|
29
|
+
continue;
|
|
30
|
+
if (existsSync(join(p, bin)))
|
|
31
|
+
return join(p, bin);
|
|
32
|
+
if (process.platform === 'win32' && existsSync(join(p, `${bin}.exe`)))
|
|
33
|
+
return join(p, `${bin}.exe`);
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/** Resolve the espeak binary path: an explicit override, else espeak-ng, else espeak. */
|
|
38
|
+
export function resolveEspeakBin() {
|
|
39
|
+
return which(ESPEAK_BIN) || which('espeak-ng') || which('espeak');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* espeak voice names are language codes, optionally with a variant: `en`, `en-gb`, `en-us+m3`, `de+f2`.
|
|
43
|
+
* A macOS voice name like "Eddy (English (UK))" or "Daniel" is NOT one and must never be passed to
|
|
44
|
+
* espeak verbatim (task-1408 residual) — it would be an invalid `-v` argument. This matches only the
|
|
45
|
+
* espeak form, so a Mac voice name is rejected and the engine default is used instead.
|
|
46
|
+
*/
|
|
47
|
+
const ESPEAK_VOICE_RE = /^[a-z]{2,3}(-[a-z0-9]+)*(\+[a-z0-9]+)?$/i;
|
|
48
|
+
/** A valid espeak voice token, or null when `configured` is empty or is a macOS voice name. */
|
|
49
|
+
export function espeakVoiceFor(configured) {
|
|
50
|
+
const v = configured?.trim();
|
|
51
|
+
if (!v)
|
|
52
|
+
return null;
|
|
53
|
+
return ESPEAK_VOICE_RE.test(v) ? v : null;
|
|
54
|
+
}
|
|
55
|
+
export class EspeakTts {
|
|
56
|
+
name = 'espeak';
|
|
57
|
+
label = 'espeak-ng (local, no key)';
|
|
58
|
+
defaultVoice;
|
|
59
|
+
current = null;
|
|
60
|
+
constructor(defaultVoice = DEFAULT_ESPEAK_VOICE) { this.defaultVoice = defaultVoice; }
|
|
61
|
+
get voiceName() { return this.defaultVoice; }
|
|
62
|
+
async available() { return resolveEspeakBin() !== null; }
|
|
63
|
+
/** espeak-ng has no per-machine "installed voices" list the user cycles; the default covers it. */
|
|
64
|
+
async listVoices() { return [this.defaultVoice]; }
|
|
65
|
+
speak(text, opts = {}) {
|
|
66
|
+
this.stopCurrent(); // single queue: never two speakers at once
|
|
67
|
+
const bin = resolveEspeakBin();
|
|
68
|
+
const voice = opts.voice ?? this.defaultVoice;
|
|
69
|
+
const rate = opts.rate ?? DEFAULT_ESPEAK_RATE;
|
|
70
|
+
const safe = sanitizeSpeech(text);
|
|
71
|
+
const args = ['-v', voice, '-s', String(rate)];
|
|
72
|
+
if (typeof opts.pitch === 'number' && Number.isFinite(opts.pitch)) {
|
|
73
|
+
args.push('-p', String(Math.max(0, Math.min(99, Math.round(opts.pitch))))); // 0-99, lower = deeper
|
|
74
|
+
}
|
|
75
|
+
args.push('--stdin'); // read the utterance from stdin — never an argv word
|
|
76
|
+
let child = null;
|
|
77
|
+
let stopped = false;
|
|
78
|
+
let resolve;
|
|
79
|
+
const done = new Promise((r) => { resolve = r; });
|
|
80
|
+
const finish = () => { if (!stopped) {
|
|
81
|
+
stopped = true;
|
|
82
|
+
if (this.current === handle)
|
|
83
|
+
this.current = null;
|
|
84
|
+
resolve();
|
|
85
|
+
} };
|
|
86
|
+
try {
|
|
87
|
+
if (!bin)
|
|
88
|
+
throw new Error('espeak not found');
|
|
89
|
+
child = spawn(bin, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
90
|
+
child.stdin?.on('error', () => { });
|
|
91
|
+
try {
|
|
92
|
+
child.stdin?.end(safe);
|
|
93
|
+
}
|
|
94
|
+
catch { /* child gone */ }
|
|
95
|
+
child.on('spawn', () => { if (!stopped)
|
|
96
|
+
opts.onFirstAudio?.(); });
|
|
97
|
+
child.on('error', finish);
|
|
98
|
+
child.on('close', finish);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
queueMicrotask(finish); // no espeak → degrade to text
|
|
102
|
+
}
|
|
103
|
+
const handle = {
|
|
104
|
+
done,
|
|
105
|
+
stop: () => {
|
|
106
|
+
if (stopped)
|
|
107
|
+
return;
|
|
108
|
+
stopped = true;
|
|
109
|
+
if (this.current === handle)
|
|
110
|
+
this.current = null;
|
|
111
|
+
try {
|
|
112
|
+
child?.kill('SIGTERM');
|
|
113
|
+
}
|
|
114
|
+
catch { /* already gone */ }
|
|
115
|
+
resolve();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
this.current = handle;
|
|
119
|
+
return handle;
|
|
120
|
+
}
|
|
121
|
+
stopCurrent() {
|
|
122
|
+
const h = this.current;
|
|
123
|
+
if (h) {
|
|
124
|
+
this.current = null;
|
|
125
|
+
try {
|
|
126
|
+
h.stop();
|
|
127
|
+
}
|
|
128
|
+
catch { /* ignore */ }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A TTS engine that produces no audio — it records what would have been spoken and resolves at once.
|
|
2
|
+
// Used by the headless tests and the latency harness so speech-out is deterministic and silent.
|
|
3
|
+
export class FakeTts {
|
|
4
|
+
name = 'fake';
|
|
5
|
+
label = 'fake (silent, for tests)';
|
|
6
|
+
/** Every text passed to speak(), in order, for assertions. */
|
|
7
|
+
spoken = [];
|
|
8
|
+
/** How many times stop() interrupted a live utterance. */
|
|
9
|
+
stops = 0;
|
|
10
|
+
voices;
|
|
11
|
+
constructor(voices = ['Samantha', 'Alex', 'Daniel']) { this.voices = voices; }
|
|
12
|
+
async available() { return true; }
|
|
13
|
+
async listVoices() { return this.voices.slice(); }
|
|
14
|
+
speak(text, opts = {}) {
|
|
15
|
+
this.spoken.push(text);
|
|
16
|
+
let stopped = false;
|
|
17
|
+
let resolve;
|
|
18
|
+
const done = new Promise((r) => { resolve = r; });
|
|
19
|
+
// Fire first-audio on the next tick so latency timing sees a realistic (tiny) gap, then finish.
|
|
20
|
+
const t = setTimeout(() => { if (!stopped) {
|
|
21
|
+
opts.onFirstAudio?.();
|
|
22
|
+
resolve();
|
|
23
|
+
} }, 0);
|
|
24
|
+
return {
|
|
25
|
+
done,
|
|
26
|
+
stop: () => { if (stopped)
|
|
27
|
+
return; stopped = true; clearTimeout(t); this.stops++; resolve(); },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|