Skip to content

Commit f0d36bd

Browse files
committed
feat(tools): transcribe — native audio transcription via whisper.cpp
transcribe tool (cmd/odek/transcribe_tool.go): - Takes audio file path, returns structured {text, duration_sec, segments, model, language} - Calls whisper CLI as subprocess with --output-json flag - Parses whisper's JSON output into typed Go structs - Validates binary on PATH (whisper or whisper-cli) with clear setup instructions when missing - Validates model file at ~/.odek/whisper/models/ggml-<model>.bin with clear download instructions when missing - NO implicit downloads — errors tell the user exactly what to do - Same security as all tools: danger.ClassifyPath + O_NOFOLLOW - Registered in builtinTools() + classifyToolCall() + recover() Config (internal/config/loader.go): - New TranscriptionConfig type: Model, Language, AutoTranscribe, ModelsDir, BinaryPath - Added to FileConfig JSON schema (transcription section in odek.json) - Added to ResolvedConfig with resolveTranscription defaults - Default: model=tiny, auto_transcribe=true Telegram auto-transcribe (cmd/odek/telegram.go): - OnVoiceMessage now auto-transcribes when AutoTranscribe=true - Transcribed text injected directly as user message (seamless) - Falls back to sending the file path if transcription fails - No more 'use shell tools to transcribe' — it just works Tests: 7 tests covering missing binary, missing model, file not found, empty path, invalid JSON, symlink rejection, and full mock whisper happy path with parsed JSON response. 0 new dependencies.
1 parent eec4b62 commit f0d36bd

12 files changed

Lines changed: 590 additions & 15 deletions

File tree

cmd/odek/main.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,7 +1002,7 @@ func run(args []string) error {
10021002

10031003
// Sandbox setup
10041004
var sandboxCleanup func() error
1005-
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency)
1005+
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.Transcription)
10061006

10071007
// MCP server tools
10081008
var mcpCleanup func()
@@ -1382,7 +1382,7 @@ func injectFilesToSandbox(containerName string, files []string, cwd string) (int
13821382
return injected, nil
13831383
}
13841384

1385-
func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver danger.Approver, maxConcurrency int) []odek.Tool {
1385+
func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver danger.Approver, maxConcurrency int, tc config.TranscriptionConfig) []odek.Tool {
13861386
tools := []odek.Tool{
13871387
&shellTool{
13881388
dangerousConfig: dc,
@@ -1415,6 +1415,7 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
14151415
&base64Tool{dangerousConfig: dc},
14161416
&trTool{dangerousConfig: dc},
14171417
&wordCountTool{dangerousConfig: dc},
1418+
newTranscribeTool(dc, tc),
14181419
newBrowserTool(dc),
14191420
}
14201421

@@ -1959,7 +1960,7 @@ func continueCmd(args []string) error {
19591960
"./.odek/skills",
19601961
)
19611962
}
1962-
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency)
1963+
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.Transcription)
19631964
var sandboxCleanup func() error
19641965

19651966
// MCP server tools

cmd/odek/main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ func TestRun_NoAPIKey(t *testing.T) {
201201
}
202202

203203
func TestBuiltinTools(t *testing.T) {
204-
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3)
204+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3, config.TranscriptionConfig{})
205205
if len(tools) == 0 {
206206
t.Fatal("builtinTools() returned empty slice")
207207
}

cmd/odek/mcp.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Flags:
7373
}
7474

7575
// Build tools
76-
toolSet := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency)
76+
toolSet := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, config.TranscriptionConfig{})
7777

7878
// MCP server tools — connect and discover before sandbox
7979
var mcpCleanup func()

cmd/odek/repl.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func replCmd(args []string) error {
7979
"./.odek/skills",
8080
)
8181
}
82-
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency)
82+
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, config.TranscriptionConfig{})
8383
var sandboxCleanup func() error
8484

8585
// MCP server tools

cmd/odek/serve.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
187187
approver := newWSApprover(sendFn)
188188
resolved.Dangerous.Approver = approver
189189

190-
tools := builtinTools(resolved.Dangerous, sm, approver, resolved.MaxConcurrency)
190+
tools := builtinTools(resolved.Dangerous, sm, approver, resolved.MaxConcurrency, config.TranscriptionConfig{})
191191

192192
// Find the delegateTasksTool to wire up sub-agent log streaming
193193
var subagentTool *delegateTasksTool

cmd/odek/subagent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ func subagentCmd(args []string) error {
307307
"./.odek/skills",
308308
)
309309
}
310-
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency)
310+
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, config.TranscriptionConfig{})
311311
var sandboxCleanup func() error
312312

313313
// MCP server tools

cmd/odek/subagent_contract_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"time"
1313

1414
"github.com/BackendStack21/kode"
15+
"github.com/BackendStack21/kode/internal/config"
1516
"github.com/BackendStack21/kode/internal/danger"
1617
"github.com/BackendStack21/kode/internal/llm"
1718
)
@@ -319,7 +320,7 @@ func TestSubagent_ExitCodeThree(t *testing.T) {
319320
// ── 4. delegate_tasks Tool Schema ───────────────────────────────────
320321

321322
func TestDelegateTasksTool_Exists(t *testing.T) {
322-
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3)
323+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3, config.TranscriptionConfig{})
323324

324325
found := false
325326
for _, tool := range tools {
@@ -334,7 +335,7 @@ func TestDelegateTasksTool_Exists(t *testing.T) {
334335
}
335336

336337
func TestDelegateTasksTool_HasSchema(t *testing.T) {
337-
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3)
338+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3, config.TranscriptionConfig{})
338339

339340
var tool odek.Tool
340341
for _, t2 := range tools {
@@ -423,7 +424,7 @@ func TestDelegateTasksTool_HasSchema(t *testing.T) {
423424
}
424425

425426
func TestDelegateTasksTool_Description(t *testing.T) {
426-
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3)
427+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3, config.TranscriptionConfig{})
427428

428429
var tool odek.Tool
429430
for _, t2 := range tools {

cmd/odek/telegram.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ func telegramCmd(args []string) error {
452452
}
453453

454454
handler.OnVoiceMessage = func(chatID int64, messageID int, fileID string) (string, error) {
455-
// Download the voice file so the agent can transcribe it.
455+
// Download the voice file.
456456
localPath, err := telegram.DownloadVoice(bot, fileID)
457457
if err != nil {
458458
handlerLog.Warn("voice download failed", "chat_id", chatID, "error", err)
@@ -461,8 +461,30 @@ func telegramCmd(args []string) error {
461461
bot, handler, sessionManager, resolved, systemMessage, handlerLog)
462462
return "", nil
463463
}
464+
465+
// Auto-transcribe if configured and whisper is available.
466+
if resolved.Transcription.AutoTranscribe {
467+
tool := newTranscribeTool(resolved.Dangerous, resolved.Transcription)
468+
result, err := tool.Call(fmt.Sprintf(`{"path":"%s"}`, localPath))
469+
if err == nil {
470+
var r struct {
471+
Text string `json:"text"`
472+
Error string `json:"error"`
473+
}
474+
if json.Unmarshal([]byte(result), &r) == nil && r.Error == "" && r.Text != "" {
475+
// Transcribed text injected directly as user message
476+
go handleChatMessage(chatID, messageID, r.Text,
477+
bot, handler, sessionManager, resolved, systemMessage, handlerLog)
478+
return "", nil
479+
}
480+
}
481+
// Transcription failed — fall through to file path message
482+
handlerLog.Warn("auto-transcribe failed, falling back to path", "chat_id", chatID, "error", err)
483+
}
484+
485+
// Fallback: pass the file path to the agent
464486
go handleChatMessage(chatID, messageID,
465-
fmt.Sprintf("🎤 Voice message received and saved to %q. Use shell tools (ffmpeg, whisper) to transcribe and respond.", localPath),
487+
fmt.Sprintf("🎤 Voice message saved to %q. Use transcribe() tool to get the text.", localPath),
466488
bot, handler, sessionManager, resolved, systemMessage, handlerLog)
467489
return "", nil
468490
}
@@ -943,7 +965,7 @@ func handleChatMessage(
943965
cs.LastActive = time.Now()
944966

945967
// Build the agent with Telegram approver.
946-
tools := builtinTools(resolved.Dangerous, nil, approver, resolved.MaxConcurrency)
968+
tools := builtinTools(resolved.Dangerous, nil, approver, resolved.MaxConcurrency, config.TranscriptionConfig{})
947969

948970
modelLabel := odek.ProfileLabel(resolved.Model)
949971
if modelLabel == "" {

0 commit comments

Comments
 (0)