const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { spawn } = require('child_process'); const PORT = 8000; const MODEL_ID = 'kimi-agent'; const TIMEOUT_MS = 15 * 60 * 1000; const WORKSPACE = '/workspace'; const CONV_ROOT = path.join(WORKSPACE, 'conversations'); const STATE_DIR = path.join(WORKSPACE, '.kimi-agent'); const MAP_FILE = path.join(STATE_DIR, 'sessions.json'); const MAX_ENTRIES = 1000; // prune oldest beyond this fs.mkdirSync(CONV_ROOT, { recursive: true }); fs.mkdirSync(STATE_DIR, { recursive: true }); // --- persistent conversation -> session map --------------------------------- // key = hash(history-so-far) -> { convId, sessionId, dir, ts } let sessionMap = {}; try { sessionMap = JSON.parse(fs.readFileSync(MAP_FILE, 'utf8')); } catch { sessionMap = {}; } let writeQueue = Promise.resolve(); function persistMap() { // prune to the MAX_ENTRIES most-recently-used before writing const keys = Object.keys(sessionMap); if (keys.length > MAX_ENTRIES) { keys.sort((a, b) => (sessionMap[a].ts || 0) - (sessionMap[b].ts || 0)); for (const k of keys.slice(0, keys.length - MAX_ENTRIES)) delete sessionMap[k]; } const snapshot = JSON.stringify(sessionMap); writeQueue = writeQueue.then( () => fs.promises.writeFile(MAP_FILE, snapshot), () => fs.promises.writeFile(MAP_FILE, snapshot), ); return writeQueue; } // --- message helpers -------------------------------------------------------- function textOf(msg) { const c = msg.content; if (Array.isArray(c)) return c.map(p => p.text || '').join('\n'); return c == null ? '' : String(c); } // only user/assistant turns define conversation identity (system is constant) function convTurns(messages) { return messages.filter(m => m.role === 'user' || m.role === 'assistant'); } function historyKey(turns) { const norm = turns.map(m => ({ role: m.role, text: textOf(m).trim() })); return crypto.createHash('sha256').update(JSON.stringify(norm)).digest('hex'); } function renderTranscript(turns) { return turns .map(m => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${textOf(m)}`) .join('\n\n'); } // --- kimi invocation -------------------------------------------------------- // Returns { text, sessionId }. Parses --output-format stream-json: // {"role":"assistant","content":"..."} // {"role":"meta","type":"session.resume_hint","session_id":"session_..."} function runKimi({ prompt, cwd, resumeId }) { return new Promise((resolve, reject) => { const args = []; if (resumeId) args.push('-r', resumeId); args.push('-p', prompt, '--output-format', 'stream-json'); const child = spawn('kimi', args, { cwd, timeout: TIMEOUT_MS }); let stdout = ''; let stderr = ''; child.stdout.on('data', d => { stdout += d; }); child.stderr.on('data', d => { stderr += d; }); child.on('error', reject); child.on('close', code => { const parts = []; let sessionId = null; for (const line of stdout.split('\n')) { const t = line.trim(); if (!t) continue; let obj; try { obj = JSON.parse(t); } catch { continue; } if (obj.role === 'assistant' && obj.content) parts.push(obj.content); if (obj.type === 'session.resume_hint' && obj.session_id) sessionId = obj.session_id; } const text = parts.join('').trim(); if (!text && code !== 0) { reject(new Error(`kimi exited ${code}: ${stderr.slice(0, 2000)}`)); } else { resolve({ text, sessionId }); } }); }); } // Decide session/dir, run kimi, and record the forward mapping. async function handleTurn(messages) { const turns = convTurns(messages); // find the last user turn = the new prompt; everything before it is prior history let lastUserIdx = -1; for (let i = turns.length - 1; i >= 0; i--) { if (turns[i].role === 'user') { lastUserIdx = i; break; } } if (lastUserIdx === -1) throw new Error('no user message found'); const newPrompt = textOf(turns[lastUserIdx]); const prior = turns.slice(0, lastUserIdx); let convId; let dir; let resumeId = null; let prompt = newPrompt; if (prior.length === 0) { // brand-new conversation convId = crypto.randomUUID(); dir = path.join(CONV_ROOT, convId); } else { const entry = sessionMap[historyKey(prior)]; if (entry) { // known conversation -> resume the same kimi session in its own dir convId = entry.convId; dir = entry.dir; resumeId = entry.sessionId; } else { // lost mapping (restart / edited history): reseed a fresh session with // the full transcript so continuity is preserved convId = crypto.randomUUID(); dir = path.join(CONV_ROOT, convId); prompt = renderTranscript(turns.slice(0, lastUserIdx + 1)); } } fs.mkdirSync(dir, { recursive: true }); const { text, sessionId } = await runKimi({ prompt, cwd: dir, resumeId }); // store forward mapping: next request's prior history == these turns + reply const forward = turns.slice(0, lastUserIdx + 1).concat([{ role: 'assistant', content: text }]); sessionMap[historyKey(forward)] = { convId, sessionId: sessionId || resumeId, dir, ts: Date.now(), }; persistMap(); return text; } // --- OpenAI-compatible HTTP surface ---------------------------------------- function completionBody(text) { return { id: `chatcmpl-${Date.now()}`, object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: MODEL_ID, choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop', }], }; } const server = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/v1/models') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ object: 'list', data: [{ id: MODEL_ID, object: 'model', owned_by: 'moonshot' }], })); return; } if (req.method === 'POST' && req.url === '/v1/chat/completions') { let body = ''; req.on('data', d => { body += d; }); req.on('end', async () => { let parsed; try { parsed = JSON.parse(body); } catch { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid JSON body' })); return; } try { const text = await handleTurn(parsed.messages || []); if (parsed.stream) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); const id = `chatcmpl-${Date.now()}`; const created = Math.floor(Date.now() / 1000); res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created, model: MODEL_ID, choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: null }], })}\n\n`); res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created, model: MODEL_ID, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], })}\n\n`); res.write('data: [DONE]\n\n'); res.end(); } else { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(completionBody(text))); } } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: String(err.message || err) })); } }); return; } res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'not found' })); }); server.listen(PORT, () => console.log(`kimi-agent wrapper listening on :${PORT}`));