351 lines
10 KiB
JavaScript
351 lines
10 KiB
JavaScript
import express from "express";
|
|
import dotenv from "dotenv";
|
|
import cors from "cors";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { reflectWithCortex, ingestToCortex } from "./lib/cortex.js";
|
|
|
|
dotenv.config();
|
|
|
|
const sessionsDir = path.join(process.cwd(), "sessions");
|
|
if (!fs.existsSync(sessionsDir)) fs.mkdirSync(sessionsDir);
|
|
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
|
|
// Cache and normalize env flags/values once
|
|
const {
|
|
NEOMEM_API,
|
|
MEM0_API_KEY,
|
|
OPENAI_API_KEY,
|
|
OLLAMA_URL,
|
|
PERSONA_URL,
|
|
CORTEX_ENABLED,
|
|
PORT: PORT_ENV,
|
|
DEBUG_PROMPT,
|
|
} = process.env;
|
|
|
|
const PORT = Number(PORT_ENV) || 7078;
|
|
const cortexEnabled = String(CORTEX_ENABLED).toLowerCase() === "true";
|
|
const debugPrompt = String(DEBUG_PROMPT).toLowerCase() === "true";
|
|
|
|
// Basic env validation warnings (non-fatal)
|
|
if (!NEOMEM_API || !MEM0_API_KEY) {
|
|
console.warn("⚠️ NeoMem configuration missing: NEOMEM_API or MEM0_API_KEY not set.");
|
|
}
|
|
|
|
/* ------------------------------
|
|
Helpers for NeoMem REST API
|
|
--------------------------------*/
|
|
// Small helper for fetch with timeout + JSON + error detail
|
|
async function fetchJSON(url, options = {}, timeoutMs = 30000) {
|
|
const controller = new AbortController();
|
|
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const resp = await fetch(url, { ...options, signal: controller.signal });
|
|
const text = await resp.text();
|
|
const parsed = text ? JSON.parse(text) : null;
|
|
if (!resp.ok) {
|
|
const msg = parsed?.error || parsed?.message || text || resp.statusText;
|
|
throw new Error(`${resp.status} ${msg}`);
|
|
}
|
|
return parsed;
|
|
} finally {
|
|
clearTimeout(t);
|
|
}
|
|
}
|
|
|
|
async function memAdd(content, userId, sessionId, cortexData) {
|
|
const url = `${NEOMEM_API}/memories`;
|
|
const payload = {
|
|
messages: [{ role: "user", content }],
|
|
user_id: userId,
|
|
// run_id: sessionId,
|
|
metadata: { source: "relay", cortex: cortexData },
|
|
};
|
|
return fetchJSON(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${MEM0_API_KEY}`,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
async function memSearch(query, userId, sessionId) {
|
|
const url = `${NEOMEM_API}/search`;
|
|
const payload = { query, user_id: userId };
|
|
return fetchJSON(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${MEM0_API_KEY}`,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
/* ------------------------------
|
|
Utility to time spans
|
|
--------------------------------*/
|
|
async function span(name, fn) {
|
|
const start = Date.now();
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
console.log(`${name} took ${Date.now() - start}ms`);
|
|
}
|
|
}
|
|
|
|
/* ------------------------------
|
|
Healthcheck
|
|
--------------------------------*/
|
|
app.get("/_health", (req, res) => {
|
|
res.json({ ok: true, time: new Date().toISOString() });
|
|
});
|
|
|
|
/* ------------------------------
|
|
Sessions
|
|
--------------------------------*/
|
|
// List all saved sessions
|
|
app.get("/sessions", (_, res) => {
|
|
const list = fs.readdirSync(sessionsDir)
|
|
.filter(f => f.endsWith(".json"))
|
|
.map(f => f.replace(".json", ""));
|
|
res.json(list);
|
|
});
|
|
|
|
// Load a single session
|
|
app.get("/sessions/:id", (req, res) => {
|
|
const file = path.join(sessionsDir, `${req.params.id}.json`);
|
|
if (!fs.existsSync(file)) return res.json([]);
|
|
res.json(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
});
|
|
|
|
// Save or update a session
|
|
app.post("/sessions/:id", (req, res) => {
|
|
const file = path.join(sessionsDir, `${req.params.id}.json`);
|
|
fs.writeFileSync(file, JSON.stringify(req.body, null, 2));
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
/* ------------------------------
|
|
Chat completion endpoint
|
|
--------------------------------*/
|
|
app.post("/v1/chat/completions", async (req, res) => {
|
|
try {
|
|
const { model, messages, sessionId: clientSessionId } = req.body || {};
|
|
if (!Array.isArray(messages) || !messages.length) {
|
|
return res.status(400).json({ error: "invalid_messages" });
|
|
}
|
|
if (!model || typeof model !== "string") {
|
|
return res.status(400).json({ error: "invalid_model" });
|
|
}
|
|
|
|
const sessionId = clientSessionId || "default";
|
|
const userId = "brian"; // fixed for now
|
|
|
|
console.log(`🛰️ Incoming request. Session: ${sessionId}`);
|
|
|
|
// Find last user message efficiently
|
|
const lastUserMsg = [...messages].reverse().find(m => m.role === "user")?.content;
|
|
if (!lastUserMsg) {
|
|
return res.status(400).json({ error: "no_user_message" });
|
|
}
|
|
|
|
// 1. Cortex Reflection (new pipeline)
|
|
/*let reflection = {};
|
|
try {
|
|
console.log("🧠 Reflecting with Cortex...");
|
|
const memoriesPreview = []; // we'll fill this in later with memSearch
|
|
reflection = await reflectWithCortex(lastUserMsg, memoriesPreview);
|
|
console.log("🔍 Reflection:", reflection);
|
|
} catch (err) {
|
|
console.warn("⚠️ Cortex reflect failed:", err.message);
|
|
reflection = { error: err.message };
|
|
}*/
|
|
|
|
// 2. Search memories
|
|
/* let memorySnippets = [];
|
|
await span("mem.search", async () => {
|
|
if (NEOMEM_API && MEM0_API_KEY) {
|
|
try {
|
|
const { results } = await memSearch(lastUserMsg, userId, sessionId);
|
|
if (results?.length) {
|
|
console.log(`📚 Mem0 hits: ${results.length}`);
|
|
results.forEach((r, i) =>
|
|
console.log(` ${i + 1}) ${r.memory} (score ${Number(r.score).toFixed(3)})`)
|
|
);
|
|
memorySnippets = results.map((r, i) => `${i + 1}) ${r.memory}`);
|
|
} else {
|
|
console.log("😴 No memories found");
|
|
}
|
|
} catch (e) {
|
|
console.warn("⚠️ mem.search failed:", e.message);
|
|
}
|
|
}
|
|
});*/
|
|
|
|
// 3. Fetch persona
|
|
/* let personaText = "Persona: Lyra 🤖 friendly, concise, poker-savvy.";
|
|
await span("persona.fetch", async () => {
|
|
try {
|
|
if (PERSONA_URL) {
|
|
const data = await fetchJSON(PERSONA_URL);
|
|
if (data?.persona) {
|
|
const name = data.persona.name ?? "Lyra";
|
|
const style = data.persona.style ?? "friendly, concise";
|
|
const protocols = Array.isArray(data.persona.protocols) ? data.persona.protocols.join(", ") : "";
|
|
personaText = `Persona: ${name} 🤖 ${style}. Protocols: ${protocols}`.trim();
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("💥 persona.fetch failed", err);
|
|
}
|
|
}); */
|
|
|
|
// 1. Ask Cortex to build the final prompt
|
|
let cortexPrompt = "";
|
|
try {
|
|
console.log("🧠 Requesting prompt from Cortex...");
|
|
const response = await fetch(`${process.env.CORTEX_API_URL || "http://10.0.0.41:7081"}/reason`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
prompt: lastUserMsg,
|
|
session_id: sessionId,
|
|
user_id: userId
|
|
})
|
|
});
|
|
const data = await response.json();
|
|
cortexPrompt = data.full_prompt || data.prompt || "";
|
|
console.log("🧩 Cortex returned prompt");
|
|
} catch (err) {
|
|
console.warn("⚠️ Cortex prompt build failed:", err.message);
|
|
}
|
|
|
|
|
|
// 4. Build final messages
|
|
const injectedMessages = [
|
|
{ role: "system", content: cortexPrompt || "You are Lyra." },
|
|
...messages,
|
|
];
|
|
|
|
if (debugPrompt) {
|
|
console.log("\n==== Injected Prompt ====");
|
|
console.log(JSON.stringify(injectedMessages, null, 2));
|
|
console.log("=========================\n");
|
|
}
|
|
|
|
// 5. Call LLM (OpenAI or Ollama)
|
|
const isOllama = model.startsWith("ollama:");
|
|
const llmUrl = isOllama
|
|
? `${OLLAMA_URL}/api/chat`
|
|
: "https://api.openai.com/v1/chat/completions";
|
|
|
|
const llmHeaders = isOllama
|
|
? { "Content-Type": "application/json" }
|
|
: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
|
};
|
|
|
|
const llmBody = {
|
|
model: isOllama ? model.replace("ollama:", "") : model,
|
|
messages: injectedMessages, // <-- make sure injectedMessages is defined above this section
|
|
stream: false,
|
|
};
|
|
|
|
const data = await fetchJSON(llmUrl, {
|
|
method: "POST",
|
|
headers: llmHeaders,
|
|
body: JSON.stringify(llmBody),
|
|
});
|
|
|
|
// define once for everything below
|
|
const assistantReply = isOllama
|
|
? data?.message?.content
|
|
: data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || "";
|
|
|
|
// 🧠 Send exchange back to Cortex for ingest
|
|
try {
|
|
await ingestToCortex(lastUserMsg, assistantReply || "", {}, sessionId);
|
|
console.log("📤 Sent exchange back to Cortex ingest");
|
|
} catch (err) {
|
|
console.warn("⚠️ Cortex ingest failed:", err.message);
|
|
}
|
|
|
|
// 💾 Save exchange to session log
|
|
try {
|
|
const logFile = path.join(sessionsDir, `${sessionId}.jsonl`);
|
|
const entry = JSON.stringify({
|
|
ts: new Date().toISOString(),
|
|
turn: [
|
|
{ role: "user", content: lastUserMsg },
|
|
{ role: "assistant", content: assistantReply || "" }
|
|
]
|
|
}) + "\n";
|
|
fs.appendFileSync(logFile, entry, "utf8");
|
|
console.log(`🧠 Logged session exchange → ${logFile}`);
|
|
} catch (e) {
|
|
console.warn("⚠️ Session log write failed:", e.message);
|
|
}
|
|
|
|
// 🔄 Forward user↔assistant exchange to Intake summarizer
|
|
if (process.env.INTAKE_API_URL) {
|
|
try {
|
|
const intakePayload = {
|
|
session_id: sessionId,
|
|
turns: [
|
|
{ role: "user", content: lastUserMsg },
|
|
{ role: "assistant", content: assistantReply || "" }
|
|
]
|
|
};
|
|
|
|
await fetch(process.env.INTAKE_API_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(intakePayload),
|
|
});
|
|
|
|
console.log("📨 Sent exchange to Intake summarizer");
|
|
} catch (err) {
|
|
console.warn("⚠️ Intake post failed:", err.message);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (isOllama) {
|
|
res.json({
|
|
id: "ollama-" + Date.now(),
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: data?.message || { role: "assistant", content: "" },
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
});
|
|
} else {
|
|
res.json(data);
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error("💥 relay error", err);
|
|
res.status(500).json({ error: "relay_failed", detail: err.message });
|
|
}
|
|
});
|
|
|
|
/* ------------------------------
|
|
Start server
|
|
--------------------------------*/
|
|
app.listen(PORT, () => {
|
|
console.log(`Relay listening on port ${PORT}`);
|
|
});
|