From b8292e14a1901e216e164a8a134f8d31ee5c99d4 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sat, 14 Mar 2026 01:40:35 +0900 Subject: [PATCH] feat: shared transcription cache to avoid duplicate Whisper API calls Both services (nanoclaw + nanoclaw-codex) now share cache/transcriptions/ directory. When one service transcribes audio, the other reads from cache instead of making a second Whisper API call. Uses .pending file to coordinate concurrent transcription attempts. --- src/channels/discord.test.ts | 1 + src/channels/discord.ts | 67 ++++++++++++++++++++++++++++++------ src/config.ts | 2 ++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index ef2249a..dc84cb3 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -13,6 +13,7 @@ vi.mock('../config.js', () => ({ ASSISTANT_NAME: 'Andy', TRIGGER_PATTERN: /^@Andy\b/i, DATA_DIR: '/tmp/nanoclaw-test-data', + CACHE_DIR: '/tmp/nanoclaw-test-cache', })); // Mock logger diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 56bb619..b24339e 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -10,11 +10,12 @@ import { TextChannel, } from 'discord.js'; -import { ASSISTANT_NAME, DATA_DIR, TRIGGER_PATTERN } from '../config.js'; +import { ASSISTANT_NAME, CACHE_DIR, DATA_DIR, TRIGGER_PATTERN } from '../config.js'; import { readEnvFile } from '../env.js'; import { logger } from '../logger.js'; const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); +const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions'); /** * Download a Discord image attachment to local disk. @@ -34,15 +35,54 @@ async function downloadImage(att: Attachment): Promise { return filePath; } +/** + * Wait for a pending transcription from the other service (poll cache file). + * Returns the cached text, or null if timeout. + */ +async function waitForPendingTranscription( + cacheFile: string, + timeoutMs = 15000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + await new Promise((r) => setTimeout(r, 300)); + if (fs.existsSync(cacheFile)) { + return fs.readFileSync(cacheFile, 'utf-8'); + } + } + return null; +} + /** * Transcribe an audio attachment via OpenAI Whisper API. - * Returns the transcribed text, or a fallback placeholder on failure. + * Uses shared file cache so both services don't duplicate API calls. */ async function transcribeAudio( att: Attachment, apiKey: string, ): Promise { + fs.mkdirSync(TRANSCRIPTION_CACHE_DIR, { recursive: true }); + const cacheFile = path.join(TRANSCRIPTION_CACHE_DIR, `${att.id}.txt`); + const pendingFile = path.join(TRANSCRIPTION_CACHE_DIR, `${att.id}.pending`); + + // Check cache first + if (fs.existsSync(cacheFile)) { + logger.info({ attId: att.id }, 'Transcription cache hit'); + return fs.readFileSync(cacheFile, 'utf-8'); + } + + // Another service is already transcribing — wait for result + if (fs.existsSync(pendingFile)) { + logger.info({ attId: att.id }, 'Waiting for pending transcription'); + const cached = await waitForPendingTranscription(cacheFile); + if (cached) return cached; + // Timeout — fall through and transcribe ourselves + } + try { + // Mark as pending + fs.writeFileSync(pendingFile, process.pid.toString()); + const res = await fetch(att.url); if (!res.ok) throw new Error(`Download failed: ${res.status}`); const buffer = Buffer.from(await res.arrayBuffer()); @@ -65,14 +105,25 @@ async function transcribeAudio( throw new Error(`Whisper API ${whisperRes.status}: ${errText}`); } const data = (await whisperRes.json()) as { text: string }; + const result = `[Voice message transcription]: ${data.text}`; + + // Save to cache for the other service + fs.writeFileSync(cacheFile, result); logger.info( { file: filename, length: data.text.length }, - 'Audio transcribed', + 'Audio transcribed + cached', ); - return `[Voice message transcription]: ${data.text}`; + return result; } catch (err) { logger.error({ err, file: att.name }, 'Audio transcription failed'); return `[Audio: ${att.name || 'audio'} (transcription failed)]`; + } finally { + // Clean up pending marker + try { + fs.unlinkSync(pendingFile); + } catch { + /* ignore */ + } } } import { registerChannel, ChannelOpts } from './registry.js'; @@ -511,12 +562,8 @@ export class DiscordChannel implements Channel { // Separate into bulk-deletable (< 14 days) and old messages const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000; - const recent = messages.filter( - (m) => m.createdTimestamp > twoWeeksAgo, - ); - const old = messages.filter( - (m) => m.createdTimestamp <= twoWeeksAgo, - ); + const recent = messages.filter((m) => m.createdTimestamp > twoWeeksAgo); + const old = messages.filter((m) => m.createdTimestamp <= twoWeeksAgo); if (recent.size >= 2) { await tc.bulkDelete(recent); diff --git a/src/config.ts b/src/config.ts index 338dcaf..a3ae02e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,6 +31,8 @@ export const GROUPS_DIR = path.resolve( export const DATA_DIR = path.resolve( process.env.NANOCLAW_DATA_DIR || path.join(PROJECT_ROOT, 'data'), ); +// Shared cache directory (same across both services for dedup) +export const CACHE_DIR = path.join(PROJECT_ROOT, 'cache'); export const AGENT_TIMEOUT = parseInt( process.env.AGENT_TIMEOUT || '1800000',