'use strict';// lilt: Electron main process. Pure text helpers come first and are// exported so check.js can test them under plain Node. const fs = require('fs');const path = require('path');const os = require('os');const http = require('http');const https = require('https');const net = require('net');const { spawn, execFile } = require('child_process'); // ---------------------------------------------------------------- pure helpers const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');// Spaces in a phrase match any run of whitespace or light punctuation.const phrase = s => esc(s.trim()).replace(/\s+/g, '[\\s,.-]+'); function countWords(text) { return (text || '').trim().split(/\s+/).filter(Boolean).length;} function wpm(words, ms) { return ms > 0 ? Math.round(words / (ms / 60000)) : 0;} // Dictionary entries with `to` are spoken-form replacements ("btw" -> "by the way").function applyDictionary(text, dict) { for (const d of dict || []) { if (!d.to || !d.word || !d.word.trim()) continue; text = text.replace(new RegExp(`(?<![\\p{L}\\p{N}])${phrase(d.word)}(?![\\p{L}\\p{N}])`, 'giu'), d.to); } return text;} // Replaces spoken snippet triggers with placeholder tokens so the LLM cannot// rewrite them; `restore` puts the snippet text back afterwards.function expandSnippets(text, snippets) { const slots = []; for (const s of snippets || []) { if (!s.trigger || !s.trigger.trim()) continue; const re = new RegExp(`(?<![\\p{L}\\p{N}])${phrase(s.trigger)}[.!?,]?(?![\\p{L}\\p{N}])`, 'giu'); text = text.replace(re, () => { slots.push(s.text); return `[[S${slots.length - 1}]]`; }); } return { text, slots };} function restoreSnippets(text, slots) { return text.replace(/\[\[S(\d+)\]\]/g, (_, i) => slots[+i] ?? '');} function appCategory(cls = '', title = '') { const c = cls.toLowerCase(), t = title.toLowerCase(); if (/signal|telegram|discord|whatsapp|element|messages|vesktop|webcord|beeper/.test(c)) return 'personal'; if (/slack|teams|zulip|mattermost/.test(c)) return 'work'; if (/thunderbird|geary|evolution|mutt|mailspring|betterbird/.test(c) || /gmail|outlook|proton mail|inbox/.test(t)) return 'email'; return 'other';} const TERMINALS = ['st', 'st-256color', 'kitty', 'alacritty', 'foot', 'xterm', 'urxvt', 'wezterm', 'ghostty', 'konsole', 'gnome-terminal', 'gnome-terminal-server', 'xfce4-terminal', 'tilix', 'terminator'];function isTerminal(cls = '') { const c = cls.toLowerCase(); return TERMINALS.includes(c) || /terminal|term$/.test(c);} const KEYS = { 9: 'Escape', 37: 'Left Ctrl', 105: 'Right Ctrl', 64: 'Left Alt', 108: 'Right Alt', 50: 'Left Shift', 62: 'Right Shift', 133: 'Left Super', 134: 'Right Super', 66: 'Caps Lock', 135: 'Menu', 65: 'Space', 95: 'F11', 96: 'F12', 118: 'Insert', 127: 'Pause', 78: 'Scroll Lock' };function keyLabel(code) { if (KEYS[code]) return KEYS[code]; if (code >= 67 && code <= 76) return 'F' + (code - 66); return 'Key ' + code;} // 16-bit mono PCM WAV from Float32 samples.function wavBuffer(f32, rate = 16000) { const b = Buffer.alloc(44 + f32.length * 2); b.write('RIFF', 0); b.writeUInt32LE(36 + f32.length * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22); b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(f32.length * 2, 40); for (let i = 0; i < f32.length; i++) b.writeInt16LE(Math.round(Math.max(-1, Math.min(1, f32[i])) * 32767), 44 + i * 2); return b;} // Whisper's markers for silence or noise, e.g. "[BLANK_AUDIO]" or "(music)".function stripMarkers(text) { return (text || '').replace(/\[[^\]]*\]|\([^)]*(music|silence|blank|noise|inaudible)[^)]*\)/gi, '').replace(/\s+/g, ' ').trim();} const STYLE = { formal: 'Use proper capitalization and full punctuation.', casual: 'Use normal capitalization but lighter punctuation, like a relaxed message; drop the final period.', veryCasual: 'Write in all lowercase with minimal punctuation, like a quick text.',};const EDIT = { light: 'Clean it up: remove filler words (um, uh, like, you know), stutters and repeated words, apply self-corrections ("no wait, I mean X" keeps only X), fix punctuation, capitalization and obvious grammar. Keep every other word as spoken.', medium: 'Rewrite it for clarity and concision: remove filler words, false starts and repetition, apply self-corrections ("no wait, I mean X" keeps only X), tighten wordy phrasing, fix grammar. Keep the speaker\'s meaning, point of view, every fact and name.',};function cleanupPrompt(level, style, category) { return 'You are a dictation editor, not an assistant. You receive a transcript of something the user said, inside <dictation> tags, that they want typed into ' + (category === 'other' ? 'an app' : `a ${category} app`) + `. ${EDIT[level] || EDIT.light} ${STYLE[style] || STYLE.formal} ` + 'If it is a question or a command, it stays a question or a command addressed to the same person. Never answer it, never add anything. ' + 'Tokens like [[S0]] are placeholders: keep them exactly as written. Output only the edited text, no tags.';}// Few-shot turns that pin the model to editing, shaped to the chosen style.const SHOTS = [ ['um can you uh send me the the report by friday', 'Can you send me the report by Friday?'], ['find me a good restaurant near the office', 'Find me a good restaurant near the office.'], ['so we meet tuesday no wait wednesday at like 3', 'So we meet Wednesday at 3.'],];function cleanupMessages(level, style, category, text) { const shape = o => style === 'veryCasual' ? o.toLowerCase().replace(/\.$/, '') : style === 'casual' ? o.replace(/\.$/, '') : o; const m = [{ role: 'system', content: cleanupPrompt(level, style, category) }]; for (const [a, b] of SHOTS) m.push({ role: 'user', content: `<dictation>${a}</dictation>` }, { role: 'assistant', content: shape(b) }); m.push({ role: 'user', content: `<dictation>${text}</dictation>` }); return m;} // Share of words in `out` that never appeared in `src`. Cleanup mostly deletes,// so a high value means the model answered or rewrote instead of editing.function drift(src, out) { const w = t => t.toLowerCase().replace(/[^\p{L}\p{N}\s\[\]']/gu, ' ').split(/\s+/).filter(Boolean); const have = new Set(w(src)), words = w(out); return words.length ? words.filter(x => !have.has(x)).length / words.length : 0;} // What the assistant agent receives for one spoken (or typed) command.function assistantPrompt(text, win = {}, system = '') { const w = win.cls ? `\n\nFocused window: ${win.cls}${win.title ? ` "${win.title}"` : ''}` : ''; const sys = system.trim() ? `Kris's standing instructions:\n${system.trim()}\n\n` : ''; return `First read ~/.config/agents/AGENTS.md and follow it; the other files in ~/.config/agents are yours to use.\n\n${sys}` + `Voice command from Kris (speech to text, may contain recognition errors): ${text}${w}`;} // grok's streaming JSON: the answer is the last {"type":"result"} line. Plain text passes through.function cliResult(out) { for (const l of out.trim().split('\n').reverse()) { try { const j = JSON.parse(l); if (j && j.type === 'result') return String(j.result || ''); } catch {} } return out;} const P = { assistantPrompt, cliResult, countWords, wpm, applyDictionary, expandSnippets, restoreSnippets, appCategory, isTerminal, keyLabel, wavBuffer, stripMarkers, cleanupPrompt, cleanupMessages, drift };module.exports = P;if (process.type !== 'browser') return; // plain Node (check.js): stop here // ---------------------------------------------------------------- electron app const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, screen, clipboard, shell, session, globalShortcut, nativeTheme } = require('electron'); const VERSION = require('./package.json').version;const HOME = os.homedir();const DATA = path.join(HOME, '.local/share/lilt');// The app was called Flow at first; carry its data folder over once.const OLD_DATA = path.join(HOME, '.local/share/flow');if (!fs.existsSync(DATA) && fs.existsSync(OLD_DATA)) fs.renameSync(OLD_DATA, DATA);const MODELS = path.join(DATA, 'models');const AUDIO = path.join(DATA, 'audio');const DB = path.join(DATA, 'db.json');const VAD = path.join(MODELS, 'ggml-silero-v5.1.2.bin');const MCP_PORT = 7865;const OLLAMA = 'http://127.0.0.1:11434';const CLI = process.argv.indexOf('--transcribe');const ASK = process.argv.indexOf('--ask');const HF = 'https://huggingface.co'; const CATALOG = [ { id: 'large-v3-turbo', name: 'Whisper Ultra Turbo V3', engine: 'whisper', file: 'ggml-large-v3-turbo.bin', size: '1.6 GB', langs: '100+', url: `${HF}/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin` }, { id: 'large-v3', name: 'Whisper Ultra V3', engine: 'whisper', file: 'ggml-large-v3.bin', size: '3.1 GB', langs: '100+', url: `${HF}/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin` }, { id: 'parakeet-v3', name: 'Parakeet V3 (NVIDIA)', engine: 'parakeet', file: 'ggml-parakeet-tdt-0.6b-v3-f16.bin', size: '1.3 GB', langs: '25', url: `${HF}/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-f16.bin` }, { id: 'small', name: 'Whisper Standard', engine: 'whisper', file: 'ggml-small.bin', size: '488 MB', langs: '100+', url: `${HF}/ggerganov/whisper.cpp/resolve/main/ggml-small.bin` },]; const DEFAULTS = { theme: 'system', firstName: (n => n.charAt(0).toUpperCase() + n.slice(1))(os.userInfo().username), lastName: '', email: '', hotkey: { keycode: 108, label: 'Right Alt' }, micId: '', micLabel: '', language: 'en', model: 'large-v3-turbo', llm: 'ollama', llmModel: 'qwen3.5:4b', cleanup: 'light', styles: { personal: 'casual', work: 'formal', email: 'formal', other: 'casual' }, transformsOptIn: true, autoTransform: { enabled: false, id: 'polish' }, launchAtLogin: false, showBarAlways: true, showInDock: true, sounds: true, muteWhileDictating: false, scratchpadInBar: false, privacy: { contextAwareness: true, storeAudio: true }, notetaker: { notifyBefore: 15, showNextMeeting: true, autoDetect: true, autoStart: false, maxMinutes: 120, stopOnCallEnd: true, hideFromCapture: true, notepadView: 'open', liveTranscript: true, calendarNotice: 'off', consentNotice: false, linkSharing: 'anyone', autoShareWho: 'no one', autoShareFor: 'all meetings' }, vibe: { variableRecognition: false, fileTagging: true }, assistant: { enabled: true, model: 'claude-sonnet', hotkey: { keycode: 50, label: 'Left Shift' }, systemPrompt: '' }, dismissed: {},}; const newId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6); function seed() { const t = Date.now(); const d = (word, extra) => ({ id: newId(), word, ts: t, ...extra }); return { settings: structuredClone(DEFAULTS), history: [], dictionary: [d('btw', { to: 'by the way' })], snippets: [ { id: newId(), trigger: 'organize thoughts prompt', text: 'Organize these unstructured thoughts into a clear, polished version without changing the meaning.', ts: t }, ], transforms: [ { id: 'polish', name: 'Polish', desc: 'Improve clarity and conciseness', key: '1', prompt: 'Rewrite the text to be clearer and more concise. Fix grammar and flow, keep the meaning, tone and any names or numbers. Output only the rewritten text.' }, { id: 'prompt-engineer', name: 'Prompt Engineer', desc: 'Constructs optimal prompts', key: '2', prompt: 'Turn the text into a well-structured prompt for an AI assistant: state the goal, the relevant context, constraints and the expected output format. Keep every detail the user gave. Output only the prompt.' }, ], notes: [], meetings: [], commands: [], };} // Fills in settings keys added after the db was created, without touching user values.function merge(def, cur) { if (!cur || typeof cur !== 'object' || Array.isArray(cur)) return structuredClone(def); for (const k of Object.keys(def)) { const dv = def[k]; if (dv && typeof dv === 'object' && !Array.isArray(dv)) cur[k] = merge(dv, cur[k]); else if (!(k in cur)) cur[k] = dv; } return cur;} let db;function load() { fs.mkdirSync(AUDIO, { recursive: true }); fs.mkdirSync(MODELS, { recursive: true }); if (fs.existsSync(DB)) { try { db = JSON.parse(fs.readFileSync(DB, 'utf8')); } catch { fs.renameSync(DB, `${DB}.bad-${Date.now()}`); } // keep a corrupt file instead of overwriting it } if (!db) db = seed(); const s = seed(); for (const k of ['history', 'dictionary', 'snippets', 'transforms', 'notes', 'meetings', 'commands']) if (!Array.isArray(db[k])) db[k] = s[k]; db.settings = merge(DEFAULTS, db.settings); // commands from before the log file existed, oldest first if (CLI < 0 && db.commands.length && !fs.existsSync(ASSIST_LOG)) fs.writeFileSync(ASSIST_LOG, db.commands.filter(c => c.reply).reverse().map(c => JSON.stringify(c) + '\n').join(''));} let saveTimer = null;function writeNow() { clearTimeout(saveTimer); saveTimer = null; if (CLI > -1) return; // the --transcribe test mode never writes fs.writeFileSync(DB + '.tmp', JSON.stringify(db)); fs.renameSync(DB + '.tmp', DB);}function save() { clearTimeout(saveTimer); saveTimer = setTimeout(writeNow, 300); } // ---------------------------------------------------------------- runtime + push const rt = { version: VERSION, dataDir: DATA, mcpUrl: `http://127.0.0.1:${MCP_PORT}/mcp`, engine: 'off', engineError: '', llm: 'off', recording: false, recordingKind: 'dictate', processing: false, notetaker: false, assistant: false };const progress = {}; // model id -> 0..1 while downloading function models() { return CATALOG.map(m => ({ id: m.id, name: m.name, engine: m.engine, size: m.size, langs: m.langs, installed: fs.existsSync(path.join(MODELS, m.file)), progress: progress[m.id] }));}function runtime() { return { ...rt, hotkeyLabel: db.settings.hotkey.label, models: models(), assistantDir: ASSIST_DIR, assistantLog: ASSIST_LOG, assistantDirOk: fs.existsSync(path.join(ASSIST_DIR, 'AGENTS.md')), assistantModels: ASSISTANTS.map(({ id, name, cli, desc }) => ({ id, name, cli, desc, available: !!installed[cli] })) };}function state() { return { ...db, runtime: runtime() }; } let hub = null, bar = null, tray = null, trayKey = '', quitting = false, pushTimer = null;function push() { clearTimeout(pushTimer); pushTimer = setTimeout(() => { if (hub && !hub.isDestroyed() && !hub.webContents.isCrashed()) hub.webContents.send('state', state()); barSend({ type: 'state', ...runtime(), settings: { scratchpadInBar: db.settings.scratchpadInBar, sounds: db.settings.sounds, showBarAlways: db.settings.showBarAlways } }); updateBarVisibility(); const key = `${!!rec}${!!nt}${!!assist}`; if (tray && key !== trayKey) { trayKey = key; tray.setContextMenu(trayMenu()); } }, 50);}const changed = () => { save(); push(); };const barSend = msg => { if (bar && !bar.isDestroyed() && !bar.webContents.isCrashed()) bar.webContents.send('bar', msg); }; // ---------------------------------------------------------------- process helpers function run(cmd, args, opts = {}) { return new Promise(resolve => { execFile(cmd, args, { timeout: 15000, maxBuffer: 64 << 20, ...opts }, (err, stdout, stderr) => resolve({ ok: !err, out: (stdout || '').toString(), err: (stderr || (err && err.message) || '').toString() })); });} function freePort() { return new Promise(res => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); });} function request(url, { method = 'GET', body, headers = {}, timeout = 0 } = {}) { return new Promise((resolve, reject) => { const req = http.request(url, { method, headers, timeout }, res => { const chunks = []; res.on('data', c => chunks.push(c)); res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() })); }); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('timeout'))); if (body) req.write(body); req.end(); });} const sleep = ms => new Promise(r => setTimeout(r, ms)); // ---------------------------------------------------------------- speech engine let whisper = null, whisperPort = 0, whisperReady = null, whisperFails = 0; function currentModel() { return CATALOG.find(m => m.id === db.settings.model) || CATALOG[0]; } function stopWhisper() { if (whisper) { const w = whisper; whisper = null; w.kill(); }} async function startEngine() { stopWhisper(); const m = currentModel(), file = path.join(MODELS, m.file); if (!fs.existsSync(file)) { rt.engine = 'off'; rt.engineError = `${m.name} is not downloaded`; push(); return; } if (m.engine === 'parakeet') { rt.engine = 'ready'; rt.engineError = ''; push(); return; } rt.engine = 'loading'; rt.engineError = ''; push(); whisperPort = await freePort(); const args = ['-m', file, '--host', '127.0.0.1', '--port', String(whisperPort), '-dev', '0', '-t', '8']; if (fs.existsSync(VAD)) args.push('--vad', '-vm', VAD); const child = spawn('whisper-server', args, { stdio: ['ignore', 'ignore', 'pipe'] }); whisper = child; let tail = ''; child.stderr.on('data', d => { tail = (tail + d).slice(-2000); }); child.on('error', e => { rt.engine = 'error'; rt.engineError = e.message; push(); }); child.on('exit', code => { if (whisper !== child) return; // replaced on purpose whisper = null; rt.engine = 'error'; rt.engineError = `whisper-server exited (${code}): ${tail.trim().split('\n').slice(-3).join(' ')}`; push(); if (++whisperFails <= 3 && !quitting) setTimeout(startEngine, 3000); }); whisperReady = (async () => { for (let i = 0; i < 240 && whisper === child; i++) { try { if ((await request(`http://127.0.0.1:${whisperPort}/`, { timeout: 1000 })).status === 200) { rt.engine = 'ready'; whisperFails = 0; push(); return true; } } catch {} await sleep(250); } return false; })();} async function transcribeFile(file, { prompt = true } = {}) { const m = currentModel(); if (m.engine === 'parakeet') { const r = await run('parakeet-cli', ['-np', '-m', path.join(MODELS, m.file), '-f', file], { timeout: 600000 }); if (!r.ok) throw new Error(r.err.trim().split('\n').pop()); return stripMarkers(r.out); } if (!whisper) await startEngine(); if (!(await whisperReady)) throw new Error(rt.engineError || 'speech engine not ready'); const words = prompt ? db.dictionary.filter(d => !d.to).map(d => d.word).join(', ') : ''; const fields = { temperature: '0', response_format: 'json', language: db.settings.language || 'en' }; if (words) fields.prompt = words; const boundary = '----lilt' + newId(); const parts = Object.entries(fields).map(([k, v]) => Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`)); parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="a.wav"\r\nContent-Type: audio/wav\r\n\r\n`), fs.readFileSync(file), Buffer.from(`\r\n--${boundary}--\r\n`)); const body = Buffer.concat(parts); const r = await request(`http://127.0.0.1:${whisperPort}/inference`, { method: 'POST', body, headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length } }); if (r.status !== 200) throw new Error(`whisper-server ${r.status}: ${r.body.slice(0, 200)}`); return stripMarkers(JSON.parse(r.body).text);} // ---------------------------------------------------------------- language model let ollamaChild = null; async function ollamaUp() { try { return (await request(`${OLLAMA}/api/tags`, { timeout: 1500 })).status === 200; } catch { return false; }} // Cloud models through the CLIs already signed in on this machine. Each call runs// headless from an empty folder with tools, hooks, MCP and settings off, so the// CLI only rewrites text. Prompts go over stdin (meeting transcripts outgrow argv).const CLOUD = { 'claude-haiku': { bin: 'claude', name: 'Claude Haiku 5.5', args: sys => ['-p', '--model', 'claude-haiku-5-5', '--effort', 'low', '--system-prompt', sys, '--tools', '', '--no-session-persistence', '--setting-sources', '', '--strict-mcp-config', '--disable-slash-commands', '--output-format', 'text'], }, 'codex-luna': { // service_tier "fast" is Codex fast mode; the -fast model ids are refused on ChatGPT-account logins. bin: 'codex', name: 'GPT-5.6 Luna', file: true, args: (sys, out) => ['exec', '-m', 'gpt-5.6-luna', '-c', 'model_reasoning_effort="low"', '-c', 'service_tier="fast"', '--skip-git-repo-check', '--ephemeral', '-s', 'read-only', '--color', 'never', '-o', out, '-'], },};const CLI_DIR = path.join(DATA, 'cli'); // scratch for CLI output files, never /tmp function cliEnv() { const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; // codex keeps its login where ~/.profile points it; lilt started from a launcher never read that file if (!env.CODEX_HOME && fs.existsSync(path.join(HOME, '.config/codex'))) env.CODEX_HOME = path.join(HOME, '.config/codex'); env.PATH = [path.join(HOME, '.local/bin'), path.join(HOME, '.npm-global/bin'), env.PATH].join(':'); return env;} // Flatten system + few-shot turns into one prompt for a CLI.function flatten(messages) { const sys = messages[0].role === 'system' ? messages[0].content : ''; const turns = messages.slice(sys ? 1 : 0); const last = turns.pop().content; let ex = ''; for (let i = 0; i + 1 < turns.length; i += 2) ex += `${turns[i].content}\n<output>${turns[i + 1].content}</output>\n\n`; const prompt = ex ? `Examples of input and the exact output expected:\n\n${ex}Now the real input. Reply with only the output text, no tags:\n\n${last}` : last; return { sys, prompt };} function cloudChat(cfg, messages) { const { sys, prompt } = flatten(messages); fs.mkdirSync(CLI_DIR, { recursive: true }); const out = path.join(CLI_DIR, `${newId()}.txt`); // codex has no system-prompt flag, so the instructions lead the prompt const input = cfg.file ? `${sys}\n\n${prompt}` : prompt; return new Promise((resolve, reject) => { const child = spawn(cfg.bin, cfg.args(sys, out), { cwd: CLI_DIR, env: cliEnv(), stdio: ['pipe', 'pipe', 'pipe'] }); let so = '', se = ''; const timer = setTimeout(() => { child.kill(); reject(new Error(`${cfg.name} timed out`)); }, 60000); child.stdout.on('data', d => { so += d; }); child.stderr.on('data', d => { se = (se + d).slice(-2000); }); child.on('error', e => { clearTimeout(timer); reject(e); }); child.on('close', code => { clearTimeout(timer); let text = so; if (cfg.file) { try { text = fs.readFileSync(out, 'utf8'); } catch { text = ''; } fs.rmSync(out, { force: true }); } text = text.replace(/<\/?output>/g, '').trim(); if (code !== 0 || !text) return reject(new Error(`${cfg.name} failed (${code}): ${se.trim().slice(-300)}`)); resolve(text); }); child.stdin.end(input); });} async function startLLM() { rt.llm = 'loading'; push(); const cloud = CLOUD[db.settings.llm]; if (cloud) { const r = await run('sh', ['-c', `command -v ${cloud.bin}`], { env: cliEnv() }); rt.llm = r.ok ? 'ready' : 'off'; push(); return; } if (!(await ollamaUp())) { ollamaChild = spawn('ollama', ['serve'], { stdio: 'ignore' }); ollamaChild.on('error', () => { ollamaChild = null; }); for (let i = 0; i < 40 && !(await ollamaUp()); i++) await sleep(250); } if (!(await ollamaUp())) { rt.llm = 'off'; push(); return; } const model = db.settings.llmModel; const tags = JSON.parse((await request(`${OLLAMA}/api/tags`)).body).models || []; if (!tags.some(t => t.name === model || t.name === model + ':latest')) { const r = await run('ollama', ['pull', model], { timeout: 0 }); if (!r.ok) { rt.llm = 'off'; push(); return; } } try { await chat('Reply with OK.', 'OK'); rt.llm = 'ready'; } catch { rt.llm = 'off'; } push();} // chat(system, user) or chat(messagesArray)async function chat(system, user, opts = {}) { const messages = Array.isArray(system) ? system : [{ role: 'system', content: system }, { role: 'user', content: user }]; if (Array.isArray(system)) opts = user || {}; if (CLOUD[db.settings.llm]) return cloudChat(CLOUD[db.settings.llm], messages); const body = JSON.stringify({ model: db.settings.llmModel, think: false, stream: false, keep_alive: '30m', options: { temperature: 0.2, ...opts }, messages }); const r = await request(`${OLLAMA}/api/chat`, { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, timeout: 300000 }); if (r.status !== 200) throw new Error(`ollama ${r.status}: ${r.body.slice(0, 200)}`); return JSON.parse(r.body).message.content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();} // ---------------------------------------------------------------- text pipeline // raw transcript -> final text. Any LLM failure keeps the deterministic result.async function finish(raw, win = {}) { const s = db.settings; const cat = s.privacy.contextAwareness ? appCategory(win.cls, win.title) : 'other'; let text = applyDictionary(raw, db.dictionary); const { text: held, slots } = expandSnippets(text, db.snippets); const bare = held.replace(/\[\[S\d+\]\]/g, '').replace(/[\s.,!?]/g, ''); let out = held; if (s.cleanup !== 'none' && bare && rt.llm === 'ready') { try { const c = await chat(cleanupMessages(s.cleanup, s.styles[cat], cat, held)); const ok = c && slots.every((_, i) => c.includes(`[[S${i}]]`)) && drift(held, c) <= (s.cleanup === 'medium' ? 0.6 : 0.35); if (ok) out = c; else console.error('cleanup rejected:', JSON.stringify(c)); } catch (e) { console.error('cleanup failed:', e.message); } } const at = s.autoTransform, tf = at.enabled && db.transforms.find(t => t.id === at.id); if (tf && bare && rt.llm === 'ready') { try { const c = await chat(transformPrompt(tf) + ' Keep tokens like [[S0]] exactly as written.', `<text>${out}</text>`); if (c && slots.every((_, i) => c.includes(`[[S${i}]]`))) out = c;Lines 1–500 of 1193. Load more
'use strict'; // lilt: Electron main process. Pure text helpers come first and are // exported so check.js can test them under plain Node. const fs = require('fs'); const path = require('path'); const os = require('os'); const http = require('http'); const https = require('https'); const net = require('net'); const { spawn, execFile } = require('child_process'); // ---------------------------------------------------------------- pure helpers const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Spaces in a phrase match any run of whitespace or light punctuation. const phrase = s => esc(s.trim()).replace(/\s+/g, '[\\s,.-]+'); function countWords(text) { return (text || '').trim().split(/\s+/).filter(Boolean).length; } function wpm(words, ms) { return ms > 0 ? Math.round(words / (ms / 60000)) : 0; } // Dictionary entries with `to` are spoken-form replacements ("btw" -> "by the way"). function applyDictionary(text, dict) { for (const d of dict || []) { if (!d.to || !d.word || !d.word.trim()) continue; text = text.replace(new RegExp(`(?<![\\p{L}\\p{N}])${phrase(d.word)}(?![\\p{L}\\p{N}])`, 'giu'), d.to); } return text; } // Replaces spoken snippet triggers with placeholder tokens so the LLM cannot // rewrite them; `restore` puts the snippet text back afterwards. function expandSnippets(text, snippets) { const slots = []; for (const s of snippets || []) { if (!s.trigger || !s.trigger.trim()) continue; const re = new RegExp(`(?<![\\p{L}\\p{N}])${phrase(s.trigger)}[.!?,]?(?![\\p{L}\\p{N}])`, 'giu'); text = text.replace(re, () => { slots.push(s.text); return `[[S${slots.length - 1}]]`; }); } return { text, slots }; } function restoreSnippets(text, slots) { return text.replace(/\[\[S(\d+)\]\]/g, (_, i) => slots[+i] ?? ''); } function appCategory(cls = '', title = '') { const c = cls.toLowerCase(), t = title.toLowerCase(); if (/signal|telegram|discord|whatsapp|element|messages|vesktop|webcord|beeper/.test(c)) return 'personal'; if (/slack|teams|zulip|mattermost/.test(c)) return 'work'; if (/thunderbird|geary|evolution|mutt|mailspring|betterbird/.test(c) || /gmail|outlook|proton mail|inbox/.test(t)) return 'email'; return 'other'; } const TERMINALS = ['st', 'st-256color', 'kitty', 'alacritty', 'foot', 'xterm', 'urxvt', 'wezterm', 'ghostty', 'konsole', 'gnome-terminal', 'gnome-terminal-server', 'xfce4-terminal', 'tilix', 'terminator']; function isTerminal(cls = '') { const c = cls.toLowerCase(); return TERMINALS.includes(c) || /terminal|term$/.test(c); } const KEYS = { 9: 'Escape', 37: 'Left Ctrl', 105: 'Right Ctrl', 64: 'Left Alt', 108: 'Right Alt', 50: 'Left Shift', 62: 'Right Shift', 133: 'Left Super', 134: 'Right Super', 66: 'Caps Lock', 135: 'Menu', 65: 'Space', 95: 'F11', 96: 'F12', 118: 'Insert', 127: 'Pause', 78: 'Scroll Lock' }; function keyLabel(code) { if (KEYS[code]) return KEYS[code]; if (code >= 67 && code <= 76) return 'F' + (code - 66); return 'Key ' + code; } // 16-bit mono PCM WAV from Float32 samples. function wavBuffer(f32, rate = 16000) { const b = Buffer.alloc(44 + f32.length * 2); b.write('RIFF', 0); b.writeUInt32LE(36 + f32.length * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22); b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(f32.length * 2, 40); for (let i = 0; i < f32.length; i++) b.writeInt16LE(Math.round(Math.max(-1, Math.min(1, f32[i])) * 32767), 44 + i * 2); return b; } // Whisper's markers for silence or noise, e.g. "[BLANK_AUDIO]" or "(music)". function stripMarkers(text) { return (text || '').replace(/\[[^\]]*\]|\([^)]*(music|silence|blank|noise|inaudible)[^)]*\)/gi, '').replace(/\s+/g, ' ').trim(); } const STYLE = { formal: 'Use proper capitalization and full punctuation.', casual: 'Use normal capitalization but lighter punctuation, like a relaxed message; drop the final period.', veryCasual: 'Write in all lowercase with minimal punctuation, like a quick text.', }; const EDIT = { light: 'Clean it up: remove filler words (um, uh, like, you know), stutters and repeated words, apply self-corrections ("no wait, I mean X" keeps only X), fix punctuation, capitalization and obvious grammar. Keep every other word as spoken.', medium: 'Rewrite it for clarity and concision: remove filler words, false starts and repetition, apply self-corrections ("no wait, I mean X" keeps only X), tighten wordy phrasing, fix grammar. Keep the speaker\'s meaning, point of view, every fact and name.', }; function cleanupPrompt(level, style, category) { return 'You are a dictation editor, not an assistant. You receive a transcript of something the user said, inside <dictation> tags, that they want typed into ' + (category === 'other' ? 'an app' : `a ${category} app`) + `. ${EDIT[level] || EDIT.light} ${STYLE[style] || STYLE.formal} ` + 'If it is a question or a command, it stays a question or a command addressed to the same person. Never answer it, never add anything. ' + 'Tokens like [[S0]] are placeholders: keep them exactly as written. Output only the edited text, no tags.'; } // Few-shot turns that pin the model to editing, shaped to the chosen style. const SHOTS = [ ['um can you uh send me the the report by friday', 'Can you send me the report by Friday?'], ['find me a good restaurant near the office', 'Find me a good restaurant near the office.'], ['so we meet tuesday no wait wednesday at like 3', 'So we meet Wednesday at 3.'], ]; function cleanupMessages(level, style, category, text) { const shape = o => style === 'veryCasual' ? o.toLowerCase().replace(/\.$/, '') : style === 'casual' ? o.replace(/\.$/, '') : o; const m = [{ role: 'system', content: cleanupPrompt(level, style, category) }]; for (const [a, b] of SHOTS) m.push({ role: 'user', content: `<dictation>${a}</dictation>` }, { role: 'assistant', content: shape(b) }); m.push({ role: 'user', content: `<dictation>${text}</dictation>` }); return m; } // Share of words in `out` that never appeared in `src`. Cleanup mostly deletes, // so a high value means the model answered or rewrote instead of editing. function drift(src, out) { const w = t => t.toLowerCase().replace(/[^\p{L}\p{N}\s\[\]']/gu, ' ').split(/\s+/).filter(Boolean); const have = new Set(w(src)), words = w(out); return words.length ? words.filter(x => !have.has(x)).length / words.length : 0; } // What the assistant agent receives for one spoken (or typed) command. function assistantPrompt(text, win = {}, system = '') { const w = win.cls ? `\n\nFocused window: ${win.cls}${win.title ? ` "${win.title}"` : ''}` : ''; const sys = system.trim() ? `Kris's standing instructions:\n${system.trim()}\n\n` : ''; return `First read ~/.config/agents/AGENTS.md and follow it; the other files in ~/.config/agents are yours to use.\n\n${sys}` + `Voice command from Kris (speech to text, may contain recognition errors): ${text}${w}`; } // grok's streaming JSON: the answer is the last {"type":"result"} line. Plain text passes through. function cliResult(out) { for (const l of out.trim().split('\n').reverse()) { try { const j = JSON.parse(l); if (j && j.type === 'result') return String(j.result || ''); } catch {} } return out; } const P = { assistantPrompt, cliResult, countWords, wpm, applyDictionary, expandSnippets, restoreSnippets, appCategory, isTerminal, keyLabel, wavBuffer, stripMarkers, cleanupPrompt, cleanupMessages, drift }; module.exports = P; if (process.type !== 'browser') return; // plain Node (check.js): stop here // ---------------------------------------------------------------- electron app const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, screen, clipboard, shell, session, globalShortcut, nativeTheme } = require('electron'); const VERSION = require('./package.json').version; const HOME = os.homedir(); const DATA = path.join(HOME, '.local/share/lilt'); // The app was called Flow at first; carry its data folder over once. const OLD_DATA = path.join(HOME, '.local/share/flow'); if (!fs.existsSync(DATA) && fs.existsSync(OLD_DATA)) fs.renameSync(OLD_DATA, DATA); const MODELS = path.join(DATA, 'models'); const AUDIO = path.join(DATA, 'audio'); const DB = path.join(DATA, 'db.json'); const VAD = path.join(MODELS, 'ggml-silero-v5.1.2.bin'); const MCP_PORT = 7865; const OLLAMA = 'http://127.0.0.1:11434'; const CLI = process.argv.indexOf('--transcribe'); const ASK = process.argv.indexOf('--ask'); const HF = 'https://huggingface.co'; const CATALOG = [ { id: 'large-v3-turbo', name: 'Whisper Ultra Turbo V3', engine: 'whisper', file: 'ggml-large-v3-turbo.bin', size: '1.6 GB', langs: '100+', url: `${HF}/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin` }, { id: 'large-v3', name: 'Whisper Ultra V3', engine: 'whisper', file: 'ggml-large-v3.bin', size: '3.1 GB', langs: '100+', url: `${HF}/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin` }, { id: 'parakeet-v3', name: 'Parakeet V3 (NVIDIA)', engine: 'parakeet', file: 'ggml-parakeet-tdt-0.6b-v3-f16.bin', size: '1.3 GB', langs: '25', url: `${HF}/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-f16.bin` }, { id: 'small', name: 'Whisper Standard', engine: 'whisper', file: 'ggml-small.bin', size: '488 MB', langs: '100+', url: `${HF}/ggerganov/whisper.cpp/resolve/main/ggml-small.bin` }, ]; const DEFAULTS = { theme: 'system', firstName: (n => n.charAt(0).toUpperCase() + n.slice(1))(os.userInfo().username), lastName: '', email: '', hotkey: { keycode: 108, label: 'Right Alt' }, micId: '', micLabel: '', language: 'en', model: 'large-v3-turbo', llm: 'ollama', llmModel: 'qwen3.5:4b', cleanup: 'light', styles: { personal: 'casual', work: 'formal', email: 'formal', other: 'casual' }, transformsOptIn: true, autoTransform: { enabled: false, id: 'polish' }, launchAtLogin: false, showBarAlways: true, showInDock: true, sounds: true, muteWhileDictating: false, scratchpadInBar: false, privacy: { contextAwareness: true, storeAudio: true }, notetaker: { notifyBefore: 15, showNextMeeting: true, autoDetect: true, autoStart: false, maxMinutes: 120, stopOnCallEnd: true, hideFromCapture: true, notepadView: 'open', liveTranscript: true, calendarNotice: 'off', consentNotice: false, linkSharing: 'anyone', autoShareWho: 'no one', autoShareFor: 'all meetings' }, vibe: { variableRecognition: false, fileTagging: true }, assistant: { enabled: true, model: 'claude-sonnet', hotkey: { keycode: 50, label: 'Left Shift' }, systemPrompt: '' }, dismissed: {}, }; const newId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6); function seed() { const t = Date.now(); const d = (word, extra) => ({ id: newId(), word, ts: t, ...extra }); return { settings: structuredClone(DEFAULTS), history: [], dictionary: [d('btw', { to: 'by the way' })], snippets: [ { id: newId(), trigger: 'organize thoughts prompt', text: 'Organize these unstructured thoughts into a clear, polished version without changing the meaning.', ts: t }, ], transforms: [ { id: 'polish', name: 'Polish', desc: 'Improve clarity and conciseness', key: '1', prompt: 'Rewrite the text to be clearer and more concise. Fix grammar and flow, keep the meaning, tone and any names or numbers. Output only the rewritten text.' }, { id: 'prompt-engineer', name: 'Prompt Engineer', desc: 'Constructs optimal prompts', key: '2', prompt: 'Turn the text into a well-structured prompt for an AI assistant: state the goal, the relevant context, constraints and the expected output format. Keep every detail the user gave. Output only the prompt.' }, ], notes: [], meetings: [], commands: [], }; } // Fills in settings keys added after the db was created, without touching user values. function merge(def, cur) { if (!cur || typeof cur !== 'object' || Array.isArray(cur)) return structuredClone(def); for (const k of Object.keys(def)) { const dv = def[k]; if (dv && typeof dv === 'object' && !Array.isArray(dv)) cur[k] = merge(dv, cur[k]); else if (!(k in cur)) cur[k] = dv; } return cur; } let db; function load() { fs.mkdirSync(AUDIO, { recursive: true }); fs.mkdirSync(MODELS, { recursive: true }); if (fs.existsSync(DB)) { try { db = JSON.parse(fs.readFileSync(DB, 'utf8')); } catch { fs.renameSync(DB, `${DB}.bad-${Date.now()}`); } // keep a corrupt file instead of overwriting it } if (!db) db = seed(); const s = seed(); for (const k of ['history', 'dictionary', 'snippets', 'transforms', 'notes', 'meetings', 'commands']) if (!Array.isArray(db[k])) db[k] = s[k]; db.settings = merge(DEFAULTS, db.settings); // commands from before the log file existed, oldest first if (CLI < 0 && db.commands.length && !fs.existsSync(ASSIST_LOG)) fs.writeFileSync(ASSIST_LOG, db.commands.filter(c => c.reply).reverse().map(c => JSON.stringify(c) + '\n').join('')); } let saveTimer = null; function writeNow() { clearTimeout(saveTimer); saveTimer = null; if (CLI > -1) return; // the --transcribe test mode never writes fs.writeFileSync(DB + '.tmp', JSON.stringify(db)); fs.renameSync(DB + '.tmp', DB); } function save() { clearTimeout(saveTimer); saveTimer = setTimeout(writeNow, 300); } // ---------------------------------------------------------------- runtime + push const rt = { version: VERSION, dataDir: DATA, mcpUrl: `http://127.0.0.1:${MCP_PORT}/mcp`, engine: 'off', engineError: '', llm: 'off', recording: false, recordingKind: 'dictate', processing: false, notetaker: false, assistant: false }; const progress = {}; // model id -> 0..1 while downloading function models() { return CATALOG.map(m => ({ id: m.id, name: m.name, engine: m.engine, size: m.size, langs: m.langs, installed: fs.existsSync(path.join(MODELS, m.file)), progress: progress[m.id] })); } function runtime() { return { ...rt, hotkeyLabel: db.settings.hotkey.label, models: models(), assistantDir: ASSIST_DIR, assistantLog: ASSIST_LOG, assistantDirOk: fs.existsSync(path.join(ASSIST_DIR, 'AGENTS.md')), assistantModels: ASSISTANTS.map(({ id, name, cli, desc }) => ({ id, name, cli, desc, available: !!installed[cli] })) }; } function state() { return { ...db, runtime: runtime() }; } let hub = null, bar = null, tray = null, trayKey = '', quitting = false, pushTimer = null; function push() { clearTimeout(pushTimer); pushTimer = setTimeout(() => { if (hub && !hub.isDestroyed() && !hub.webContents.isCrashed()) hub.webContents.send('state', state()); barSend({ type: 'state', ...runtime(), settings: { scratchpadInBar: db.settings.scratchpadInBar, sounds: db.settings.sounds, showBarAlways: db.settings.showBarAlways } }); updateBarVisibility(); const key = `${!!rec}${!!nt}${!!assist}`; if (tray && key !== trayKey) { trayKey = key; tray.setContextMenu(trayMenu()); } }, 50); } const changed = () => { save(); push(); }; const barSend = msg => { if (bar && !bar.isDestroyed() && !bar.webContents.isCrashed()) bar.webContents.send('bar', msg); }; // ---------------------------------------------------------------- process helpers function run(cmd, args, opts = {}) { return new Promise(resolve => { execFile(cmd, args, { timeout: 15000, maxBuffer: 64 << 20, ...opts }, (err, stdout, stderr) => resolve({ ok: !err, out: (stdout || '').toString(), err: (stderr || (err && err.message) || '').toString() })); }); } function freePort() { return new Promise(res => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); }); } function request(url, { method = 'GET', body, headers = {}, timeout = 0 } = {}) { return new Promise((resolve, reject) => { const req = http.request(url, { method, headers, timeout }, res => { const chunks = []; res.on('data', c => chunks.push(c)); res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() })); }); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('timeout'))); if (body) req.write(body); req.end(); }); } const sleep = ms => new Promise(r => setTimeout(r, ms)); // ---------------------------------------------------------------- speech engine let whisper = null, whisperPort = 0, whisperReady = null, whisperFails = 0; function currentModel() { return CATALOG.find(m => m.id === db.settings.model) || CATALOG[0]; } function stopWhisper() { if (whisper) { const w = whisper; whisper = null; w.kill(); } } async function startEngine() { stopWhisper(); const m = currentModel(), file = path.join(MODELS, m.file); if (!fs.existsSync(file)) { rt.engine = 'off'; rt.engineError = `${m.name} is not downloaded`; push(); return; } if (m.engine === 'parakeet') { rt.engine = 'ready'; rt.engineError = ''; push(); return; } rt.engine = 'loading'; rt.engineError = ''; push(); whisperPort = await freePort(); const args = ['-m', file, '--host', '127.0.0.1', '--port', String(whisperPort), '-dev', '0', '-t', '8']; if (fs.existsSync(VAD)) args.push('--vad', '-vm', VAD); const child = spawn('whisper-server', args, { stdio: ['ignore', 'ignore', 'pipe'] }); whisper = child; let tail = ''; child.stderr.on('data', d => { tail = (tail + d).slice(-2000); }); child.on('error', e => { rt.engine = 'error'; rt.engineError = e.message; push(); }); child.on('exit', code => { if (whisper !== child) return; // replaced on purpose whisper = null; rt.engine = 'error'; rt.engineError = `whisper-server exited (${code}): ${tail.trim().split('\n').slice(-3).join(' ')}`; push(); if (++whisperFails <= 3 && !quitting) setTimeout(startEngine, 3000); }); whisperReady = (async () => { for (let i = 0; i < 240 && whisper === child; i++) { try { if ((await request(`http://127.0.0.1:${whisperPort}/`, { timeout: 1000 })).status === 200) { rt.engine = 'ready'; whisperFails = 0; push(); return true; } } catch {} await sleep(250); } return false; })(); } async function transcribeFile(file, { prompt = true } = {}) { const m = currentModel(); if (m.engine === 'parakeet') { const r = await run('parakeet-cli', ['-np', '-m', path.join(MODELS, m.file), '-f', file], { timeout: 600000 }); if (!r.ok) throw new Error(r.err.trim().split('\n').pop()); return stripMarkers(r.out); } if (!whisper) await startEngine(); if (!(await whisperReady)) throw new Error(rt.engineError || 'speech engine not ready'); const words = prompt ? db.dictionary.filter(d => !d.to).map(d => d.word).join(', ') : ''; const fields = { temperature: '0', response_format: 'json', language: db.settings.language || 'en' }; if (words) fields.prompt = words; const boundary = '----lilt' + newId(); const parts = Object.entries(fields).map(([k, v]) => Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`)); parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="a.wav"\r\nContent-Type: audio/wav\r\n\r\n`), fs.readFileSync(file), Buffer.from(`\r\n--${boundary}--\r\n`)); const body = Buffer.concat(parts); const r = await request(`http://127.0.0.1:${whisperPort}/inference`, { method: 'POST', body, headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length } }); if (r.status !== 200) throw new Error(`whisper-server ${r.status}: ${r.body.slice(0, 200)}`); return stripMarkers(JSON.parse(r.body).text); } // ---------------------------------------------------------------- language model let ollamaChild = null; async function ollamaUp() { try { return (await request(`${OLLAMA}/api/tags`, { timeout: 1500 })).status === 200; } catch { return false; } } // Cloud models through the CLIs already signed in on this machine. Each call runs // headless from an empty folder with tools, hooks, MCP and settings off, so the // CLI only rewrites text. Prompts go over stdin (meeting transcripts outgrow argv). const CLOUD = { 'claude-haiku': { bin: 'claude', name: 'Claude Haiku 5.5', args: sys => ['-p', '--model', 'claude-haiku-5-5', '--effort', 'low', '--system-prompt', sys, '--tools', '', '--no-session-persistence', '--setting-sources', '', '--strict-mcp-config', '--disable-slash-commands', '--output-format', 'text'], }, 'codex-luna': { // service_tier "fast" is Codex fast mode; the -fast model ids are refused on ChatGPT-account logins. bin: 'codex', name: 'GPT-5.6 Luna', file: true, args: (sys, out) => ['exec', '-m', 'gpt-5.6-luna', '-c', 'model_reasoning_effort="low"', '-c', 'service_tier="fast"', '--skip-git-repo-check', '--ephemeral', '-s', 'read-only', '--color', 'never', '-o', out, '-'], }, }; const CLI_DIR = path.join(DATA, 'cli'); // scratch for CLI output files, never /tmp function cliEnv() { const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; // codex keeps its login where ~/.profile points it; lilt started from a launcher never read that file if (!env.CODEX_HOME && fs.existsSync(path.join(HOME, '.config/codex'))) env.CODEX_HOME = path.join(HOME, '.config/codex'); env.PATH = [path.join(HOME, '.local/bin'), path.join(HOME, '.npm-global/bin'), env.PATH].join(':'); return env; } // Flatten system + few-shot turns into one prompt for a CLI. function flatten(messages) { const sys = messages[0].role === 'system' ? messages[0].content : ''; const turns = messages.slice(sys ? 1 : 0); const last = turns.pop().content; let ex = ''; for (let i = 0; i + 1 < turns.length; i += 2) ex += `${turns[i].content}\n<output>${turns[i + 1].content}</output>\n\n`; const prompt = ex ? `Examples of input and the exact output expected:\n\n${ex}Now the real input. Reply with only the output text, no tags:\n\n${last}` : last; return { sys, prompt }; } function cloudChat(cfg, messages) { const { sys, prompt } = flatten(messages); fs.mkdirSync(CLI_DIR, { recursive: true }); const out = path.join(CLI_DIR, `${newId()}.txt`); // codex has no system-prompt flag, so the instructions lead the prompt const input = cfg.file ? `${sys}\n\n${prompt}` : prompt; return new Promise((resolve, reject) => { const child = spawn(cfg.bin, cfg.args(sys, out), { cwd: CLI_DIR, env: cliEnv(), stdio: ['pipe', 'pipe', 'pipe'] }); let so = '', se = ''; const timer = setTimeout(() => { child.kill(); reject(new Error(`${cfg.name} timed out`)); }, 60000); child.stdout.on('data', d => { so += d; }); child.stderr.on('data', d => { se = (se + d).slice(-2000); }); child.on('error', e => { clearTimeout(timer); reject(e); }); child.on('close', code => { clearTimeout(timer); let text = so; if (cfg.file) { try { text = fs.readFileSync(out, 'utf8'); } catch { text = ''; } fs.rmSync(out, { force: true }); } text = text.replace(/<\/?output>/g, '').trim(); if (code !== 0 || !text) return reject(new Error(`${cfg.name} failed (${code}): ${se.trim().slice(-300)}`)); resolve(text); }); child.stdin.end(input); }); } async function startLLM() { rt.llm = 'loading'; push(); const cloud = CLOUD[db.settings.llm]; if (cloud) { const r = await run('sh', ['-c', `command -v ${cloud.bin}`], { env: cliEnv() }); rt.llm = r.ok ? 'ready' : 'off'; push(); return; } if (!(await ollamaUp())) { ollamaChild = spawn('ollama', ['serve'], { stdio: 'ignore' }); ollamaChild.on('error', () => { ollamaChild = null; }); for (let i = 0; i < 40 && !(await ollamaUp()); i++) await sleep(250); } if (!(await ollamaUp())) { rt.llm = 'off'; push(); return; } const model = db.settings.llmModel; const tags = JSON.parse((await request(`${OLLAMA}/api/tags`)).body).models || []; if (!tags.some(t => t.name === model || t.name === model + ':latest')) { const r = await run('ollama', ['pull', model], { timeout: 0 }); if (!r.ok) { rt.llm = 'off'; push(); return; } } try { await chat('Reply with OK.', 'OK'); rt.llm = 'ready'; } catch { rt.llm = 'off'; } push(); } // chat(system, user) or chat(messagesArray) async function chat(system, user, opts = {}) { const messages = Array.isArray(system) ? system : [{ role: 'system', content: system }, { role: 'user', content: user }]; if (Array.isArray(system)) opts = user || {}; if (CLOUD[db.settings.llm]) return cloudChat(CLOUD[db.settings.llm], messages); const body = JSON.stringify({ model: db.settings.llmModel, think: false, stream: false, keep_alive: '30m', options: { temperature: 0.2, ...opts }, messages }); const r = await request(`${OLLAMA}/api/chat`, { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, timeout: 300000 }); if (r.status !== 200) throw new Error(`ollama ${r.status}: ${r.body.slice(0, 200)}`); return JSON.parse(r.body).message.content.replace(/<think>[\s\S]*?<\/think>/g, '').trim(); } // ---------------------------------------------------------------- text pipeline // raw transcript -> final text. Any LLM failure keeps the deterministic result. async function finish(raw, win = {}) { const s = db.settings; const cat = s.privacy.contextAwareness ? appCategory(win.cls, win.title) : 'other'; let text = applyDictionary(raw, db.dictionary); const { text: held, slots } = expandSnippets(text, db.snippets); const bare = held.replace(/\[\[S\d+\]\]/g, '').replace(/[\s.,!?]/g, ''); let out = held; if (s.cleanup !== 'none' && bare && rt.llm === 'ready') { try { const c = await chat(cleanupMessages(s.cleanup, s.styles[cat], cat, held)); const ok = c && slots.every((_, i) => c.includes(`[[S${i}]]`)) && drift(held, c) <= (s.cleanup === 'medium' ? 0.6 : 0.35); if (ok) out = c; else console.error('cleanup rejected:', JSON.stringify(c)); } catch (e) { console.error('cleanup failed:', e.message); } } const at = s.autoTransform, tf = at.enabled && db.transforms.find(t => t.id === at.id); if (tf && bare && rt.llm === 'ready') { try { const c = await chat(transformPrompt(tf) + ' Keep tokens like [[S0]] exactly as written.', `<text>${out}</text>`); if (c && slots.every((_, i) => c.includes(`[[S${i}]]`))) out = c;