diff --git a/src/safety.js b/src/safety.js new file mode 100644 index 0000000..b5df35a --- /dev/null +++ b/src/safety.js @@ -0,0 +1,19 @@ +// Very small safety filter for V1. Extend as needed. +const dangerousPatterns = [ + /\bkill\b/i, + /\bweapon\b/i, + /\bhack(?:ing|er)?\b/i, + /\bpassword\b/i, + /\bcredit card\b/i, + /\bssn\b/i +]; + +function isDangerous(text) { + if (!text) return false; + for (const re of dangerousPatterns) { + if (re.test(text)) return true; + } + return false; +} + +module.exports = { isDangerous }; diff --git a/src/server.js b/src/server.js index 88578cd..1ac7e2a 100644 --- a/src/server.js +++ b/src/server.js @@ -1,127 +1,59 @@ -import http from "node:http"; -import { AGENTS, buildAgentPrompt } from "./agents.js"; -import { addNovelty, rankSuggestions } from "./ranker.js"; - -const PORT = Number(process.env.PORT || 3000); -const MODEL = process.env.COMMANDER_MODEL || "gpt-5.6-luna"; -const MAX_SUGGESTIONS = Math.min(Number(process.env.MAX_SUGGESTIONS || 5), AGENTS.length); -const CACHE_TTL_MS = Number(process.env.CACHE_TTL_MS || 60000); -const cache = new Map(); - -function json(res, status, body) { - res.writeHead(status, { - "content-type": "application/json; charset=utf-8", - "access-control-allow-origin": "*", - "access-control-allow-methods": "POST, OPTIONS", - "access-control-allow-headers": "content-type" - }); - res.end(JSON.stringify(body)); -} - -function readBody(req) { - return new Promise((resolve, reject) => { - let raw = ""; - req.on("data", chunk => { - raw += chunk; - if (raw.length > 100_000) req.destroy(); - }); - req.on("end", () => { - try { resolve(JSON.parse(raw || "{}")); } - catch { reject(new Error("Invalid JSON")); } - }); - req.on("error", reject); - }); -} - -function extractJson(text) { - const cleaned = String(text || "").replace(/^```json\s*/i, "").replace(/```$/i, "").trim(); - const start = cleaned.indexOf("{"); - const end = cleaned.lastIndexOf("}"); - if (start < 0 || end < start) throw new Error("Model did not return JSON"); - return JSON.parse(cleaned.slice(start, end + 1)); -} - -async function callModel(agent, promptPartial, context) { - if (!process.env.OPENAI_API_KEY) { - return { - text: `[mock:${agent.id}] Improve this prompt: ${promptPartial}`, - score: 0.5 - }; - } - - const response = await fetch("https://api.openai.com/v1/responses", { - method: "POST", - headers: { - "authorization": `Bearer ${process.env.OPENAI_API_KEY}`, - "content-type": "application/json" - }, - body: JSON.stringify({ - model: MODEL, - input: buildAgentPrompt(agent, promptPartial, context), - text: { format: { type: "json_object" } } - }) - }); - - if (!response.ok) { - const message = await response.text(); - throw new Error(`Model request failed (${response.status}): ${message.slice(0, 300)}`); - } - - const data = await response.json(); - const outputText = data.output_text || data.output?.flatMap(x => x.content || []).find(x => x.text)?.text; - const parsed = extractJson(outputText); - return { text: parsed.text, score: Number(parsed.score) }; -} - -async function suggest({ promptPartial, context = "", max_suggestions = MAX_SUGGESTIONS }) { - const key = JSON.stringify({ promptPartial, context, max_suggestions }); - const cached = cache.get(key); - if (cached && cached.expires > Date.now()) return cached.value; - - const agents = AGENTS.slice(0, Math.max(1, Math.min(Number(max_suggestions), AGENTS.length))); - const results = await Promise.allSettled( - agents.map(async agent => { - const result = await callModel(agent, promptPartial, context); - return { - id: `${agent.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - text: result.text, - score: result.score, - type: agent.type, - agent: agent.id - }; - }) - ); +const express = require('express'); +const path = require('path'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); +const cors = require('cors'); +const { orchestrate } = require('./orchestrator'); +const { combineSelected } = require('./combine'); + +const app = express(); +app.use(helmet()); +app.use(cors()); +app.use(express.json({ limit: '64kb' })); + +const limiter = rateLimit({ windowMs: 60_000, max: 120 }); +app.use(limiter); + +// serve static frontend +app.use(express.static(path.join(__dirname, '..', 'public'))); + +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', version: 'v1' }); +}); - const suggestions = results - .filter(r => r.status === "fulfilled") - .map(r => r.value); +app.post('/api/suggest', async (req, res) => { + try { + const body = req.body || {}; + const { promptPartial, cursorIndex = null, context = {}, max_suggestions = 5, all = false } = body; - const ranked = rankSuggestions(addNovelty(suggestions, promptPartial), Number(max_suggestions)); - const value = { suggestions: ranked, model: MODEL, cached: false }; - cache.set(key, { value, expires: Date.now() + CACHE_TTL_MS }); - return value; -} + if (!promptPartial || typeof promptPartial !== 'string') return res.status(400).json({ error: 'promptPartial (string) required' }); + if (max_suggestions <= 0) return res.status(400).json({ error: 'max_suggestions must be positive' }); -const server = http.createServer(async (req, res) => { - if (req.method === "OPTIONS") return json(res, 204, {}); - if (req.method !== "POST" || req.url !== "/api/suggest") { - return json(res, 404, { error: "Not found" }); + const suggestions = await orchestrate({ promptPartial, cursorIndex, context, max_suggestions, all }); + res.json({ suggestions }); + } catch (err) { + console.error('api/suggest error', err && err.stack || err); + res.status(500).json({ error: 'generation_failed' }); } +}); +app.post('/api/combine', async (req, res) => { try { - const body = await readBody(req); - const promptPartial = String(body.promptPartial || "").trim(); - if (!promptPartial) return json(res, 400, { error: "promptPartial is required" }); - if (promptPartial.length > 20_000) return json(res, 400, { error: "promptPartial is too long" }); - - const result = await suggest(body); - return json(res, 200, result); - } catch (error) { - console.error(error); - return json(res, 500, { error: error.message || "Internal server error" }); + const body = req.body || {}; + const { selected = [], promptPartial = '', context = {} } = body; + if (!Array.isArray(selected) || selected.length === 0) return res.status(400).json({ error: 'selected array required' }); + + const combined = await combineSelected({ selected, promptPartial, context }); + res.json({ combined }); + } catch (err) { + console.error('api/combine error', err && err.stack || err); + res.status(500).json({ error: 'combine_failed' }); } }); -server.listen(PORT, () => { - console.log(`Commander listening on http://localhost:${PORT}`); -}); +if (require.main === module) { + const port = process.env.PORT || 3000; + app.listen(port, () => console.log(`Commander v1 listening on http://localhost:${port}`)); +} + +module.exports = app; diff --git a/tests/api.test.js b/tests/api.test.js new file mode 100644 index 0000000..32562fb --- /dev/null +++ b/tests/api.test.js @@ -0,0 +1,15 @@ +const request = require('supertest'); +const app = require('../src/server'); + +test('GET /api/health returns ok', async () => { + const res = await request(app).get('/api/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); +}); + +test('POST /api/suggest returns suggestions in mock mode', async () => { + const res = await request(app).post('/api/suggest').send({ promptPartial: 'I want to know about' }); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.suggestions)).toBe(true); + expect(res.body.suggestions.length).toBeGreaterThan(0); +});