Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ Or with pip:
pip install piper-tts
```

The `piper` executable must be located at `~/.local/bin/piper`. If installed in a different location, you can symlink it there instead:

```bash
ln -s $(which piper) ~/.local/bin/piper
```

Download a voice model to `~/.local/share/piper-voices/`:

```bash
Expand Down Expand Up @@ -132,6 +138,13 @@ For unauthenticated local endpoints (e.g. Ollama):
- `chatTemplateKwargs` _(optional)_ - extra keyword arguments passed to the model's chat template (e.g. `{"enable_thinking": false}` for Qwen models to disable chain-of-thought)
- `retries` _(optional)_ - number of retry attempts for transient LLM failures

### Logging

The plugin writes diagnostics through OpenCode's structured app logger. If this plugin is not working with your setup, check the OpenCode log file and, optionally, enable debug mode. See the [OpenCode Docs](https://opencode.ai/docs/troubleshooting/#logs) for details.

Routine plugin diagnostics use `debug`; recoverable issues use `warn`; failed
child processes, API calls, or unexpected exceptions use `error`.

### STT API transcription (optional)

Instead of local `whisper-cli`, you can use an OpenAI-compatible speech-to-text
Expand Down
28 changes: 19 additions & 9 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,21 @@ import os from "node:os";
import { registerSTT } from "./lib/stt.js";
import { registerTTS } from "./lib/tts.js";
import { createClient } from "./lib/llm-client.js";
import { createLogger } from "./lib/logger.js";

function loadPromptFile(filePath) {
function loadPromptFile(filePath, logger, name) {
if (!filePath) return null;
const resolved = filePath.replace(/^~(?=\/|$)/, os.homedir());
try {
return fs.readFileSync(resolved, "utf-8").trim() || null;
} catch {
const prompt = fs.readFileSync(resolved, "utf-8").trim() || null;
logger?.log(
"plugin",
prompt ? `Loaded ${name} prompt: ${resolved}` : `Ignored empty ${name} prompt: ${resolved}`,
"debug",
);
return prompt;
} catch (err) {
logger?.log("Plugin", `Failed to load ${name} prompt ${resolved}: ${err.message}`, "warn");
return null;
}
}
Expand All @@ -46,16 +54,18 @@ export default {
id: "opencode-voice",
tui: async (api, options) => {
const { kv } = api;
const { complete } = createClient(options);
const logger = createLogger(api.client);
logger.log("plugin", "Initializing", "debug");
const { complete } = createClient(options, logger);

const prompts = {
stt: loadPromptFile(options?.sttPrompt),
ttsAuto: loadPromptFile(options?.ttsAutoPrompt),
ttsManual: loadPromptFile(options?.ttsManualPrompt),
stt: loadPromptFile(options?.sttPrompt, logger, "STT"),
ttsAuto: loadPromptFile(options?.ttsAutoPrompt, logger, "TTS auto"),
ttsManual: loadPromptFile(options?.ttsManualPrompt, logger, "TTS manual"),
};

const sttCommands = registerSTT(api, kv, complete, prompts, options);
const ttsCommands = registerTTS(api, kv, complete, prompts);
const sttCommands = registerSTT(api, kv, complete, prompts, options, logger);
const ttsCommands = registerTTS(api, kv, complete, prompts, logger);

api.command.register(() => [...sttCommands, ...ttsCommands]);
},
Expand Down
31 changes: 27 additions & 4 deletions lib/llm-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ function wait(ms) {
* Create an LLM completion function.
*
* @param {object} [pluginOptions] - Static config from tui.json plugin options
* @param {{ log?: (scope: string, message: string, level?: string) => void }} [logger]
* @returns {{ complete: (opts: { system?: string, prompt: string, config?: object }) => Promise<{ text: string | null, error?: string }> }}
*/
export function createClient(pluginOptions) {
export function createClient(pluginOptions, logger) {
function getConfig() {
return {
endpoint: pluginOptions?.endpoint,
Expand All @@ -80,8 +81,14 @@ export function createClient(pluginOptions) {
*/
async function complete({ system, prompt, config: overrides }) {
const cfg = { ...getConfig(), ...overrides };
if (!cfg.endpoint) return { text: null, error: "LLM endpoint not configured" };
if (!cfg.model) return { text: null, error: "LLM model not configured" };
if (!cfg.endpoint) {
logger?.log?.("LLM", "completion skipped: endpoint not configured", "warn");
return { text: null, error: "LLM endpoint not configured" };
}
if (!cfg.model) {
logger?.log?.("LLM", "completion skipped: model not configured", "warn");
return { text: null, error: "LLM model not configured" };
}
const apiKey = cfg.apiKeyEnv ? process.env[cfg.apiKeyEnv] : null;

const endpoint = cfg.endpoint.replace(/\/+$/, "") + "/chat/completions";
Expand All @@ -100,6 +107,11 @@ export function createClient(pluginOptions) {

for (let attempt = 0; attempt <= cfg.retries; attempt++) {
try {
logger?.log?.(
"LLM",
`Completion request attempt=${attempt + 1} model=${cfg.model} maxTokens=${cfg.maxTokens} promptChars=${prompt.length}`,
"debug",
);
const response = await fetch(endpoint, {
method: "POST",
headers: {
Expand All @@ -110,6 +122,11 @@ export function createClient(pluginOptions) {
});

if (!response.ok) {
logger?.log?.(
"LLM",
`Completion response status=${response.status}`,
shouldRetry(response.status) ? "warn" : "error",
);
if (attempt < cfg.retries && shouldRetry(response.status)) {
await wait(250 * 2 ** attempt);
continue;
Expand All @@ -119,14 +136,20 @@ export function createClient(pluginOptions) {

const data = await response.json();
const text = data?.choices?.[0]?.message?.content || null;
if (text) return { text };
if (text) {
logger?.log?.("LLM", `Completion succeeded chars=${text.length}`, "debug");
return { text };
}

logger?.log?.("LLM", "Completion returned empty content", "warn");

if (attempt < cfg.retries) {
await wait(250 * 2 ** attempt);
continue;
}
return { text: null, error: "Empty LLM response" };
} catch (err) {
logger?.log?.("LLM", `Completion error attempt=${attempt + 1}: ${err.message}`, "warn");
if (attempt < cfg.retries) {
await wait(250 * 2 ** attempt);
continue;
Expand Down
18 changes: 18 additions & 0 deletions lib/logger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function createLogger(client) {
async function log(scope, message, level = "debug") {
try {
await client?.app?.log?.({
body: {
service: "opencode-voice",
level,
message,
extra: { scope },
},
});
} catch {
// Logging should never interrupt voice features.
}
}

return { log };
}
Loading
Loading