@algochad/archcoder 2.0.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/README.md +113 -0
- package/bin/cli-entry.js +55 -0
- package/bin/cli-output.js +145 -0
- package/bin/cli.js +5108 -0
- package/bin/cli.test.js +56 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +67 -0
- package/dist/assets/MultiRunWindow-BZp3MjJP.js +1 -0
- package/dist/assets/SettingsWindow-DoGYXpX7.js +1 -0
- package/dist/assets/TerminalView-BN7BR5Ff.js +3 -0
- package/dist/assets/TimelineDialog-ZQ33oVQR.js +1 -0
- package/dist/assets/ToolOutputDialog-Blv3pnug.js +16 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-CtCEGYrr.css +1 -0
- package/dist/assets/index-o_d2wtWC.js +48 -0
- package/dist/assets/main-5QGBtzdq.css +1 -0
- package/dist/assets/main-B6oiMU86.js +8033 -0
- package/dist/assets/vendor--DbVqbJpV.css +1 -0
- package/dist/assets/vendor-.bun-HTKwyaEM.js +10086 -0
- package/dist/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/assets/worker-bqd4RMrj.js +155 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +67 -0
- package/dist/index.html +533 -0
- package/dist/logo-dark-192x192.png +0 -0
- package/dist/logo-dark-512x512.svg +16 -0
- package/dist/logo-light-192x192.png +0 -0
- package/dist/logo-light-512x512.svg +16 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/pwa-maskable-192.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/dist/site.webmanifest +22 -0
- package/dist/sw.js +1 -0
- package/package.json +107 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +67 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +67 -0
- package/public/logo-dark-192x192.png +0 -0
- package/public/logo-dark-512x512.svg +16 -0
- package/public/logo-light-192x192.png +0 -0
- package/public/logo-light-512x512.svg +16 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/pwa-maskable-192.png +0 -0
- package/public/pwa-maskable-512.png +0 -0
- package/public/site.webmanifest +22 -0
- package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
- package/server/index.d.ts +37 -0
- package/server/index.js +14694 -0
- package/server/lib/cloudflare-tunnel.js +650 -0
- package/server/lib/git/DOCUMENTATION.md +146 -0
- package/server/lib/git/credentials.js +74 -0
- package/server/lib/git/identity-storage.js +110 -0
- package/server/lib/git/index.js +6 -0
- package/server/lib/git/service.js +3117 -0
- package/server/lib/github/DOCUMENTATION.md +170 -0
- package/server/lib/github/auth.js +307 -0
- package/server/lib/github/device-flow.js +50 -0
- package/server/lib/github/index.js +24 -0
- package/server/lib/github/octokit.js +10 -0
- package/server/lib/github/pr-status.js +478 -0
- package/server/lib/github/repo/index.js +55 -0
- package/server/lib/installer/desktop.js +289 -0
- package/server/lib/installer/download.js +208 -0
- package/server/lib/installer/index.js +45 -0
- package/server/lib/installer/platform.js +100 -0
- package/server/lib/notifications/DOCUMENTATION.md +61 -0
- package/server/lib/notifications/index.js +1 -0
- package/server/lib/notifications/message.js +49 -0
- package/server/lib/notifications/message.test.js +59 -0
- package/server/lib/opencode/DOCUMENTATION.md +59 -0
- package/server/lib/opencode/agents.js +634 -0
- package/server/lib/opencode/auth.js +81 -0
- package/server/lib/opencode/commands.js +339 -0
- package/server/lib/opencode/index.js +66 -0
- package/server/lib/opencode/mcp.js +206 -0
- package/server/lib/opencode/providers.js +96 -0
- package/server/lib/opencode/shared.js +527 -0
- package/server/lib/opencode/skills.js +480 -0
- package/server/lib/opencode/tunnel-auth.js +591 -0
- package/server/lib/opencode/ui-auth.js +510 -0
- package/server/lib/package-manager.js +505 -0
- package/server/lib/quota/DOCUMENTATION.md +55 -0
- package/server/lib/quota/index.js +24 -0
- package/server/lib/quota/providers/claude.js +107 -0
- package/server/lib/quota/providers/codex.js +113 -0
- package/server/lib/quota/providers/copilot.js +165 -0
- package/server/lib/quota/providers/google/api.js +92 -0
- package/server/lib/quota/providers/google/auth.js +108 -0
- package/server/lib/quota/providers/google/index.js +124 -0
- package/server/lib/quota/providers/google/transforms.js +109 -0
- package/server/lib/quota/providers/index.js +152 -0
- package/server/lib/quota/providers/interface.js +55 -0
- package/server/lib/quota/providers/kimi.js +108 -0
- package/server/lib/quota/providers/minimax-cn-coding-plan.js +15 -0
- package/server/lib/quota/providers/minimax-coding-plan.js +15 -0
- package/server/lib/quota/providers/minimax-shared.js +136 -0
- package/server/lib/quota/providers/nanogpt.js +124 -0
- package/server/lib/quota/providers/ollama-cloud.js +112 -0
- package/server/lib/quota/providers/openai.js +91 -0
- package/server/lib/quota/providers/openrouter.js +92 -0
- package/server/lib/quota/providers/zai.js +91 -0
- package/server/lib/quota/utils/auth.js +46 -0
- package/server/lib/quota/utils/formatters.js +76 -0
- package/server/lib/quota/utils/index.js +10 -0
- package/server/lib/quota/utils/transformers.js +55 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
- package/server/lib/skills-catalog/cache.js +32 -0
- package/server/lib/skills-catalog/clawdhub/api.js +158 -0
- package/server/lib/skills-catalog/clawdhub/index.js +30 -0
- package/server/lib/skills-catalog/clawdhub/install.js +238 -0
- package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
- package/server/lib/skills-catalog/curated-sources.js +21 -0
- package/server/lib/skills-catalog/git.js +77 -0
- package/server/lib/skills-catalog/index.js +42 -0
- package/server/lib/skills-catalog/install.js +294 -0
- package/server/lib/skills-catalog/scan.js +221 -0
- package/server/lib/skills-catalog/source.js +85 -0
- package/server/lib/terminal/DOCUMENTATION.md +114 -0
- package/server/lib/terminal/index.js +12 -0
- package/server/lib/terminal/input-ws-protocol.js +66 -0
- package/server/lib/terminal/input-ws-protocol.test.js +138 -0
- package/server/lib/tts/DOCUMENTATION.md +134 -0
- package/server/lib/tts/index.js +16 -0
- package/server/lib/tts/service.js +162 -0
- package/server/lib/tts/summarization.js +171 -0
- package/server/lib/tunnels/index.js +166 -0
- package/server/lib/tunnels/providers/cloudflare.js +260 -0
- package/server/lib/tunnels/registry.js +51 -0
- package/server/lib/tunnels/types.js +219 -0
- package/server/lib/utils/lru.js +107 -0
- package/server/lib/utils/sse.js +121 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# TTS Module Documentation
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
This module provides server-side Text-to-Speech services using OpenAI's TTS API, along with text summarization and sanitization utilities for preparing content for speech synthesis.
|
|
5
|
+
|
|
6
|
+
## Entrypoints and structure
|
|
7
|
+
- `packages/web/server/lib/tts/index.js`: Public entrypoint imported by `packages/web/server/index.js`.
|
|
8
|
+
- `packages/web/server/lib/tts/service.js`: TTS service implementation with OpenAI integration.
|
|
9
|
+
- `packages/web/server/lib/tts/summarization.js`: Text summarization and sanitization utilities using opencode.ai zen API.
|
|
10
|
+
|
|
11
|
+
## Public exports
|
|
12
|
+
|
|
13
|
+
### TTS Service (from service.js)
|
|
14
|
+
- `ttsService`: Singleton instance of TTSService class.
|
|
15
|
+
- `TTSService`: TTS service class for OpenAI audio generation.
|
|
16
|
+
- `TTS_VOICES`: Array of supported OpenAI voice identifiers.
|
|
17
|
+
|
|
18
|
+
### Summarization (from summarization.js)
|
|
19
|
+
- `summarizeText({ text, threshold, maxLength, zenModel })`: Summarizes text for TTS output using opencode.ai zen API.
|
|
20
|
+
- `sanitizeForTTS(text)`: Sanitizes text by removing markdown, URLs, file paths, and other non-speakable content.
|
|
21
|
+
|
|
22
|
+
## Constants
|
|
23
|
+
|
|
24
|
+
### Voice identifiers
|
|
25
|
+
- `TTS_VOICES`: Array of supported OpenAI voices: `['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar']`.
|
|
26
|
+
|
|
27
|
+
### Summarization defaults
|
|
28
|
+
- `SUMMARIZE_TIMEOUT_MS`: 30000 (30 seconds timeout for zen API requests).
|
|
29
|
+
|
|
30
|
+
### Default values
|
|
31
|
+
- `summarizeText` defaults: `threshold` = 200, `maxLength` = 500, `zenModel` = 'gpt-5-nano'.
|
|
32
|
+
- `generateSpeechStream` defaults: `voice` = 'coral', `model` = 'gpt-4o-mini-tts', `speed` = 1.0.
|
|
33
|
+
- `generateSpeechBuffer` defaults: `voice` = 'coral', `model` = 'gpt-4o-mini-tts', `speed` = 1.0.
|
|
34
|
+
|
|
35
|
+
## TTSService methods
|
|
36
|
+
|
|
37
|
+
### `isAvailable()`
|
|
38
|
+
Returns boolean indicating whether OpenAI API key is configured (checks environment variable `OPENAI_API_KEY` or OpenCode auth file).
|
|
39
|
+
|
|
40
|
+
### `generateSpeechStream(options)`
|
|
41
|
+
Generates speech and returns as a web stream for direct streaming to clients.
|
|
42
|
+
- Options: `text` (required), `voice`, `model`, `speed`, `instructions`, `apiKey`.
|
|
43
|
+
- Returns: `{ stream: ReadableStream, contentType: 'audio/mpeg' }`.
|
|
44
|
+
- Throws: Error if API key not configured or text is empty.
|
|
45
|
+
|
|
46
|
+
### `generateSpeechBuffer(options)`
|
|
47
|
+
Generates speech and returns as Buffer for caching purposes.
|
|
48
|
+
- Options: `text` (required), `voice`, `model`, `speed`, `instructions`.
|
|
49
|
+
- Returns: Buffer containing MP3 audio data.
|
|
50
|
+
- Throws: Error if API key not configured or text is empty.
|
|
51
|
+
|
|
52
|
+
## Response contracts
|
|
53
|
+
|
|
54
|
+
### `summarizeText`
|
|
55
|
+
Returns object with:
|
|
56
|
+
- `summary`: Sanitized summary text or original text (if not summarized).
|
|
57
|
+
- `summarized`: Boolean indicating if summarization was performed.
|
|
58
|
+
- `reason`: Optional string explaining why summarization was skipped (e.g., 'Text under threshold', 'Request timed out').
|
|
59
|
+
- `originalLength`: Optional number for original text length.
|
|
60
|
+
- `summaryLength`: Optional number for summarized text length.
|
|
61
|
+
|
|
62
|
+
### `sanitizeForTTS`
|
|
63
|
+
Returns sanitized string with markdown, URLs, file paths, and special characters removed.
|
|
64
|
+
|
|
65
|
+
### `generateSpeechStream`
|
|
66
|
+
Returns object with:
|
|
67
|
+
- `stream`: ReadableStream of MP3 audio data.
|
|
68
|
+
- `contentType`: Always 'audio/mpeg'.
|
|
69
|
+
|
|
70
|
+
### `generateSpeechBuffer`
|
|
71
|
+
Returns Buffer containing MP3 audio data.
|
|
72
|
+
|
|
73
|
+
## API key resolution
|
|
74
|
+
OpenAI API keys are resolved in order:
|
|
75
|
+
1. Environment variable `OPENAI_API_KEY`.
|
|
76
|
+
2. OpenCode auth file (`auth.openai`, `auth.codex`, or `auth.chatgpt`).
|
|
77
|
+
3. Supports both string format (just token) and object format (with `access` or `token` fields).
|
|
78
|
+
|
|
79
|
+
## Usage in web server
|
|
80
|
+
The TTS module is used by `packages/web/server/index.js` for:
|
|
81
|
+
- Generating speech streams for client playback.
|
|
82
|
+
- Generating speech buffers for caching.
|
|
83
|
+
- Summarizing long messages before TTS synthesis.
|
|
84
|
+
- Sanitizing text to remove non-speakable content.
|
|
85
|
+
|
|
86
|
+
The server-side TTS approach bypasses mobile Safari's audio context restrictions by generating audio on the server and streaming to clients.
|
|
87
|
+
|
|
88
|
+
## Notes for contributors
|
|
89
|
+
|
|
90
|
+
### Adding new TTS features
|
|
91
|
+
1. Add new methods to `packages/web/server/lib/tts/service.js` TTSService class.
|
|
92
|
+
2. Export public functions from `packages/web/server/lib/tts/index.js`.
|
|
93
|
+
3. Follow existing patterns for API key resolution and error handling.
|
|
94
|
+
4. Ensure all text is sanitized before TTS synthesis.
|
|
95
|
+
5. Consider adding new voice options to `TTS_VOICES` constant.
|
|
96
|
+
|
|
97
|
+
### Text sanitization
|
|
98
|
+
- Always call `sanitizeForTTS` on text before passing to TTS generation.
|
|
99
|
+
- The sanitization removes markdown, code blocks, URLs, file paths, shell commands, and special characters.
|
|
100
|
+
- This prevents the TTS from reading out technical formatting that sounds unnatural.
|
|
101
|
+
|
|
102
|
+
### Error handling
|
|
103
|
+
- `generateSpeechStream` and `generateSpeechBuffer` throw descriptive errors for missing API keys or empty text.
|
|
104
|
+
- `summarizeText` catches zen API errors and falls back to original text with `summarized: false`.
|
|
105
|
+
- All errors are logged to console with `[TTSService]` or `[Summarize]` prefix.
|
|
106
|
+
|
|
107
|
+
### API key management
|
|
108
|
+
- TTSService caches OpenAI client instance and recreates when API key changes.
|
|
109
|
+
- API key changes are detected by comparing with `_lastApiKey` property.
|
|
110
|
+
- This allows dynamic API key updates without server restart.
|
|
111
|
+
|
|
112
|
+
### Testing
|
|
113
|
+
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
|
|
114
|
+
- Test API key resolution with environment variable and auth file.
|
|
115
|
+
- Test speech generation with various text lengths and voice options.
|
|
116
|
+
- Test summarization behavior above and below threshold.
|
|
117
|
+
- Test sanitization with markdown, URLs, and code blocks.
|
|
118
|
+
- Verify streaming and buffer generation produce valid MP3 audio.
|
|
119
|
+
|
|
120
|
+
## Verification notes
|
|
121
|
+
|
|
122
|
+
### Manual verification
|
|
123
|
+
1. Configure OpenAI API key via environment variable or OpenCode settings.
|
|
124
|
+
2. Test `ttsService.isAvailable()` returns true.
|
|
125
|
+
3. Call `ttsService.generateSpeechStream({ text: 'Hello world' })` and verify stream is returned.
|
|
126
|
+
4. Call `ttsService.generateSpeechBuffer({ text: 'Hello world' })` and verify Buffer is returned.
|
|
127
|
+
5. Test `summarizeText` with text above and below threshold.
|
|
128
|
+
6. Test `sanitizeForTTS` with markdown, URLs, and code blocks.
|
|
129
|
+
|
|
130
|
+
### API endpoint verification
|
|
131
|
+
1. Start web server and access TTS endpoint via client.
|
|
132
|
+
2. Verify audio plays correctly in browser.
|
|
133
|
+
3. Test on mobile Safari to verify bypass of audio context restrictions.
|
|
134
|
+
4. Test with long messages to verify summarization is triggered.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTS Module Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Public export surface for the Text-to-Speech domain module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
ttsService,
|
|
9
|
+
TTSService,
|
|
10
|
+
TTS_VOICES,
|
|
11
|
+
} from './service.js';
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
summarizeText,
|
|
15
|
+
sanitizeForTTS,
|
|
16
|
+
} from './summarization.js';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side Text-to-Speech Service
|
|
3
|
+
*
|
|
4
|
+
* Uses OpenAI's TTS API to generate audio on the server and stream it to clients.
|
|
5
|
+
* This bypasses mobile Safari's audio context restrictions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import OpenAI from 'openai';
|
|
9
|
+
import { readAuthFile } from '../opencode/auth.js';
|
|
10
|
+
|
|
11
|
+
// Voice options from OpenAI
|
|
12
|
+
export const TTS_VOICES = [
|
|
13
|
+
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable',
|
|
14
|
+
'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar'
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function getOpenAIApiKey() {
|
|
18
|
+
// First check environment variable
|
|
19
|
+
const envKey = process.env.OPENAI_API_KEY;
|
|
20
|
+
if (envKey) {
|
|
21
|
+
return envKey;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Then check opencode auth file (same as usage tracker)
|
|
25
|
+
try {
|
|
26
|
+
const auth = readAuthFile();
|
|
27
|
+
// Check for openai, codex, or chatgpt aliases
|
|
28
|
+
const openaiAuth = auth.openai || auth.codex || auth.chatgpt;
|
|
29
|
+
if (openaiAuth) {
|
|
30
|
+
// Handle both string format (just the token) and object format
|
|
31
|
+
if (typeof openaiAuth === 'string') {
|
|
32
|
+
return openaiAuth;
|
|
33
|
+
}
|
|
34
|
+
// Try access token first (OAuth), then regular token
|
|
35
|
+
if (openaiAuth.access) {
|
|
36
|
+
return openaiAuth.access;
|
|
37
|
+
}
|
|
38
|
+
if (openaiAuth.token) {
|
|
39
|
+
return openaiAuth.token;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.warn('[TTSService] Failed to read auth file:', error.message);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class TTSService {
|
|
50
|
+
constructor() {
|
|
51
|
+
this._client = null;
|
|
52
|
+
this._lastApiKey = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_getClient() {
|
|
56
|
+
const apiKey = getOpenAIApiKey();
|
|
57
|
+
|
|
58
|
+
// If API key changed or client doesn't exist, create new client
|
|
59
|
+
if (apiKey && (!this._client || this._lastApiKey !== apiKey)) {
|
|
60
|
+
this._client = new OpenAI({ apiKey });
|
|
61
|
+
this._lastApiKey = apiKey;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return this._client;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
isAvailable() {
|
|
68
|
+
return this._getClient() !== null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generate speech and return as a stream
|
|
73
|
+
*/
|
|
74
|
+
async generateSpeechStream(options) {
|
|
75
|
+
const {
|
|
76
|
+
text,
|
|
77
|
+
voice = 'coral',
|
|
78
|
+
model = 'gpt-4o-mini-tts',
|
|
79
|
+
speed = 1.0,
|
|
80
|
+
instructions,
|
|
81
|
+
apiKey
|
|
82
|
+
} = options;
|
|
83
|
+
|
|
84
|
+
// Use provided API key or fall back to configured key
|
|
85
|
+
let client;
|
|
86
|
+
if (apiKey) {
|
|
87
|
+
client = new OpenAI({ apiKey });
|
|
88
|
+
} else {
|
|
89
|
+
client = this._getClient();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!client) {
|
|
93
|
+
throw new Error('OpenAI API key not configured. Set OPENAI_API_KEY environment variable, configure OpenAI in OpenCode, or provide an API key in settings.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!text.trim()) {
|
|
97
|
+
throw new Error('Text is required for TTS');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
console.log('[TTSService] Generating speech with voice:', voice, 'model:', model);
|
|
102
|
+
const response = await client.audio.speech.create({
|
|
103
|
+
model,
|
|
104
|
+
voice,
|
|
105
|
+
input: text,
|
|
106
|
+
speed,
|
|
107
|
+
...(instructions && { instructions }),
|
|
108
|
+
response_format: 'mp3',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Convert the response to a web stream
|
|
112
|
+
const stream = response.body;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
stream,
|
|
116
|
+
contentType: 'audio/mpeg',
|
|
117
|
+
};
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error('[TTSService] Error generating speech:', error);
|
|
120
|
+
throw new Error(`Failed to generate speech: ${error.message || 'Unknown error'}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Generate speech and return as a buffer (for caching)
|
|
126
|
+
*/
|
|
127
|
+
async generateSpeechBuffer(options) {
|
|
128
|
+
const client = this._getClient();
|
|
129
|
+
if (!client) {
|
|
130
|
+
throw new Error('OpenAI API key not configured. Set OPENAI_API_KEY environment variable or configure OpenAI in OpenCode.');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const {
|
|
134
|
+
text,
|
|
135
|
+
voice = 'coral',
|
|
136
|
+
model = 'gpt-4o-mini-tts',
|
|
137
|
+
speed = 1.0,
|
|
138
|
+
instructions
|
|
139
|
+
} = options;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const response = await client.audio.speech.create({
|
|
143
|
+
model,
|
|
144
|
+
voice,
|
|
145
|
+
input: text,
|
|
146
|
+
speed,
|
|
147
|
+
...(instructions && { instructions }),
|
|
148
|
+
response_format: 'mp3',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
152
|
+
return Buffer.from(arrayBuffer);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.error('[TTSService] Error generating speech buffer:', error);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Export singleton instance
|
|
161
|
+
export const ttsService = new TTSService();
|
|
162
|
+
export { TTSService };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Summarization Service
|
|
3
|
+
*
|
|
4
|
+
* Uses the opencode.ai zen API with gpt-5-nano for fast, lightweight summarization.
|
|
5
|
+
* Used by all TTS implementations (Browser, Say, OpenAI).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function buildSummarizationPrompt(maxLength) {
|
|
9
|
+
return `You are a text summarizer for text-to-speech output. Create a concise, natural-sounding summary that captures the key points. Keep the summary under ${maxLength} characters.
|
|
10
|
+
|
|
11
|
+
CRITICAL INSTRUCTIONS:
|
|
12
|
+
1. Output ONLY the final summary - no thinking, no reasoning, no explanations
|
|
13
|
+
2. Do not show your work or thought process
|
|
14
|
+
3. Do not use any special characters, markdown, code, URLs, file paths, or formatting
|
|
15
|
+
4. Do not include phrases like "Here's a summary" or "In summary"
|
|
16
|
+
5. Just provide clean, speakable text that can be read aloud
|
|
17
|
+
6. Stay within the ${maxLength} character limit
|
|
18
|
+
|
|
19
|
+
Your response should be ready to speak immediately.`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SUMMARIZE_TIMEOUT_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sanitize text for TTS output
|
|
26
|
+
* Removes markdown, URLs, file paths, and other non-speakable content
|
|
27
|
+
*/
|
|
28
|
+
export function sanitizeForTTS(text) {
|
|
29
|
+
if (!text || typeof text !== 'string') return '';
|
|
30
|
+
|
|
31
|
+
return text
|
|
32
|
+
// Remove markdown formatting
|
|
33
|
+
.replace(/[*_~`#]/g, '')
|
|
34
|
+
// Remove code blocks
|
|
35
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
36
|
+
.replace(/`[^`]*`/g, '')
|
|
37
|
+
// Remove shell-like command patterns
|
|
38
|
+
.replace(/^\s*[$#>]\s*/gm, '')
|
|
39
|
+
// Remove common shell operators
|
|
40
|
+
.replace(/[|&;<>]/g, ' ')
|
|
41
|
+
// Remove backslashes (escape characters)
|
|
42
|
+
.replace(/\\/g, '')
|
|
43
|
+
// Remove brackets that might be interpreted specially
|
|
44
|
+
.replace(/[[\]{}()]/g, '')
|
|
45
|
+
// Remove quotes that might cause issues
|
|
46
|
+
.replace(/["']/g, '')
|
|
47
|
+
// Remove URLs
|
|
48
|
+
.replace(/https?:\/\/[^\s]+/g, ' a link ')
|
|
49
|
+
// Remove file paths
|
|
50
|
+
.replace(/\/[\w\-./]+/g, '')
|
|
51
|
+
// Collapse multiple spaces/newlines
|
|
52
|
+
.replace(/\s+/g, ' ')
|
|
53
|
+
.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract text from zen API response
|
|
58
|
+
*/
|
|
59
|
+
function extractZenOutputText(data) {
|
|
60
|
+
if (!data || typeof data !== 'object') return null;
|
|
61
|
+
const output = data.output;
|
|
62
|
+
if (!Array.isArray(output)) return null;
|
|
63
|
+
|
|
64
|
+
const messageItem = output.find(
|
|
65
|
+
(item) => item && typeof item === 'object' && item.type === 'message'
|
|
66
|
+
);
|
|
67
|
+
if (!messageItem) return null;
|
|
68
|
+
|
|
69
|
+
const content = messageItem.content;
|
|
70
|
+
if (!Array.isArray(content)) return null;
|
|
71
|
+
|
|
72
|
+
const textItem = content.find(
|
|
73
|
+
(item) => item && typeof item === 'object' && item.type === 'output_text'
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const text = typeof textItem?.text === 'string' ? textItem.text.trim() : '';
|
|
77
|
+
return text || null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Summarize text using the opencode.ai zen API
|
|
82
|
+
*
|
|
83
|
+
* @param {Object} options
|
|
84
|
+
* @param {string} options.text - The text to summarize
|
|
85
|
+
* @param {number} options.threshold - Character threshold (don't summarize if under this length)
|
|
86
|
+
* @param {number} options.maxLength - Maximum character length for the summary output (50-2000)
|
|
87
|
+
* @param {string} [options.zenModel] - Override zen model (defaults to gpt-5-nano)
|
|
88
|
+
* @returns {Promise<{summary: string, summarized: boolean, reason?: string}>}
|
|
89
|
+
*/
|
|
90
|
+
export async function summarizeText({
|
|
91
|
+
text,
|
|
92
|
+
threshold = 200,
|
|
93
|
+
maxLength = 500,
|
|
94
|
+
zenModel,
|
|
95
|
+
}) {
|
|
96
|
+
// Don't summarize if text is under threshold
|
|
97
|
+
if (!text || text.length <= threshold) {
|
|
98
|
+
return {
|
|
99
|
+
summary: sanitizeForTTS(text || ''),
|
|
100
|
+
summarized: false,
|
|
101
|
+
reason: text ? 'Text under threshold' : 'No text provided',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const timer = setTimeout(() => controller.abort(), SUMMARIZE_TIMEOUT_MS);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const prompt = buildSummarizationPrompt(maxLength);
|
|
110
|
+
|
|
111
|
+
const response = await fetch('https://opencode.ai/zen/v1/responses', {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'Content-Type': 'application/json' },
|
|
114
|
+
body: JSON.stringify({
|
|
115
|
+
model: zenModel || 'gpt-5-nano',
|
|
116
|
+
input: [
|
|
117
|
+
{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` },
|
|
118
|
+
],
|
|
119
|
+
stream: false,
|
|
120
|
+
reasoning: { effort: 'low' },
|
|
121
|
+
}),
|
|
122
|
+
signal: controller.signal,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
127
|
+
console.error('[Summarize] zen API error:', response.status, errorBody);
|
|
128
|
+
return {
|
|
129
|
+
summary: sanitizeForTTS(text),
|
|
130
|
+
summarized: false,
|
|
131
|
+
reason: `zen API returned ${response.status}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const data = await response.json();
|
|
136
|
+
const summary = extractZenOutputText(data);
|
|
137
|
+
|
|
138
|
+
if (summary) {
|
|
139
|
+
const sanitized = sanitizeForTTS(summary);
|
|
140
|
+
return {
|
|
141
|
+
summary: sanitized,
|
|
142
|
+
summarized: true,
|
|
143
|
+
originalLength: text.length,
|
|
144
|
+
summaryLength: sanitized.length,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
summary: sanitizeForTTS(text),
|
|
150
|
+
summarized: false,
|
|
151
|
+
reason: 'No response from model',
|
|
152
|
+
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error.name === 'AbortError') {
|
|
155
|
+
console.error('[Summarize] Request timed out');
|
|
156
|
+
return {
|
|
157
|
+
summary: sanitizeForTTS(text),
|
|
158
|
+
summarized: false,
|
|
159
|
+
reason: 'Request timed out',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
console.error('[Summarize] Error:', error);
|
|
163
|
+
return {
|
|
164
|
+
summary: sanitizeForTTS(text),
|
|
165
|
+
summarized: false,
|
|
166
|
+
reason: error.message,
|
|
167
|
+
};
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TUNNEL_MODE_QUICK,
|
|
3
|
+
TUNNEL_PROVIDER_CLOUDFLARE,
|
|
4
|
+
TunnelServiceError,
|
|
5
|
+
normalizeTunnelStartRequest,
|
|
6
|
+
validateTunnelStartRequest,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
|
|
9
|
+
export function createTunnelService({
|
|
10
|
+
registry,
|
|
11
|
+
getController,
|
|
12
|
+
setController,
|
|
13
|
+
getActivePort,
|
|
14
|
+
onQuickTunnelWarning,
|
|
15
|
+
}) {
|
|
16
|
+
if (!registry) {
|
|
17
|
+
throw new Error('Tunnel service requires a provider registry');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const resolveActiveMode = () => {
|
|
21
|
+
const controller = getController();
|
|
22
|
+
if (!controller || typeof controller.mode !== 'string') {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return controller.mode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const resolveActiveProvider = () => {
|
|
29
|
+
const controller = getController();
|
|
30
|
+
if (!controller || typeof controller.provider !== 'string') {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return controller.provider;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const stop = () => {
|
|
37
|
+
const controller = getController();
|
|
38
|
+
if (!controller) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const providerId = typeof controller.provider === 'string' ? controller.provider : '';
|
|
43
|
+
const provider = providerId ? registry.get(providerId) : null;
|
|
44
|
+
if (provider?.stop) {
|
|
45
|
+
provider.stop(controller);
|
|
46
|
+
} else {
|
|
47
|
+
controller.stop?.();
|
|
48
|
+
}
|
|
49
|
+
setController(null);
|
|
50
|
+
return true;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const checkAvailability = async (providerId) => {
|
|
54
|
+
const provider = registry.get(providerId);
|
|
55
|
+
if (!provider) {
|
|
56
|
+
throw new TunnelServiceError('provider_unsupported', `Unsupported tunnel provider: ${providerId}`);
|
|
57
|
+
}
|
|
58
|
+
const result = await provider.checkAvailability();
|
|
59
|
+
return result;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Mutex to prevent concurrent tunnel starts from orphaning child processes.
|
|
63
|
+
let startLock = Promise.resolve();
|
|
64
|
+
|
|
65
|
+
const start = async (rawRequest, options = {}) => {
|
|
66
|
+
let releaseLock;
|
|
67
|
+
const lockPromise = new Promise((resolve) => { releaseLock = resolve; });
|
|
68
|
+
const previousLock = startLock;
|
|
69
|
+
startLock = lockPromise;
|
|
70
|
+
|
|
71
|
+
await previousLock;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const request = normalizeTunnelStartRequest(rawRequest);
|
|
75
|
+
const provider = registry.get(request.provider);
|
|
76
|
+
|
|
77
|
+
if (!provider) {
|
|
78
|
+
throw new TunnelServiceError('provider_unsupported', `Unsupported tunnel provider: ${request.provider}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
validateTunnelStartRequest(request, provider.capabilities);
|
|
82
|
+
|
|
83
|
+
let publicUrl = provider.resolvePublicUrl(getController());
|
|
84
|
+
const activeMode = resolveActiveMode();
|
|
85
|
+
|
|
86
|
+
if (publicUrl && activeMode !== request.mode) {
|
|
87
|
+
stop();
|
|
88
|
+
publicUrl = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!publicUrl) {
|
|
92
|
+
const availability = await provider.checkAvailability();
|
|
93
|
+
if (!availability?.available) {
|
|
94
|
+
const missingDependencyMessage = typeof availability?.message === 'string' && availability.message.trim().length > 0
|
|
95
|
+
? availability.message
|
|
96
|
+
: (request.provider === TUNNEL_PROVIDER_CLOUDFLARE
|
|
97
|
+
? 'cloudflared is not installed. Install it with: brew install cloudflared'
|
|
98
|
+
: `Required dependency for provider '${request.provider}' is missing`);
|
|
99
|
+
throw new TunnelServiceError('missing_dependency', missingDependencyMessage);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const activePort = Number.isFinite(getActivePort?.()) ? getActivePort() : null;
|
|
103
|
+
const originUrl = activePort !== null ? `http://127.0.0.1:${activePort}` : undefined;
|
|
104
|
+
|
|
105
|
+
const controller = await provider.start(request, {
|
|
106
|
+
activePort,
|
|
107
|
+
originUrl,
|
|
108
|
+
...options,
|
|
109
|
+
});
|
|
110
|
+
controller.provider = request.provider;
|
|
111
|
+
setController(controller);
|
|
112
|
+
|
|
113
|
+
publicUrl = provider.resolvePublicUrl(controller);
|
|
114
|
+
if (!publicUrl) {
|
|
115
|
+
stop();
|
|
116
|
+
throw new TunnelServiceError('startup_failed', 'Tunnel started but no public URL was assigned');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (request.mode === TUNNEL_MODE_QUICK) {
|
|
120
|
+
onQuickTunnelWarning?.();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
publicUrl,
|
|
126
|
+
request,
|
|
127
|
+
activeMode: request.mode,
|
|
128
|
+
provider: request.provider,
|
|
129
|
+
providerMetadata: provider.getMetadata?.(getController()) ?? null,
|
|
130
|
+
};
|
|
131
|
+
} finally {
|
|
132
|
+
releaseLock();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const getPublicUrl = () => {
|
|
137
|
+
const controller = getController();
|
|
138
|
+
if (!controller) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const provider = registry.get(controller.provider);
|
|
142
|
+
if (!provider) {
|
|
143
|
+
return controller.getPublicUrl?.() ?? null;
|
|
144
|
+
}
|
|
145
|
+
return provider.resolvePublicUrl(controller);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const getProviderMetadata = () => {
|
|
149
|
+
const controller = getController();
|
|
150
|
+
if (!controller) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const provider = registry.get(controller.provider);
|
|
154
|
+
return provider?.getMetadata?.(controller) ?? null;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
start,
|
|
159
|
+
stop,
|
|
160
|
+
checkAvailability,
|
|
161
|
+
getPublicUrl,
|
|
162
|
+
getProviderMetadata,
|
|
163
|
+
resolveActiveMode,
|
|
164
|
+
resolveActiveProvider,
|
|
165
|
+
};
|
|
166
|
+
}
|