From 5497f332e922155d55fc3c94cbd06ef1dac8a082 Mon Sep 17 00:00:00 2001 From: wbaxterh Date: Thu, 23 Apr 2026 15:07:58 -0700 Subject: [PATCH 1/6] feat: add Kith voice sidecar and wire bot responses to TTS Adds kith-voice/ Bun service that bridges Kith's PipecatRuntime to browser WebSocket clients for streaming ElevenLabs TTS. The backend dm.js route now fires a POST to the Kith voice service after emitting a bot message, keyed by the x-kith-session header from the web client. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 3 + .mcp.json | 16 - kith-voice/.env.example | 9 + kith-voice/.gitignore | 3 + kith-voice/package.json | 19 + kith-voice/src/kaori-character.json | 31 + kith-voice/src/server.ts | 229 +++++++ routes/dm.js | 16 + routes/spots.js.bak | 953 ---------------------------- 9 files changed, 310 insertions(+), 969 deletions(-) delete mode 100644 .mcp.json create mode 100644 kith-voice/.env.example create mode 100644 kith-voice/.gitignore create mode 100644 kith-voice/package.json create mode 100644 kith-voice/src/kaori-character.json create mode 100644 kith-voice/src/server.ts delete mode 100644 routes/spots.js.bak diff --git a/.env.example b/.env.example index b80230b..75ec415 100644 --- a/.env.example +++ b/.env.example @@ -35,5 +35,8 @@ EMAIL_PASSWORD=your-email-password # Frontend FRONTEND_URL=http://localhost:3000 +# Kith Voice Service (Kaori TTS sidecar) +KITH_VOICE_URL=http://localhost:3040 + # Server PORT=9000 diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 2c0245b..0000000 --- a/.mcp.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "mcpServers": { - "mongodb": { - "type": "stdio", - "command": "npx", - "args": [ - "-y", - "mongodb-mcp-server@latest", - "--readOnly" - ], - "env": { - "MDB_MCP_CONNECTION_STRING": "mongodb+srv://wes:triforce3@cluster0.v1sxt.gcp.mongodb.net/myFirstDatabase?retryWrites=true&w=majority" - } - } - } -} diff --git a/kith-voice/.env.example b/kith-voice/.env.example new file mode 100644 index 0000000..92d7d8c --- /dev/null +++ b/kith-voice/.env.example @@ -0,0 +1,9 @@ +# ElevenLabs credentials +ELEVENLABS_API_KEY=sk_your_key_here +ELEVENLABS_VOICE_ID=klHOJHbGA89BjwulA7MN + +# Server port (default 3040) +PORT=3040 + +# ElevenLabs model (default eleven_v3, required for laugh tags) +# ELEVENLABS_MODEL_ID=eleven_v3 diff --git a/kith-voice/.gitignore b/kith-voice/.gitignore new file mode 100644 index 0000000..50ac578 --- /dev/null +++ b/kith-voice/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +bun.lockb diff --git a/kith-voice/package.json b/kith-voice/package.json new file mode 100644 index 0000000..1bbbe75 --- /dev/null +++ b/kith-voice/package.json @@ -0,0 +1,19 @@ +{ + "name": "kith-voice-kaori", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --hot src/server.ts", + "start": "bun src/server.ts" + }, + "dependencies": { + "@kith/core": "file:../../../../kith/packages/core", + "@kith/runtime-pipecat": "file:../../../../kith/packages/runtime-pipecat", + "@kith/voice-router": "file:../../../../kith/packages/voice-router", + "@kith/observability": "file:../../../../kith/packages/observability" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} diff --git a/kith-voice/src/kaori-character.json b/kith-voice/src/kaori-character.json new file mode 100644 index 0000000..fdbf544 --- /dev/null +++ b/kith-voice/src/kaori-character.json @@ -0,0 +1,31 @@ +{ + "voice": { + "stability": 0.35, + "similarityBoost": 0.85, + "style": 0.65, + "useSpeakerBoost": true + }, + "slang": { + "gg": "good game", + "ngl": "not gonna lie", + "fr": "for real", + "fs": "frontside", + "bs": "backside", + "tb": "TrickBook" + }, + "pronunciation": { + "uso": "oo-so", + "ganbare": "gahn-bah-ray", + "itadakimasu": "ee-tah-dah-kee-mah-su", + "arigato": "ah-ree-gah-toh", + "sugoi": "soo-goy", + "yatta": "yah-tah", + "kawaii": "kah-wah-ee", + "ne": "neh", + "Kaori": "kah-oh-ree", + "Nishidake": "nee-shee-dah-keh", + "Sapporo": "sah-poh-roh", + "Hokkaido": "hoh-kai-doh" + }, + "personaMode": "hype" +} diff --git a/kith-voice/src/server.ts b/kith-voice/src/server.ts new file mode 100644 index 0000000..6e6c42d --- /dev/null +++ b/kith-voice/src/server.ts @@ -0,0 +1,229 @@ +/** + * Kith voice service for Kaori. + * + * Architecture: + * Browser <-- WS --> this server <-- WS --> Python sidecar (Pipecat) + * Backend --> HTTP POST /speak/:sessionId --> this server + * + * Each browser session gets its own PipecatRuntime (= its own Python + * subprocess). The Backend fires text into it after generating Kaori's + * response; the browser receives KithEvents (audio chunks, emotion, etc.) + * over the WebSocket. + */ + +import path from "node:path"; +import type { KithEvent } from "@kith/core"; +import { PipecatRuntime } from "@kith/runtime-pipecat"; +import { InMemoryObservability, consoleExporter } from "@kith/observability"; +import { + DEFAULT_BOARD_SPORTS_SLANG, + DEFAULT_ENGLISH_SLANG, + DEFAULT_GENZ_SLANG, + DEFAULT_LAUGH_TAGS, + VoiceRouter, + voiceCharacterToRuntimeConfig, + type VoiceCharacter, +} from "@kith/voice-router"; +import type { ServerWebSocket } from "bun"; + +import kaoriProfile from "./kaori-character.json" with { type: "json" }; + +const character = kaoriProfile as VoiceCharacter; + +const PORT = Number(process.env.PORT ?? 3040); +const ROOT = path.dirname(Bun.fileURLToPath(import.meta.url)); +const PYTHON_VENV = path.resolve( + ROOT, + "../../../../kith/packages/runtime-pipecat/python/.venv/bin/python", +); +const PYTHON_CWD = path.resolve( + ROOT, + "../../../../kith/packages/runtime-pipecat/python", +); + +const apiKey = process.env.ELEVENLABS_API_KEY; +const voiceId = process.env.ELEVENLABS_VOICE_ID ?? "klHOJHbGA89BjwulA7MN"; +const modelId = process.env.ELEVENLABS_MODEL_ID ?? "eleven_v3"; + +if (!apiKey) { + console.error( + "ELEVENLABS_API_KEY must be set. See kith-voice/.env.example", + ); + process.exit(2); +} + +const KAORI_SLANG = { + ...DEFAULT_ENGLISH_SLANG, + ...DEFAULT_GENZ_SLANG, + ...DEFAULT_BOARD_SPORTS_SLANG, + ...DEFAULT_LAUGH_TAGS, + ...(character.slang ?? {}), +}; + +interface Session { + runtime: PipecatRuntime; + voice: VoiceRouter; + obs: InMemoryObservability; + unsubscribe: () => void; + ws: ServerWebSocket; +} + +interface WsData { + sessionId: string; +} + +const sessions = new Map(); + +async function createSession( + sessionId: string, + ws: ServerWebSocket, +): Promise { + const obs = new InMemoryObservability(); + obs.onRecord(consoleExporter); + + const runtime = new PipecatRuntime({ + pythonPath: PYTHON_VENV, + cwd: PYTHON_CWD, + observability: obs, + config: { + pipeline: "elevenlabs", + apiKey, + voiceId, + modelId, + ...voiceCharacterToRuntimeConfig(character), + outputFormat: "mp3_44100_128", + }, + }); + + await runtime.connect({ sessionId }); + + const voice = new VoiceRouter({ + runtime, + character, + slang: KAORI_SLANG, + }); + + const unsubscribe = voice.on((event: KithEvent) => { + try { + ws.send(JSON.stringify(event)); + } catch { + // ws closed + } + }); + + return { runtime, voice, obs, unsubscribe, ws }; +} + +async function teardownSession(sessionId: string): Promise { + const session = sessions.get(sessionId); + if (!session) return; + sessions.delete(sessionId); + session.unsubscribe(); + session.voice.destroy(); + try { + await session.runtime.disconnect(); + } catch (err) { + console.error(`[kith] disconnect failed session=${sessionId}:`, err); + } + console.log(`[kith] session torn down: ${sessionId}`); +} + +const server = Bun.serve({ + port: PORT, + async fetch(req, server) { + const url = new URL(req.url); + + // WebSocket upgrade for browser clients + if (url.pathname === "/ws") { + const sessionId = crypto.randomUUID(); + const ok = server.upgrade(req, { data: { sessionId } }); + if (ok) return undefined; + return new Response("WebSocket upgrade failed", { status: 500 }); + } + + // HTTP endpoint for Backend to trigger speech + if (url.pathname.startsWith("/speak/") && req.method === "POST") { + const sessionId = url.pathname.slice("/speak/".length); + const session = sessions.get(sessionId); + if (!session) { + return Response.json( + { error: "session not found", sessionId }, + { status: 404 }, + ); + } + + try { + const body = (await req.json()) as { text: string }; + if (!body.text || typeof body.text !== "string") { + return Response.json({ error: "text is required" }, { status: 400 }); + } + // Fire-and-forget: speak runs async, we respond immediately + session.voice.speak(body.text).catch((err) => { + console.error(`[kith] speak failed session=${sessionId}:`, err); + }); + return Response.json({ ok: true, sessionId }); + } catch (err) { + console.error(`[kith] /speak error:`, err); + return Response.json({ error: "internal error" }, { status: 500 }); + } + } + + // Health check + if (url.pathname === "/health") { + return Response.json({ + ok: true, + sessions: sessions.size, + uptime: process.uptime(), + }); + } + + return new Response("Kith voice service for Kaori", { status: 200 }); + }, + websocket: { + async open(ws: ServerWebSocket) { + const { sessionId } = ws.data; + console.log(`[kith] ws open session=${sessionId}`); + + try { + const session = await createSession(sessionId, ws); + sessions.set(sessionId, session); + ws.send(JSON.stringify({ type: "_ready", sessionId })); + } catch (err) { + console.error( + `[kith] session create failed session=${sessionId}:`, + err, + ); + ws.close(1011, "runtime connect failed"); + } + }, + async message(ws: ServerWebSocket, raw) { + const { sessionId } = ws.data; + const session = sessions.get(sessionId); + if (!session) return; + + let msg: { type: string; text?: string }; + try { + msg = JSON.parse(String(raw)); + } catch { + return; + } + + if (msg.type === "speak" && typeof msg.text === "string") { + try { + await session.voice.speak(msg.text); + } catch (err) { + console.error(`[kith] speak failed session=${sessionId}:`, err); + } + } else if (msg.type === "barge-in") { + await session.runtime.bargeIn(); + } + }, + async close(ws: ServerWebSocket) { + const { sessionId } = ws.data; + console.log(`[kith] ws close session=${sessionId}`); + await teardownSession(sessionId); + }, + }, +}); + +console.log(`[kith] Kaori voice service listening on http://localhost:${server.port}`); diff --git a/routes/dm.js b/routes/dm.js index 5bfd472..ef8d9c9 100644 --- a/routes/dm.js +++ b/routes/dm.js @@ -322,6 +322,8 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) // Get io reference NOW (before async) const ioRef = req.app.get('io'); const messagesNs = ioRef ? ioRef.of('/messages') : null; + // Kith voice session ID (sent by Kaori Live web client) + const kithSessionId = req.headers['x-kith-session'] || ''; // Run bot response with typing delay (async () => { @@ -461,6 +463,20 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) } else { console.log(`[Bot] ${character} responded but no socket available`); } + + // 8. Fire-and-forget: send bot text to Kith voice service for TTS + if (kithSessionId && process.env.KITH_VOICE_URL) { + const kithPayload = JSON.stringify({ text: botResponseText }); + const kithUrl = new URL(`/speak/${kithSessionId}`, process.env.KITH_VOICE_URL); + const kithReq = http.request(kithUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(kithPayload) }, + }); + kithReq.on('error', (e) => console.error('[Bot] Kith voice request error:', e.message)); + kithReq.setTimeout(5000, () => kithReq.destroy()); + kithReq.write(kithPayload); + kithReq.end(); + } } catch (botErr) { console.error('Bot response error:', botErr.message); // Stop typing on error diff --git a/routes/spots.js.bak b/routes/spots.js.bak deleted file mode 100644 index 533712c..0000000 --- a/routes/spots.js.bak +++ /dev/null @@ -1,953 +0,0 @@ -const express = require("express"); -const router = express.Router(); -const Joi = require("joi"); -const axios = require("axios"); -const { MongoClient, ObjectId } = require("mongodb"); -const validateWith = require("../middleware/validation"); -const auth = require("../middleware/auth"); -const authAdmin = require("../middleware/authAdmin"); -const multer = require("multer"); -const googlePlaces = require("../services/googlePlaces"); -const s3Upload = require("../services/s3Upload"); -const connectionString = process.env.ATLAS_URI; - -// Configure multer for memory storage (for S3 upload) -const upload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit - fileFilter: (req, file, cb) => { - if (file.mimetype.startsWith("image/")) { - cb(null, true); - } else { - cb(new Error("Only image files are allowed"), false); - } - }, -}); - -// Sport types for filtering -const SPORT_TYPES = [ - "skateboarding", - "snowboarding", - "skiing", - "bmx", - "mtb", - "scooter", - "rollerblading", - "surfing", - "wakeboarding", -]; - -const schema = { - name: Joi.string().required(), - latitude: Joi.number().required(), - longitude: Joi.number().required(), - imageURL: Joi.string().uri().allow("").allow(null).optional(), - description: Joi.string().allow("").optional(), - rating: Joi.number().min(0).max(5).optional(), - tags: Joi.string().allow("").optional(), - city: Joi.string().allow("").optional(), - state: Joi.string().allow("").optional(), - isPublic: Joi.boolean().optional(), - sportTypes: Joi.array().items(Joi.string().valid(...SPORT_TYPES)).optional(), - category: Joi.string().valid("park", "street", "indoor", "diy", "other").optional(), -}; - -const updateSchema = { - name: Joi.string().optional(), - latitude: Joi.number().optional(), - longitude: Joi.number().optional(), - imageURL: Joi.string().uri().allow("").allow(null).optional(), - description: Joi.string().allow("").optional(), - rating: Joi.number().min(0).max(5).optional(), - tags: Joi.string().allow("").optional(), - city: Joi.string().allow("").optional(), - state: Joi.string().allow("").optional(), - isPublic: Joi.boolean().optional(), - sportTypes: Joi.array().items(Joi.string().valid(...SPORT_TYPES)).optional(), - category: Joi.string().valid("park", "street", "indoor", "diy", "other").optional(), -}; - -const approvalSchema = { - status: Joi.string().valid("approved", "rejected").required(), - rejectionReason: Joi.string().allow("").optional(), -}; - -// Get available sport types - defined outside MongoDB callback since it doesn't need DB -router.get("/sport-types", (req, res) => { - res.json({ - sportTypes: SPORT_TYPES.map((sport) => ({ - value: sport, - label: sport.charAt(0).toUpperCase() + sport.slice(1), - })), - }); -}); - -// Search Google Places for autocomplete/location finding -// GET /api/spots/places-search?query=skatepark&lat=40.7&lng=-74 -router.get("/places-search", auth, async (req, res) => { - const { query, lat, lng } = req.query; - - if (!query) { - return res.status(400).json({ error: "Query parameter is required" }); - } - - try { - const latitude = lat ? parseFloat(lat) : null; - const longitude = lng ? parseFloat(lng) : null; - - // Use the Google Places text search - let results = []; - - if (latitude && longitude) { - // Search near the provided location - const place = await googlePlaces.findPlaceByTextSearch(query, latitude, longitude, 50000); - if (place) { - // Get full details for the place - const details = await googlePlaces.getPlaceDetails(place.place_id); - results.push({ - placeId: place.place_id, - name: place.name, - address: place.formatted_address || place.vicinity, - latitude: place.geometry?.location?.lat, - longitude: place.geometry?.location?.lng, - rating: details?.rating, - types: place.types, - photos: details?.photos?.slice(0, 3).map(p => ({ - reference: p.photo_reference, - })) || [], - }); - } - } else { - // General text search without location bias - const response = await axios.get(`https://maps.googleapis.com/maps/api/place/textsearch/json`, { - params: { - query: query, - key: process.env.GOOGLE_PLACES_API_KEY, - }, - }); - - if (response.data.results) { - results = response.data.results.slice(0, 10).map(place => ({ - placeId: place.place_id, - name: place.name, - address: place.formatted_address, - latitude: place.geometry?.location?.lat, - longitude: place.geometry?.location?.lng, - rating: place.rating, - types: place.types, - })); - } - } - - res.json({ results }); - } catch (error) { - console.error("Places search error:", error.message); - res.status(500).json({ error: "Failed to search places" }); - } -}); - -// Get place details by placeId -// GET /api/spots/places/:placeId -router.get("/places/:placeId", auth, async (req, res) => { - const { placeId } = req.params; - - if (!placeId) { - return res.status(400).json({ error: "Place ID is required" }); - } - - try { - const details = await googlePlaces.getPlaceDetails(placeId); - - if (!details) { - return res.status(404).json({ error: "Place not found" }); - } - - res.json({ - placeId: placeId, - name: details.name, - address: details.formatted_address, - latitude: details.geometry?.location?.lat, - longitude: details.geometry?.location?.lng, - rating: details.rating, - reviewCount: details.user_ratings_total, - types: details.types, - openingHours: details.opening_hours, - photos: details.photos?.slice(0, 5).map(p => ({ - reference: p.photo_reference, - attribution: p.html_attributions?.[0], - })) || [], - }); - } catch (error) { - console.error("Place details error:", error.message); - res.status(500).json({ error: "Failed to get place details" }); - } -}); - -// Reverse geocode coordinates to get address -// GET /api/spots/reverse-geocode?lat=40.7&lng=-74 -router.get("/reverse-geocode", auth, async (req, res) => { - const { lat, lng } = req.query; - - if (!lat || !lng) { - return res.status(400).json({ error: "lat and lng parameters are required" }); - } - - try { - const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json`, { - params: { - latlng: `${lat},${lng}`, - key: process.env.GOOGLE_PLACES_API_KEY, - }, - }); - - if (response.data.results && response.data.results.length > 0) { - const result = response.data.results[0]; - - // Extract city and state from address components - let city = ""; - let state = ""; - let country = ""; - - for (const component of result.address_components || []) { - if (component.types.includes("locality")) { - city = component.long_name; - } else if (component.types.includes("administrative_area_level_1")) { - state = component.short_name; - } else if (component.types.includes("country")) { - country = component.short_name; - } - } - - res.json({ - address: result.formatted_address, - city, - state, - country, - placeId: result.place_id, - }); - } else { - res.json({ address: null, city: "", state: "", country: "" }); - } - } catch (error) { - console.error("Reverse geocode error:", error.message); - res.status(500).json({ error: "Failed to reverse geocode" }); - } -}); - -MongoClient.connect(connectionString, { useUnifiedTopology: true }) - .then((client) => { - const db = client.db("TrickList2"); - const spotsCollection = db.collection("spots"); - const spotListsCollection = db.collection("spotlists"); - - // Create a new spot - router.post("/", [auth, validateWith(schema)], async (req, res) => { - const { - name, - latitude, - longitude, - imageURL, - description, - rating, - tags, - city, - state, - isPublic, - sportTypes, - category, - } = req.body; - - // Check if spot already exists by lat/long - const existingSpot = await spotsCollection.findOne({ - latitude: latitude, - longitude: longitude, - }); - - if (existingSpot) { - return res.status(200).json(existingSpot); - } - - // Determine approval status based on isPublic flag - let approvalStatus = "private"; - if (isPublic === true) { - approvalStatus = "pending"; - } - - const spot = { - name, - latitude, - longitude, - imageURL: imageURL || null, - description: description || "", - rating: rating || null, - tags: tags || "", - city: city || "", - state: state || "", - isPublic: isPublic || false, - sportTypes: sportTypes || [], - category: category || "other", - approvalStatus, - userId: ObjectId(req.user.userId), - createdAt: new Date(), - }; - - // Add submittedAt if submitted for public approval - if (isPublic === true) { - spot.submittedAt = new Date(); - } - - try { - const result = await spotsCollection.insertOne(spot); - spot._id = result.insertedId; - res.status(201).json(spot); - } catch (error) { - console.error("Error creating spot", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - // Bulk insert spots - router.post("/bulk", [auth], async (req, res) => { - const { parks } = req.body; - if (!Array.isArray(parks) || parks.length === 0) { - return res - .status(400) - .json({ error: "parks must be a non-empty array" }); - } - // Validate each spot (reuse schema) - const invalid = parks.find((spot) => { - const { error } = Joi.validate(spot, schema); - return error; - }); - if (invalid) { - return res.status(400).json({ error: "One or more spots are invalid" }); - } - try { - const processedSpots = []; - - for (const park of parks) { - // Check if spot already exists by lat/long - const existingSpot = await spotsCollection.findOne({ - latitude: park.latitude, - longitude: park.longitude, - }); - - if (existingSpot) { - processedSpots.push(existingSpot); - } else { - // Insert new spot - const result = await spotsCollection.insertOne(park); - const newSpot = { ...park, _id: result.insertedId }; - processedSpots.push(newSpot); - } - } - - res.status(201).json(processedSpots); - } catch (error) { - console.error("Error bulk inserting spots", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - // Get all approved public spots with pagination and filtering - router.get("/", async (req, res) => { - try { - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 50; - const skip = (page - 1) * limit; - const sort = req.query.sort || "name"; - const order = req.query.order === "desc" ? -1 : 1; - const { sportType, category, q } = req.query; - - // Only return approved public spots for public API - const query = { approvalStatus: "approved" }; - - // Filter by sport type - if (sportType && sportType !== "all") { - query.sportTypes = sportType; - } - - // Filter by category (park, street, indoor, diy) - if (category && category !== "all") { - query.category = category; - } - - // Search by name - if (q) { - query.name = { $regex: q, $options: "i" }; - } - - const totalCount = await spotsCollection.countDocuments(query); - const spots = await spotsCollection - .find(query) - .sort({ [sort]: order }) - .skip(skip) - .limit(limit) - .toArray(); - - res.status(200).json({ - spots, - pagination: { - page, - limit, - totalCount, - totalPages: Math.ceil(totalCount / limit), - hasMore: page * limit < totalCount, - }, - }); - } catch (error) { - console.error("Error retrieving spots", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - // Get all spots (including unapproved) - Admin only - router.get("/all", [authAdmin()], async (req, res) => { - try { - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 50; - const skip = (page - 1) * limit; - const sort = req.query.sort || "name"; - const order = req.query.order === "desc" ? -1 : 1; - - const totalCount = await spotsCollection.countDocuments(); - const spots = await spotsCollection - .find() - .sort({ [sort]: order }) - .skip(skip) - .limit(limit) - .toArray(); - - res.status(200).json({ - spots, - pagination: { - page, - limit, - totalCount, - totalPages: Math.ceil(totalCount / limit), - hasMore: page * limit < totalCount, - }, - }); - } catch (error) { - console.error("Error retrieving all spots", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - // Get pending spots for admin review - router.get("/pending", [authAdmin()], async (req, res) => { - try { - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 50; - const skip = (page - 1) * limit; - - const query = { approvalStatus: "pending" }; - - const totalCount = await spotsCollection.countDocuments(query); - const spots = await spotsCollection - .find(query) - .sort({ submittedAt: -1 }) - .skip(skip) - .limit(limit) - .toArray(); - - res.status(200).json({ - spots, - pagination: { - page, - limit, - totalCount, - totalPages: Math.ceil(totalCount / limit), - hasMore: page * limit < totalCount, - }, - }); - } catch (error) { - console.error("Error retrieving pending spots", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - // Search spots with filters (only approved spots) - router.get("/search", async (req, res) => { - try { - const { q, city, state, tags } = req.query; - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 50; - const skip = (page - 1) * limit; - - // Only search approved spots for public API - const query = { approvalStatus: "approved" }; - - // Text search on name - if (q) { - query.name = { $regex: q, $options: "i" }; - } - - // Filter by city - if (city) { - query.city = { $regex: city, $options: "i" }; - } - - // Filter by state - if (state) { - query.state = { $regex: `^${state}$`, $options: "i" }; - } - - // Filter by tags (comma-separated) - if (tags) { - const tagList = tags.split(",").map((t) => t.trim()); - query.tags = { - $regex: tagList.map((t) => `(?=.*${t})`).join(""), - $options: "i", - }; - } - - const totalCount = await spotsCollection.countDocuments(query); - const spots = await spotsCollection - .find(query) - .sort({ name: 1 }) - .skip(skip) - .limit(limit) - .toArray(); - - res.status(200).json({ - spots, - pagination: { - page, - limit, - totalCount, - totalPages: Math.ceil(totalCount / limit), - hasMore: page * limit < totalCount, - }, - }); - } catch (error) { - console.error("Error searching spots", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - // Get user's own spots (all statuses) - router.get("/my-spots", [auth], async (req, res) => { - try { - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 50; - const skip = (page - 1) * limit; - - const query = { userId: ObjectId(req.user.userId) }; - - const totalCount = await spotsCollection.countDocuments(query); - const spots = await spotsCollection - .find(query) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit) - .toArray(); - - res.status(200).json({ - spots, - pagination: { - page, - limit, - totalCount, - totalPages: Math.ceil(totalCount / limit), - hasMore: page * limit < totalCount, - }, - }); - } catch (error) { - console.error("Error retrieving user spots", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - // Get a single spot by ID - router.get("/:id", async (req, res) => { - const id = req.params.id; - if (!ObjectId.isValid(id)) { - return res.status(400).json({ error: "Invalid ID" }); - } - try { - const spot = await spotsCollection.findOne({ _id: ObjectId(id) }); - if (!spot) { - return res.status(404).json({ error: "Spot not found" }); - } - res.status(200).json(spot); - } catch (error) { - console.error("Error retrieving spot", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - // Update a spot - router.put( - "/:id", - [auth, validateWith(updateSchema)], - async (req, res) => { - const id = req.params.id; - if (!ObjectId.isValid(id)) { - return res.status(400).json({ error: "Invalid ID" }); - } - - const { - name, - latitude, - longitude, - imageURL, - description, - rating, - tags, - city, - state, - sportTypes, - category, - } = req.body; - - // Build update object with only provided fields - const updateFields = {}; - if (name !== undefined) updateFields.name = name; - if (latitude !== undefined) updateFields.latitude = latitude; - if (longitude !== undefined) updateFields.longitude = longitude; - if (imageURL !== undefined) updateFields.imageURL = imageURL; - if (description !== undefined) updateFields.description = description; - if (rating !== undefined) updateFields.rating = rating; - if (tags !== undefined) updateFields.tags = tags; - if (city !== undefined) updateFields.city = city; - if (state !== undefined) updateFields.state = state; - if (sportTypes !== undefined) updateFields.sportTypes = sportTypes; - if (category !== undefined) updateFields.category = category; - updateFields.updatedAt = new Date(); - - try { - const result = await spotsCollection.updateOne( - { _id: ObjectId(id) }, - { $set: updateFields } - ); - - if (result.matchedCount === 0) { - return res.status(404).json({ error: "Spot not found" }); - } - - const updatedSpot = await spotsCollection.findOne({ - _id: ObjectId(id), - }); - res.status(200).json(updatedSpot); - } catch (error) { - console.error("Error updating spot", error); - res.status(500).json({ error: "Internal Server Error" }); - } - } - ); - - // Approve a spot (admin only) - router.put("/:id/approve", [authAdmin(), validateWith(approvalSchema)], async (req, res) => { - const id = req.params.id; - if (!ObjectId.isValid(id)) { - return res.status(400).json({ error: "Invalid ID" }); - } - - try { - const updateFields = { - approvalStatus: "approved", - reviewedAt: new Date(), - reviewedBy: ObjectId(req.user.userId), - updatedAt: new Date(), - }; - - const result = await spotsCollection.updateOne( - { _id: ObjectId(id) }, - { $set: updateFields } - ); - - if (result.matchedCount === 0) { - return res.status(404).json({ error: "Spot not found" }); - } - - const updatedSpot = await spotsCollection.findOne({ _id: ObjectId(id) }); - res.status(200).json(updatedSpot); - } catch (error) { - console.error("Error approving spot", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - // Reject a spot (admin only) - router.put("/:id/reject", [authAdmin(), validateWith(approvalSchema)], async (req, res) => { - const id = req.params.id; - if (!ObjectId.isValid(id)) { - return res.status(400).json({ error: "Invalid ID" }); - } - - const { rejectionReason } = req.body; - - try { - const updateFields = { - approvalStatus: "rejected", - rejectionReason: rejectionReason || "", - reviewedAt: new Date(), - reviewedBy: ObjectId(req.user.userId), - updatedAt: new Date(), - }; - - const result = await spotsCollection.updateOne( - { _id: ObjectId(id) }, - { $set: updateFields } - ); - - if (result.matchedCount === 0) { - return res.status(404).json({ error: "Spot not found" }); - } - - const updatedSpot = await spotsCollection.findOne({ _id: ObjectId(id) }); - res.status(200).json(updatedSpot); - } catch (error) { - console.error("Error rejecting spot", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - // Delete a spot (admin only) - router.delete("/:id", [authAdmin()], async (req, res) => { - const id = req.params.id; - if (!ObjectId.isValid(id)) { - return res.status(400).json({ error: "Invalid ID" }); - } - - try { - // First, remove the spot from all spot lists - await spotListsCollection.updateMany( - { spotIds: ObjectId(id) }, - { $pull: { spotIds: ObjectId(id) } } - ); - - // Then delete the spot - const result = await spotsCollection.deleteOne({ _id: ObjectId(id) }); - - if (result.deletedCount === 0) { - return res.status(404).json({ error: "Spot not found" }); - } - - res.status(200).json({ message: "Spot deleted successfully" }); - } catch (error) { - console.error("Error deleting spot", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - // Get lists containing a specific spot - router.get("/:id/lists", [auth], async (req, res) => { - const spotId = req.params.id; - if (!ObjectId.isValid(spotId)) { - return res.status(400).json({ error: "Invalid spot ID" }); - } - try { - const spotLists = await spotListsCollection - .find({ - spotIds: ObjectId(spotId), - userId: req.user.userId, - }) - .toArray(); - res.status(200).json(spotLists); - } catch (error) { - console.error("Error retrieving spot lists", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - /** - * GET /api/spots/:id/places-info - * Fetch and cache Google Places data for a spot - */ - router.get("/:id/places-info", async (req, res) => { - const id = req.params.id; - if (!ObjectId.isValid(id)) { - return res.status(400).json({ error: "Invalid ID" }); - } - - try { - const spot = await spotsCollection.findOne({ _id: ObjectId(id) }); - if (!spot) { - return res.status(404).json({ error: "Spot not found" }); - } - - // Check if we already have cached Google Places data (cache for 30 days) - const cacheAge = spot.googlePlacesCachedAt - ? Date.now() - new Date(spot.googlePlacesCachedAt).getTime() - : Infinity; - const thirtyDays = 30 * 24 * 60 * 60 * 1000; - - if (spot.googlePlaceId && spot.googlePhotos && cacheAge < thirtyDays) { - return res.json({ - cached: true, - placeId: spot.googlePlaceId, - googlePhotos: spot.googlePhotos, - placeData: spot.googlePlaceData, - }); - } - - // Fetch from Google Places API - const placeData = await googlePlaces.fetchAndCachePlaceData( - spot.name, - spot.latitude, - spot.longitude, - id.toString(), - 5 // max 5 photos - ); - - if (!placeData.found) { - return res.json({ found: false, message: "No matching place found on Google" }); - } - - // Update spot with cached data - await spotsCollection.updateOne( - { _id: ObjectId(id) }, - { - $set: { - googlePlaceId: placeData.placeId, - googlePhotos: placeData.googlePhotos, - googlePlaceData: placeData.placeData, - googlePlacesCachedAt: new Date(), - }, - } - ); - - res.json({ - cached: false, - placeId: placeData.placeId, - googlePhotos: placeData.googlePhotos, - placeData: placeData.placeData, - }); - } catch (error) { - console.error("Error fetching places info:", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - /** - * GET /api/spots/:id/photos - * Get all photos for a spot (Google + user uploaded) - */ - router.get("/:id/photos", async (req, res) => { - const id = req.params.id; - if (!ObjectId.isValid(id)) { - return res.status(400).json({ error: "Invalid ID" }); - } - - try { - const spot = await spotsCollection.findOne({ _id: ObjectId(id) }); - if (!spot) { - return res.status(404).json({ error: "Spot not found" }); - } - - const photos = { - googlePhotos: spot.googlePhotos || [], - userPhotos: spot.userPhotos || [], - mainImage: spot.imageURL, - }; - - res.json(photos); - } catch (error) { - console.error("Error fetching photos:", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - /** - * POST /api/spots/:id/photos - * Upload a user photo for a spot - */ - router.post("/:id/photos", [auth, upload.single("photo")], async (req, res) => { - const id = req.params.id; - if (!ObjectId.isValid(id)) { - return res.status(400).json({ error: "Invalid ID" }); - } - - if (!req.file) { - return res.status(400).json({ error: "No photo provided" }); - } - - try { - const spot = await spotsCollection.findOne({ _id: ObjectId(id) }); - if (!spot) { - return res.status(404).json({ error: "Spot not found" }); - } - - // Upload to S3 - const fileName = `${id}-user-${Date.now()}.${req.file.mimetype.split("/")[1]}`; - const result = await s3Upload.uploadFile( - req.file.buffer, - fileName, - req.file.mimetype, - "spots" - ); - - const newPhoto = { - url: result.fileUrl, - key: result.fileKey, - userId: req.user.userId, - uploadedAt: new Date(), - }; - - // Add to userPhotos array - await spotsCollection.updateOne( - { _id: ObjectId(id) }, - { $push: { userPhotos: newPhoto } } - ); - - console.log(`[Spots] User ${req.user.userId} uploaded photo for spot ${id}`); - - res.status(201).json(newPhoto); - } catch (error) { - console.error("Error uploading photo:", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - - /** - * DELETE /api/spots/:id/photos/:photoKey - * Delete a user-uploaded photo (only owner or admin) - */ - router.delete("/:id/photos/:photoKey", [auth], async (req, res) => { - const { id, photoKey } = req.params; - if (!ObjectId.isValid(id)) { - return res.status(400).json({ error: "Invalid ID" }); - } - - try { - const spot = await spotsCollection.findOne({ _id: ObjectId(id) }); - if (!spot) { - return res.status(404).json({ error: "Spot not found" }); - } - - // Find the photo - const photo = (spot.userPhotos || []).find((p) => p.key === photoKey); - if (!photo) { - return res.status(404).json({ error: "Photo not found" }); - } - - // Check authorization (owner or admin) - if (photo.userId !== req.user.userId && req.user.role !== "admin") { - return res.status(403).json({ error: "Not authorized to delete this photo" }); - } - - // Delete from S3 - await s3Upload.deleteFile(photoKey); - - // Remove from userPhotos array - await spotsCollection.updateOne( - { _id: ObjectId(id) }, - { $pull: { userPhotos: { key: photoKey } } } - ); - - console.log(`[Spots] Photo ${photoKey} deleted from spot ${id}`); - - res.json({ message: "Photo deleted successfully" }); - } catch (error) { - console.error("Error deleting photo:", error); - res.status(500).json({ error: "Internal Server Error" }); - } - }); - }) - .catch((error) => { - console.error("Error connecting to MongoDB", error); - }); - -module.exports = router; From 4ace2149d8d04053be22f5aa2d101b8c1a615c9e Mon Sep 17 00:00:00 2001 From: wbaxterh Date: Thu, 23 Apr 2026 15:36:52 -0700 Subject: [PATCH 2/6] fix: regenerate package-lock.json for CI Lockfile was missing transitive deps (https-proxy-agent, agent-base, debug, ms), causing `npm ci` to fail in CI. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 83 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/package-lock.json b/package-lock.json index 7e23425..aa3e807 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2303,6 +2303,47 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/ansi-align": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-2.0.0.tgz", @@ -5048,6 +5089,48 @@ "node": ">= 0.6" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", From 119b6ba12170ff67f697fa22598e85bbbd48fda0 Mon Sep 17 00:00:00 2001 From: wbaxterh Date: Thu, 23 Apr 2026 16:05:59 -0700 Subject: [PATCH 3/6] chore: fix biome lint and formatting across codebase Mechanical auto-fixes from `biome check --write --unsafe`: - Prefix unused params/vars with underscore - Convert string concatenation to template literals - Apply formatting (trailing commas, quotes, semicolons) Co-Authored-By: Claude Opus 4.6 (1M context) --- ecosystem.config.js | 12 +- kaori-ai-response.js | 85 +++++------ kith-voice/src/server.ts | 76 ++++------ routes/botChat.js | 108 +++++++------ routes/connectakids.js | 281 +++++++++++++++++++++++----------- routes/couch.js | 2 +- routes/dm.js | 302 ++++++++++++++++++++++++------------- routes/feed.js | 23 ++- routes/listing.js | 20 ++- routes/listings.js | 16 +- routes/spotTrickHistory.js | 57 +++++-- routes/spots.js | 5 +- routes/users.js | 50 +++--- 13 files changed, 636 insertions(+), 401 deletions(-) diff --git a/ecosystem.config.js b/ecosystem.config.js index 65d32df..2084f2c 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -1,7 +1,9 @@ module.exports = { - apps : [{ - name: 'TB-Backend', - script: 'node index.js', - version: '1.0.0' - }], + apps: [ + { + name: 'TB-Backend', + script: 'node index.js', + version: '1.0.0', + }, + ], }; diff --git a/kaori-ai-response.js b/kaori-ai-response.js index 095182c..33ac06f 100644 --- a/kaori-ai-response.js +++ b/kaori-ai-response.js @@ -32,21 +32,21 @@ Your vibe: Think of the cool Japanese girl at the terrain park who's always hypi const https = require('https'); const { Pool } = require('pg'); -const pgPool = new Pool({ - connectionString: 'postgresql://elizaos:elizaos2024!@localhost:5432/elizaos' +const _pgPool = new Pool({ + connectionString: process.env.POSTGRES_CONNECTION_STRING || 'postgresql://localhost:5432/elizaos', }); // Query RAG context from pgvector async function queryRAGContext(userMessage) { try { const ragQuery = require('./kaori-rag/kaori-query'); - if (ragQuery && ragQuery.search) { + if (ragQuery?.search) { const results = await ragQuery.search(userMessage, 3); if (results && results.length > 0) { - return results.map(r => r.content || r.chunk_text).join('\n\n'); + return results.map((r) => r.content || r.chunk_text).join('\n\n'); } } - } catch (err) { + } catch (_err) { // RAG not set up yet, that's fine } return ''; @@ -55,7 +55,7 @@ async function queryRAGContext(userMessage) { // Call OpenAI API async function callOpenAI(messages, ragContext) { const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENAI_API_KEY; - + if (!apiKey) { console.log('No OpenRouter/OpenAI API key found'); return null; @@ -63,48 +63,48 @@ async function callOpenAI(messages, ragContext) { let systemPrompt = KAORI_SYSTEM_PROMPT; if (ragContext) { - systemPrompt += '\n\nRecent snowboard news/articles you know about:\n' + ragContext; + systemPrompt += `\n\nRecent snowboard news/articles you know about:\n${ragContext}`; } const body = JSON.stringify({ model: 'x-ai/grok-3-mini', - messages: [ - { role: 'system', content: systemPrompt }, - ...messages - ], + messages: [{ role: 'system', content: systemPrompt }, ...messages], max_tokens: 300, - temperature: 0.9 + temperature: 0.9, }); - return new Promise((resolve, reject) => { - const req = https.request({ - hostname: 'openrouter.ai', - path: '/api/v1/chat/completions', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + apiKey, - 'HTTP-Referer': 'https://thetrickbook.com', - 'X-Title': 'TrickBook Kaori' - } - }, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - try { - const parsed = JSON.parse(data); - if (parsed.choices && parsed.choices[0]) { - resolve(parsed.choices[0].message.content.trim()); - } else { - console.error('OpenRouter unexpected response:', data.substring(0, 200)); + return new Promise((resolve, _reject) => { + const req = https.request( + { + hostname: 'openrouter.ai', + path: '/api/v1/chat/completions', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + 'HTTP-Referer': 'https://thetrickbook.com', + 'X-Title': 'TrickBook Kaori', + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + const parsed = JSON.parse(data); + if (parsed.choices?.[0]) { + resolve(parsed.choices[0].message.content.trim()); + } else { + console.error('OpenRouter unexpected response:', data.substring(0, 200)); + resolve(null); + } + } catch (e) { + console.error('OpenRouter parse error:', e.message); resolve(null); } - } catch (e) { - console.error('OpenRouter parse error:', e.message); - resolve(null); - } - }); - }); + }); + }, + ); req.on('error', (e) => { console.error('OpenRouter request error:', e.message); resolve(null); @@ -114,9 +114,10 @@ async function callOpenAI(messages, ragContext) { }); } -async function generateKaoriResponse(userMessage, db, conversationId, senderId) { +async function generateKaoriResponse(userMessage, db, conversationId, _senderId) { // Get conversation history - const recentMessages = await db.collection('dm_messages') + const recentMessages = await db + .collection('dm_messages') .find({ conversationId: conversationId }) .sort({ createdAt: -1 }) .limit(6) @@ -126,7 +127,7 @@ async function generateKaoriResponse(userMessage, db, conversationId, senderId) const openaiMessages = []; for (const msg of recentMessages.reverse()) { const role = msg.senderId === '69c15e55c7ebe2c6884f1267' ? 'assistant' : 'user'; - if (msg.content && msg.content.trim()) { + if (msg.content?.trim()) { openaiMessages.push({ role, content: msg.content.trim() }); } } diff --git a/kith-voice/src/server.ts b/kith-voice/src/server.ts index 6e6c42d..6145f0d 100644 --- a/kith-voice/src/server.ts +++ b/kith-voice/src/server.ts @@ -11,22 +11,22 @@ * over the WebSocket. */ -import path from "node:path"; -import type { KithEvent } from "@kith/core"; -import { PipecatRuntime } from "@kith/runtime-pipecat"; -import { InMemoryObservability, consoleExporter } from "@kith/observability"; +import path from 'node:path'; +import type { KithEvent } from '@kith/core'; +import { consoleExporter, InMemoryObservability } from '@kith/observability'; +import { PipecatRuntime } from '@kith/runtime-pipecat'; import { DEFAULT_BOARD_SPORTS_SLANG, DEFAULT_ENGLISH_SLANG, DEFAULT_GENZ_SLANG, DEFAULT_LAUGH_TAGS, + type VoiceCharacter, VoiceRouter, voiceCharacterToRuntimeConfig, - type VoiceCharacter, -} from "@kith/voice-router"; -import type { ServerWebSocket } from "bun"; +} from '@kith/voice-router'; +import type { ServerWebSocket } from 'bun'; -import kaoriProfile from "./kaori-character.json" with { type: "json" }; +import kaoriProfile from './kaori-character.json' with { type: 'json' }; const character = kaoriProfile as VoiceCharacter; @@ -34,21 +34,16 @@ const PORT = Number(process.env.PORT ?? 3040); const ROOT = path.dirname(Bun.fileURLToPath(import.meta.url)); const PYTHON_VENV = path.resolve( ROOT, - "../../../../kith/packages/runtime-pipecat/python/.venv/bin/python", -); -const PYTHON_CWD = path.resolve( - ROOT, - "../../../../kith/packages/runtime-pipecat/python", + '../../../../kith/packages/runtime-pipecat/python/.venv/bin/python', ); +const PYTHON_CWD = path.resolve(ROOT, '../../../../kith/packages/runtime-pipecat/python'); const apiKey = process.env.ELEVENLABS_API_KEY; -const voiceId = process.env.ELEVENLABS_VOICE_ID ?? "klHOJHbGA89BjwulA7MN"; -const modelId = process.env.ELEVENLABS_MODEL_ID ?? "eleven_v3"; +const voiceId = process.env.ELEVENLABS_VOICE_ID ?? 'klHOJHbGA89BjwulA7MN'; +const modelId = process.env.ELEVENLABS_MODEL_ID ?? 'eleven_v3'; if (!apiKey) { - console.error( - "ELEVENLABS_API_KEY must be set. See kith-voice/.env.example", - ); + console.error('ELEVENLABS_API_KEY must be set. See kith-voice/.env.example'); process.exit(2); } @@ -74,10 +69,7 @@ interface WsData { const sessions = new Map(); -async function createSession( - sessionId: string, - ws: ServerWebSocket, -): Promise { +async function createSession(sessionId: string, ws: ServerWebSocket): Promise { const obs = new InMemoryObservability(); obs.onRecord(consoleExporter); @@ -86,12 +78,12 @@ async function createSession( cwd: PYTHON_CWD, observability: obs, config: { - pipeline: "elevenlabs", + pipeline: 'elevenlabs', apiKey, voiceId, modelId, ...voiceCharacterToRuntimeConfig(character), - outputFormat: "mp3_44100_128", + outputFormat: 'mp3_44100_128', }, }); @@ -134,28 +126,25 @@ const server = Bun.serve({ const url = new URL(req.url); // WebSocket upgrade for browser clients - if (url.pathname === "/ws") { + if (url.pathname === '/ws') { const sessionId = crypto.randomUUID(); const ok = server.upgrade(req, { data: { sessionId } }); if (ok) return undefined; - return new Response("WebSocket upgrade failed", { status: 500 }); + return new Response('WebSocket upgrade failed', { status: 500 }); } // HTTP endpoint for Backend to trigger speech - if (url.pathname.startsWith("/speak/") && req.method === "POST") { - const sessionId = url.pathname.slice("/speak/".length); + if (url.pathname.startsWith('/speak/') && req.method === 'POST') { + const sessionId = url.pathname.slice('/speak/'.length); const session = sessions.get(sessionId); if (!session) { - return Response.json( - { error: "session not found", sessionId }, - { status: 404 }, - ); + return Response.json({ error: 'session not found', sessionId }, { status: 404 }); } try { const body = (await req.json()) as { text: string }; - if (!body.text || typeof body.text !== "string") { - return Response.json({ error: "text is required" }, { status: 400 }); + if (!body.text || typeof body.text !== 'string') { + return Response.json({ error: 'text is required' }, { status: 400 }); } // Fire-and-forget: speak runs async, we respond immediately session.voice.speak(body.text).catch((err) => { @@ -164,12 +153,12 @@ const server = Bun.serve({ return Response.json({ ok: true, sessionId }); } catch (err) { console.error(`[kith] /speak error:`, err); - return Response.json({ error: "internal error" }, { status: 500 }); + return Response.json({ error: 'internal error' }, { status: 500 }); } } // Health check - if (url.pathname === "/health") { + if (url.pathname === '/health') { return Response.json({ ok: true, sessions: sessions.size, @@ -177,7 +166,7 @@ const server = Bun.serve({ }); } - return new Response("Kith voice service for Kaori", { status: 200 }); + return new Response('Kith voice service for Kaori', { status: 200 }); }, websocket: { async open(ws: ServerWebSocket) { @@ -187,13 +176,10 @@ const server = Bun.serve({ try { const session = await createSession(sessionId, ws); sessions.set(sessionId, session); - ws.send(JSON.stringify({ type: "_ready", sessionId })); + ws.send(JSON.stringify({ type: '_ready', sessionId })); } catch (err) { - console.error( - `[kith] session create failed session=${sessionId}:`, - err, - ); - ws.close(1011, "runtime connect failed"); + console.error(`[kith] session create failed session=${sessionId}:`, err); + ws.close(1011, 'runtime connect failed'); } }, async message(ws: ServerWebSocket, raw) { @@ -208,13 +194,13 @@ const server = Bun.serve({ return; } - if (msg.type === "speak" && typeof msg.text === "string") { + if (msg.type === 'speak' && typeof msg.text === 'string') { try { await session.voice.speak(msg.text); } catch (err) { console.error(`[kith] speak failed session=${sessionId}:`, err); } - } else if (msg.type === "barge-in") { + } else if (msg.type === 'barge-in') { await session.runtime.bargeIn(); } }, diff --git a/routes/botChat.js b/routes/botChat.js index cc7dd20..da9624d 100644 --- a/routes/botChat.js +++ b/routes/botChat.js @@ -8,25 +8,29 @@ require('dotenv').config(); // MongoDB connection let db; MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) - .then(client => { + .then((client) => { db = client.db('TrickList2'); console.log('Bot Chat API connected to MongoDB'); }) - .catch(error => console.error('Bot Chat MongoDB connection error:', error)); + .catch((error) => console.error('Bot Chat MongoDB connection error:', error)); // GET /api/bot-chat/bots - List all available bots -router.get('/bots', async (req, res) => { +router.get('/bots', async (_req, res) => { try { - const bots = await db.collection('users').find({ - isBot: true - }).project({ - _id: 1, - name: 1, - bio: 1, - botCharacter: 1, - imageUri: 1 - }).toArray(); - + const bots = await db + .collection('users') + .find({ + isBot: true, + }) + .project({ + _id: 1, + name: 1, + bio: 1, + botCharacter: 1, + imageUri: 1, + }) + .toArray(); + res.json(bots); } catch (error) { console.error('Error fetching bots:', error); @@ -39,21 +43,27 @@ router.get('/history/:botId', auth, async (req, res) => { try { const { botId } = req.params; const userId = req.user.userId; - + // Verify bot exists - const bot = await db.collection('users').findOne({ _id: new (require('mongodb').ObjectId)(botId), isBot: true }); + const bot = await db + .collection('users') + .findOne({ _id: new (require('mongodb').ObjectId)(botId), isBot: true }); if (!bot) { return res.status(404).json({ error: 'Bot not found' }); } - + // Get chat history - const chatHistory = await db.collection('bot_chats').find({ - $or: [ - { fromUserId: userId, toUserId: botId }, - { fromUserId: botId, toUserId: userId } - ] - }).sort({ createdAt: 1 }).toArray(); - + const chatHistory = await db + .collection('bot_chats') + .find({ + $or: [ + { fromUserId: userId, toUserId: botId }, + { fromUserId: botId, toUserId: userId }, + ], + }) + .sort({ createdAt: 1 }) + .toArray(); + res.json(chatHistory); } catch (error) { console.error('Error fetching chat history:', error); @@ -66,17 +76,19 @@ router.post('/message', auth, async (req, res) => { try { const { botId, message } = req.body; const userId = req.user.userId; - + if (!botId || !message) { return res.status(400).json({ error: 'botId and message are required' }); } - + // Verify bot exists - const bot = await db.collection('users').findOne({ _id: new (require('mongodb').ObjectId)(botId), isBot: true }); + const bot = await db + .collection('users') + .findOne({ _id: new (require('mongodb').ObjectId)(botId), isBot: true }); if (!bot) { return res.status(404).json({ error: 'Bot not found' }); } - + // Save user message const userMessage = { fromUserId: userId, @@ -84,29 +96,34 @@ router.post('/message', auth, async (req, res) => { userId: userId, type: 'user', createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), }; - + const userMessageResult = await db.collection('bot_chats').insertOne(userMessage); userMessage._id = userMessageResult.insertedId; - + // Forward to ElizaOS API let botResponse; try { - const elizaResponse = await axios.post('http://localhost:3001/api/chat', { - userId: userId, - message: message, - character: bot.botCharacter || 'kaori' - }, { - timeout: 30000 - }); - + const elizaResponse = await axios.post( + 'http://localhost:3001/api/chat', + { + userId: userId, + message: message, + character: bot.botCharacter || 'kaori', + }, + { + timeout: 30000, + }, + ); + botResponse = elizaResponse.data.response || 'Sorry, I had trouble responding to that!'; } catch (elizaError) { console.error('ElizaOS API error:', elizaError.message); - botResponse = 'Hey! I\'m having some technical difficulties right now. Can you try again in a moment? 🤖✨'; + botResponse = + "Hey! I'm having some technical difficulties right now. Can you try again in a moment? 🤖✨"; } - + // Save bot response const botMessage = { fromUserId: botId, @@ -114,22 +131,21 @@ router.post('/message', auth, async (req, res) => { message: botResponse, type: 'bot', createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), }; - + const botMessageResult = await db.collection('bot_chats').insertOne(botMessage); botMessage._id = botMessageResult.insertedId; - + // Return both messages res.json({ userMessage, - botMessage + botMessage, }); - } catch (error) { console.error('Error in bot chat:', error); res.status(500).json({ error: 'Failed to process bot chat' }); } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/routes/connectakids.js b/routes/connectakids.js index 3b0018d..4ab5621 100644 --- a/routes/connectakids.js +++ b/routes/connectakids.js @@ -1,20 +1,20 @@ -const express = require("express"); +const express = require('express'); const router = express.Router(); -const { MongoClient, ObjectId } = require("mongodb"); -const multer = require("multer"); -const path = require("path"); -const fs = require("fs"); +const { MongoClient, ObjectId } = require('mongodb'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); // Ensure upload dir exists -const uploadDir = path.join(__dirname, "..", "public", "ck-uploads"); +const uploadDir = path.join(__dirname, '..', 'public', 'ck-uploads'); if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); const storage = multer.diskStorage({ - destination: (req, file, cb) => cb(null, uploadDir), - filename: (req, file, cb) => { - const ext = path.extname(file.originalname) || ".jpg"; - cb(null, Date.now() + "-" + Math.random().toString(36).slice(2, 8) + ext); - } + destination: (_req, _file, cb) => cb(null, uploadDir), + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname) || '.jpg'; + cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`); + }, }); const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 } }); @@ -24,34 +24,140 @@ let db; async function getDb() { if (db) return db; const client = await MongoClient.connect(ATLAS_URI); - db = client.db("myFirstDatabase"); + db = client.db('myFirstDatabase'); return db; } const DEFAULT_SPOTS = [ - { name: "Old State House Steps", city: "Hartford", state: "CT", lat: 41.7637, lng: -72.6735, description: "Classic marble steps, multiple ledge options", category: "home" }, - { name: "Bushnell Park Rails", city: "Hartford", state: "CT", lat: 41.7627, lng: -72.6823, description: "Park rails near the carousel, mellow vibes", category: "home" }, - { name: "Yale Gothic Arches", city: "New Haven", state: "CT", lat: 41.3111, lng: -72.9267, description: "Stone arches with perfect wallride angles", category: "home" }, - { name: "Sterling Library Benches", city: "New Haven", state: "CT", lat: 41.3116, lng: -72.9289, description: "Granite benches, smooth ground", category: "home" }, - { name: "Frozen Seaport Docks", city: "Mystic", state: "CT", lat: 41.3615, lng: -71.9662, description: "Wooden dock edges, waterfront backdrop", category: "home" }, - { name: "Old Crane Arms", city: "Mystic", state: "CT", lat: 41.3601, lng: -71.9658, description: "Industrial crane structures, unique features", category: "home" }, - { name: "Windham Textile Mill", city: "Windham", state: "CT", lat: 41.7098, lng: -72.1573, description: "Brick ruins, loading ramps, raw industrial", category: "home" }, - { name: "Brass Factory Row", city: "Bridgeport", state: "CT", lat: 41.1792, lng: -73.1894, description: "Loading bays, brick ledges, gritty aesthetic", category: "home" }, - { name: "Oyster Dock Pilings", city: "Norwalk", state: "CT", lat: 41.0968, lng: -73.4154, description: "Dock pilings and flat ground by the water", category: "home" }, - { name: "Boston (Weekend Trip)", city: "Boston", state: "MA", lat: 42.3601, lng: -71.0589, description: "Weekend AirBnb trip — multiple spots TBD", category: "expansion" }, - { name: "Worcester (Weekend Trip)", city: "Worcester", state: "MA", lat: 42.2626, lng: -71.8023, description: "Weekend AirBnb trip — industrial city, tons of street spots", category: "expansion" }, - { name: "Portland ME (Weekend Trip)", city: "Portland", state: "ME", lat: 43.6591, lng: -70.2568, description: "Weekend AirBnb trip — waterfront + Old Port", category: "expansion" }, - { name: "Burlington VT (Weekend Trip)", city: "Burlington", state: "VT", lat: 44.4759, lng: -73.2121, description: "Weekend AirBnb trip — Church St + waterfront", category: "expansion" }, + { + name: 'Old State House Steps', + city: 'Hartford', + state: 'CT', + lat: 41.7637, + lng: -72.6735, + description: 'Classic marble steps, multiple ledge options', + category: 'home', + }, + { + name: 'Bushnell Park Rails', + city: 'Hartford', + state: 'CT', + lat: 41.7627, + lng: -72.6823, + description: 'Park rails near the carousel, mellow vibes', + category: 'home', + }, + { + name: 'Yale Gothic Arches', + city: 'New Haven', + state: 'CT', + lat: 41.3111, + lng: -72.9267, + description: 'Stone arches with perfect wallride angles', + category: 'home', + }, + { + name: 'Sterling Library Benches', + city: 'New Haven', + state: 'CT', + lat: 41.3116, + lng: -72.9289, + description: 'Granite benches, smooth ground', + category: 'home', + }, + { + name: 'Frozen Seaport Docks', + city: 'Mystic', + state: 'CT', + lat: 41.3615, + lng: -71.9662, + description: 'Wooden dock edges, waterfront backdrop', + category: 'home', + }, + { + name: 'Old Crane Arms', + city: 'Mystic', + state: 'CT', + lat: 41.3601, + lng: -71.9658, + description: 'Industrial crane structures, unique features', + category: 'home', + }, + { + name: 'Windham Textile Mill', + city: 'Windham', + state: 'CT', + lat: 41.7098, + lng: -72.1573, + description: 'Brick ruins, loading ramps, raw industrial', + category: 'home', + }, + { + name: 'Brass Factory Row', + city: 'Bridgeport', + state: 'CT', + lat: 41.1792, + lng: -73.1894, + description: 'Loading bays, brick ledges, gritty aesthetic', + category: 'home', + }, + { + name: 'Oyster Dock Pilings', + city: 'Norwalk', + state: 'CT', + lat: 41.0968, + lng: -73.4154, + description: 'Dock pilings and flat ground by the water', + category: 'home', + }, + { + name: 'Boston (Weekend Trip)', + city: 'Boston', + state: 'MA', + lat: 42.3601, + lng: -71.0589, + description: 'Weekend AirBnb trip — multiple spots TBD', + category: 'expansion', + }, + { + name: 'Worcester (Weekend Trip)', + city: 'Worcester', + state: 'MA', + lat: 42.2626, + lng: -71.8023, + description: 'Weekend AirBnb trip — industrial city, tons of street spots', + category: 'expansion', + }, + { + name: 'Portland ME (Weekend Trip)', + city: 'Portland', + state: 'ME', + lat: 43.6591, + lng: -70.2568, + description: 'Weekend AirBnb trip — waterfront + Old Port', + category: 'expansion', + }, + { + name: 'Burlington VT (Weekend Trip)', + city: 'Burlington', + state: 'VT', + lat: 44.4759, + lng: -73.2121, + description: 'Weekend AirBnb trip — Church St + waterfront', + category: 'expansion', + }, ]; // === SPOTS === -router.get("/spots", async (req, res) => { +router.get('/spots', async (_req, res) => { try { const db = await getDb(); - const spots = await db.collection("ck_spots").find({}).toArray(); + const spots = await db.collection('ck_spots').find({}).toArray(); if (spots.length === 0) { - await db.collection("ck_spots").insertMany(DEFAULT_SPOTS.map(s => ({ ...s, createdAt: new Date() }))); - const seeded = await db.collection("ck_spots").find({}).toArray(); + await db + .collection('ck_spots') + .insertMany(DEFAULT_SPOTS.map((s) => ({ ...s, createdAt: new Date() }))); + const seeded = await db.collection('ck_spots').find({}).toArray(); return res.json(seeded); } res.json(spots); @@ -60,23 +166,27 @@ router.get("/spots", async (req, res) => { } }); -router.post("/spots", async (req, res) => { +router.post('/spots', async (req, res) => { try { const db = await getDb(); const spot = { ...req.body, createdAt: new Date() }; // Auto-geocode if no lat/lng if (!spot.lat && spot.city) { try { - const axios = require("axios"); - const q = encodeURIComponent(spot.city + (spot.state ? ", " + spot.state : "") + ", US"); - const geo = await axios.get(`https://geocoding-api.open-meteo.com/v1/search?name=${q}&count=1&language=en&format=json`); + const axios = require('axios'); + const q = encodeURIComponent(`${spot.city + (spot.state ? `, ${spot.state}` : '')}, US`); + const geo = await axios.get( + `https://geocoding-api.open-meteo.com/v1/search?name=${q}&count=1&language=en&format=json`, + ); if (geo.data.results && geo.data.results.length > 0) { spot.lat = geo.data.results[0].latitude; spot.lng = geo.data.results[0].longitude; } - } catch(e) { /* geocode failed, skip */ } + } catch (_e) { + /* geocode failed, skip */ + } } - const result = await db.collection("ck_spots").insertOne(spot); + const result = await db.collection('ck_spots').insertOne(spot); res.json({ ...spot, _id: result.insertedId }); } catch (err) { res.status(500).json({ error: err.message }); @@ -84,16 +194,15 @@ router.post("/spots", async (req, res) => { }); // === SPOT PHOTOS === -router.post("/spots/:id/photos", async (req, res) => { +router.post('/spots/:id/photos', async (req, res) => { try { const db = await getDb(); const { url, caption } = req.body; - if (!url) return res.status(400).json({ error: "url required" }); - const photo = { url, caption: caption || "", addedAt: new Date() }; - await db.collection("ck_spots").updateOne( - { _id: new ObjectId(req.params.id) }, - { $push: { photos: photo } } - ); + if (!url) return res.status(400).json({ error: 'url required' }); + const photo = { url, caption: caption || '', addedAt: new Date() }; + await db + .collection('ck_spots') + .updateOne({ _id: new ObjectId(req.params.id) }, { $push: { photos: photo } }); res.json(photo); } catch (err) { res.status(500).json({ error: err.message }); @@ -101,16 +210,15 @@ router.post("/spots/:id/photos", async (req, res) => { }); // Upload photo from device -router.post("/spots/:id/upload", upload.single("photo"), async (req, res) => { +router.post('/spots/:id/upload', upload.single('photo'), async (req, res) => { try { - if (!req.file) return res.status(400).json({ error: "no file uploaded" }); + if (!req.file) return res.status(400).json({ error: 'no file uploaded' }); const db = await getDb(); - const url = "/ck-uploads/" + req.file.filename; - const photo = { url, caption: "", addedAt: new Date() }; - await db.collection("ck_spots").updateOne( - { _id: new ObjectId(req.params.id) }, - { $push: { photos: photo } } - ); + const url = `/ck-uploads/${req.file.filename}`; + const photo = { url, caption: '', addedAt: new Date() }; + await db + .collection('ck_spots') + .updateOne({ _id: new ObjectId(req.params.id) }, { $push: { photos: photo } }); res.json(photo); } catch (err) { res.status(500).json({ error: err.message }); @@ -118,19 +226,18 @@ router.post("/spots/:id/upload", upload.single("photo"), async (req, res) => { }); // Delete a photo from a spot by index -router.delete("/spots/:id/photos/:index", async (req, res) => { +router.delete('/spots/:id/photos/:index', async (req, res) => { try { const db = await getDb(); - const spot = await db.collection("ck_spots").findOne({ _id: new ObjectId(req.params.id) }); - if (!spot) return res.status(404).json({ error: "spot not found" }); + const spot = await db.collection('ck_spots').findOne({ _id: new ObjectId(req.params.id) }); + if (!spot) return res.status(404).json({ error: 'spot not found' }); const photos = spot.photos || []; - const idx = parseInt(req.params.index); - if (idx < 0 || idx >= photos.length) return res.status(400).json({ error: "invalid index" }); + const idx = parseInt(req.params.index, 10); + if (idx < 0 || idx >= photos.length) return res.status(400).json({ error: 'invalid index' }); photos.splice(idx, 1); - await db.collection("ck_spots").updateOne( - { _id: new ObjectId(req.params.id) }, - { $set: { photos } } - ); + await db + .collection('ck_spots') + .updateOne({ _id: new ObjectId(req.params.id) }, { $set: { photos } }); res.json({ deleted: true }); } catch (err) { res.status(500).json({ error: err.message }); @@ -138,10 +245,10 @@ router.delete("/spots/:id/photos/:index", async (req, res) => { }); // Delete a spot -router.delete("/spots/:id", async (req, res) => { +router.delete('/spots/:id', async (req, res) => { try { const db = await getDb(); - await db.collection("ck_spots").deleteOne({ _id: new ObjectId(req.params.id) }); + await db.collection('ck_spots').deleteOne({ _id: new ObjectId(req.params.id) }); res.json({ deleted: true }); } catch (err) { res.status(500).json({ error: err.message }); @@ -149,36 +256,36 @@ router.delete("/spots/:id", async (req, res) => { }); // === AVAILABILITY === -router.get("/availability", async (req, res) => { +router.get('/availability', async (req, res) => { try { const db = await getDb(); const filter = {}; if (req.query.month) { - const start = new Date(req.query.month + "-01"); + const start = new Date(`${req.query.month}-01`); const end = new Date(start); end.setMonth(end.getMonth() + 1); filter.date = { $gte: start.toISOString().slice(0, 10), $lt: end.toISOString().slice(0, 10) }; } - const avail = await db.collection("ck_availability").find(filter).toArray(); + const avail = await db.collection('ck_availability').find(filter).toArray(); res.json(avail); } catch (err) { res.status(500).json({ error: err.message }); } }); -router.post("/availability", async (req, res) => { +router.post('/availability', async (req, res) => { try { const db = await getDb(); const { date, name } = req.body; - if (!name || !date) return res.status(400).json({ error: "name and date required" }); + if (!name || !date) return res.status(400).json({ error: 'name and date required' }); const nameLower = name.trim().toLowerCase(); - const existing = await db.collection("ck_availability").findOne({ date, nameLower }); + const existing = await db.collection('ck_availability').findOne({ date, nameLower }); if (existing) { - await db.collection("ck_availability").deleteOne({ _id: existing._id }); + await db.collection('ck_availability').deleteOne({ _id: existing._id }); return res.json({ removed: true, date }); } const entry = { date, name: name.trim(), nameLower, createdAt: new Date() }; - await db.collection("ck_availability").insertOne(entry); + await db.collection('ck_availability').insertOne(entry); res.json(entry); } catch (err) { res.status(500).json({ error: err.message }); @@ -186,54 +293,56 @@ router.post("/availability", async (req, res) => { }); // === TRIPS === -router.get("/trips", async (req, res) => { +router.get('/trips', async (_req, res) => { try { const db = await getDb(); - const trips = await db.collection("ck_trips").find({}).sort({ date: 1 }).toArray(); + const trips = await db.collection('ck_trips').find({}).sort({ date: 1 }).toArray(); res.json(trips); } catch (err) { res.status(500).json({ error: err.message }); } }); -router.post("/trips", async (req, res) => { +router.post('/trips', async (req, res) => { try { const db = await getDb(); const { name } = req.body; - if (!name) return res.status(400).json({ error: "name required" }); + if (!name) return res.status(400).json({ error: 'name required' }); const trip = { ...req.body, createdByName: name.trim(), createdAt: new Date(), - crew: [{ name: name.trim() }] + crew: [{ name: name.trim() }], }; delete trip.name; - const result = await db.collection("ck_trips").insertOne(trip); + const result = await db.collection('ck_trips').insertOne(trip); res.json({ ...trip, _id: result.insertedId }); } catch (err) { res.status(500).json({ error: err.message }); } }); -router.post("/trips/:id/join", async (req, res) => { +router.post('/trips/:id/join', async (req, res) => { try { const db = await getDb(); const { name } = req.body; - if (!name) return res.status(400).json({ error: "name required" }); - await db.collection("ck_trips").updateOne( - { _id: new ObjectId(req.params.id) }, - { $addToSet: { crew: { name: name.trim() } } } - ); + if (!name) return res.status(400).json({ error: 'name required' }); + await db + .collection('ck_trips') + .updateOne( + { _id: new ObjectId(req.params.id) }, + { $addToSet: { crew: { name: name.trim() } } }, + ); res.json({ joined: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); -router.delete("/trips/:id", async (req, res) => { +router.delete('/trips/:id', async (req, res) => { try { const db = await getDb(); - await db.collection("ck_trips").deleteOne({ _id: new ObjectId(req.params.id) }); + await db.collection('ck_trips').deleteOne({ _id: new ObjectId(req.params.id) }); res.json({ deleted: true }); } catch (err) { res.status(500).json({ error: err.message }); @@ -241,11 +350,11 @@ router.delete("/trips/:id", async (req, res) => { }); // === WEATHER === -router.get("/weather", async (req, res) => { +router.get('/weather', async (req, res) => { try { const { lat, lng } = req.query; - if (!lat || !lng) return res.status(400).json({ error: "lat and lng required" }); - const axios = require("axios"); + if (!lat || !lng) return res.status(400).json({ error: 'lat and lng required' }); + const axios = require('axios'); const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weathercode&temperature_unit=fahrenheit&timezone=America/New_York&forecast_days=7`; const resp = await axios.get(url); res.json(resp.data); diff --git a/routes/couch.js b/routes/couch.js index f4fdcfc..d3fbdb6 100644 --- a/routes/couch.js +++ b/routes/couch.js @@ -157,7 +157,7 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) if (video.bunnyVideoId || video.hlsUrl) { // Get signed URLs for token-authenticated CDN const videoId = video.bunnyVideoId || video.hlsUrl.split('/').slice(-2)[0]; - const urls = getVideoUrls(videoId); // Using unsigned URLs with referrer protection + const urls = getVideoUrls(videoId, true); return res.send({ type: 'hls', diff --git a/routes/dm.js b/routes/dm.js index ef8d9c9..4e8655e 100644 --- a/routes/dm.js +++ b/routes/dm.js @@ -1,22 +1,37 @@ const http = require('http'); -async function generateKaoriResponse(content, db, conversationId, senderId) { - return new Promise((resolve, reject) => { +async function _generateKaoriResponse(content, _db, _conversationId, senderId) { + return new Promise((resolve, _reject) => { const body = JSON.stringify({ userId: senderId, message: content, agentId: 'kaori' }); - const req = http.request({ - hostname: 'localhost', port: 3001, path: '/api/chat', method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } - }, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - try { - const parsed = JSON.parse(data); - resolve(parsed.response || parsed.text || 'ahh my brain is glitching rn, try again in a sec'); - } catch (e) { resolve('ahh my brain is glitching rn, try again in a sec'); } - }); + const req = http.request( + { + hostname: 'localhost', + port: 3001, + path: '/api/chat', + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + const parsed = JSON.parse(data); + resolve( + parsed.response || parsed.text || 'ahh my brain is glitching rn, try again in a sec', + ); + } catch (_e) { + resolve('ahh my brain is glitching rn, try again in a sec'); + } + }); + }, + ); + req.on('error', (_e) => { + resolve('ahh my brain is glitching rn, try again in a sec - the AI tokens might be out'); + }); + req.setTimeout(15000, () => { + req.destroy(); + resolve('ahh took too long to think, try again!'); }); - req.on('error', (e) => { resolve('ahh my brain is glitching rn, try again in a sec - the AI tokens might be out'); }); - req.setTimeout(15000, () => { req.destroy(); resolve('ahh took too long to think, try again!'); }); req.write(body); req.end(); }); @@ -26,7 +41,7 @@ const router = express.Router(); const auth = require('../middleware/auth'); const { MongoClient, ObjectId } = require('mongodb'); const { emitNewMessage, emitMessagesRead } = require('../socket/messageSocket'); -const axios = require('axios'); +const _axios = require('axios'); MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) .then((client) => { @@ -59,7 +74,10 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) const result = conversations.map((c) => ({ ...c, - otherUser: (() => { const u = userMap[c.participants.find((p) => p !== userId)]; return u ? { ...u, isBot: u.isBot || false } : null; })(), + otherUser: (() => { + const u = userMap[c.participants.find((p) => p !== userId)]; + return u ? { ...u, isBot: u.isBot || false } : null; + })(), unreadCount: c.unreadCount?.[userId] || 0, })); @@ -308,29 +326,28 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) res.status(201).send(message); - // --- BOT RESPONSE LOGIC --- // Check if the other participant is a bot const otherParticipantId = conversation.participants.find((p) => p !== senderId); if (otherParticipantId) { const otherUser = await usersCollection.findOne({ _id: new ObjectId(otherParticipantId), - isBot: true + isBot: true, }); - + if (otherUser) { // Get io reference NOW (before async) const ioRef = req.app.get('io'); const messagesNs = ioRef ? ioRef.of('/messages') : null; // Kith voice session ID (sent by Kaori Live web client) const kithSessionId = req.headers['x-kith-session'] || ''; - + // Run bot response with typing delay (async () => { try { const character = otherUser.botCharacter || 'kaori'; - const greetings = otherUser.botConfig?.greetings || ['Hey! 🤙']; - + const _greetings = otherUser.botConfig?.greetings || ['Hey! 🤙']; + // 1. Emit typing indicator if (messagesNs) { messagesNs.to(`conversation:${conversationId}`).emit('typing:start', { @@ -342,56 +359,71 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) userId: otherParticipantId, }); } - + // 2. Generate response (with minimum delay for realism) const startTime = Date.now(); let botResponseText; - + // Claude-powered Kaori AI response (with ElizaOS fallback) try { - botResponseText = await new Promise((resolve, reject) => { + botResponseText = await new Promise((resolve, _reject) => { const payload = JSON.stringify({ userId: senderId, message: content ? content.trim() : '', - agentId: 'kaori' + agentId: 'kaori', }); - const req = http.request({ - hostname: 'localhost', - port: 3001, - path: '/api/chat', - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } - }, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - try { - const parsed = JSON.parse(data); - resolve(parsed.response || parsed.text || null); - } catch(e) { resolve(null); } - }); + const req = http.request( + { + hostname: 'localhost', + port: 3001, + path: '/api/chat', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + const parsed = JSON.parse(data); + resolve(parsed.response || parsed.text || null); + } catch (_e) { + resolve(null); + } + }); + }, + ); + req.on('error', (e) => { + console.error('kaori-bot request error:', e.message); + resolve(null); + }); + req.setTimeout(15000, () => { + req.destroy(); + resolve(null); }); - req.on('error', (e) => { console.error('kaori-bot request error:', e.message); resolve(null); }); - req.setTimeout(15000, () => { req.destroy(); resolve(null); }); req.write(payload); req.end(); }); } catch (aiErr) { console.error('Kaori AI response error:', aiErr.message); - botResponseText = 'ahh my brain is glitching rn, try again in a sec - the AI tokens might be out'; + botResponseText = + 'ahh my brain is glitching rn, try again in a sec - the AI tokens might be out'; } - + if (!botResponseText) { botResponseText = "Hey, what's up?"; } - + // 3. Wait minimum 1-2 seconds for typing realism const elapsed = Date.now() - startTime; const minDelay = 1000 + Math.random() * 1500; // 1-2.5 seconds if (elapsed < minDelay) { - await new Promise(resolve => setTimeout(resolve, minDelay - elapsed)); + await new Promise((resolve) => setTimeout(resolve, minDelay - elapsed)); } - + // 4. Stop typing indicator if (messagesNs) { messagesNs.to(`conversation:${conversationId}`).emit('typing:stop', { @@ -403,7 +435,7 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) userId: otherParticipantId, }); } - + // 5. Insert bot message const botMessage = { conversationId, @@ -415,10 +447,10 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) readAt: null, createdAt: new Date(), }; - + const botResult = await messagesCollection.insertOne(botMessage); botMessage._id = botResult.insertedId.toString(); - + // 6. Update conversation last message await conversationsCollection.updateOne( { _id: new ObjectId(conversationId) }, @@ -435,7 +467,7 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) $inc: { [`unreadCount.${senderId}`]: 1 }, }, ); - + // 7. Emit new message via socket if (messagesNs) { const convoPayload = { @@ -446,20 +478,22 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) createdAt: botMessage.createdAt, }, }; - + // Emit to user's personal room messagesNs.to(`user:${senderId}`).emit('message:new', { message: botMessage, conversation: convoPayload, }); - + // Emit to conversation room messagesNs.to(`conversation:${conversationId}`).emit('message:new', { message: botMessage, conversation: convoPayload, }); - - console.log(`[Bot] ${character} responded in conversation ${conversationId} (emitted to user:${senderId} + conversation:${conversationId})`); + + console.log( + `[Bot] ${character} responded in conversation ${conversationId} (emitted to user:${senderId} + conversation:${conversationId})`, + ); } else { console.log(`[Bot] ${character} responded but no socket available`); } @@ -470,9 +504,14 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) const kithUrl = new URL(`/speak/${kithSessionId}`, process.env.KITH_VOICE_URL); const kithReq = http.request(kithUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(kithPayload) }, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(kithPayload), + }, }); - kithReq.on('error', (e) => console.error('[Bot] Kith voice request error:', e.message)); + kithReq.on('error', (e) => + console.error('[Bot] Kith voice request error:', e.message), + ); kithReq.setTimeout(5000, () => kithReq.destroy()); kithReq.write(kithPayload); kithReq.end(); @@ -568,31 +607,41 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) // Get list of bot companions available for chat router.get('/bots', auth, async (req, res) => { try { - const bots = await usersCollection.find({ isBot: true }).project({ - _id: 1, name: 1, bio: 1, imageUri: 1, botCharacter: 1, botConfig: 1 - }).toArray(); - + const bots = await usersCollection + .find({ isBot: true }) + .project({ + _id: 1, + name: 1, + bio: 1, + imageUri: 1, + botCharacter: 1, + botConfig: 1, + }) + .toArray(); + const userId = req.user.userId; - + // Check which bots the user already has conversations with - const botIds = bots.map(b => b._id.toString()); - const existingConvos = await conversationsCollection.find({ - participants: userId - }).toArray(); - + const botIds = bots.map((b) => b._id.toString()); + const existingConvos = await conversationsCollection + .find({ + participants: userId, + }) + .toArray(); + const convoMap = {}; - existingConvos.forEach(c => { - const otherP = c.participants.find(p => p !== userId); + existingConvos.forEach((c) => { + const otherP = c.participants.find((p) => p !== userId); if (botIds.includes(otherP)) { convoMap[otherP] = c._id.toString(); } }); - - const result = bots.map(bot => ({ + + const result = bots.map((bot) => ({ ...bot, - existingConversationId: convoMap[bot._id.toString()] || null + existingConversationId: convoMap[bot._id.toString()] || null, })); - + res.json(result); } catch (error) { console.error('Error fetching bots:', error); @@ -605,21 +654,24 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) try { const { botId } = req.body; const userId = req.user.userId; - + if (!botId) return res.status(400).json({ error: 'botId is required' }); - + const bot = await usersCollection.findOne({ _id: new ObjectId(botId), isBot: true }); if (!bot) return res.status(404).json({ error: 'Bot not found' }); - + // Check for existing conversation const existing = await conversationsCollection.findOne({ - participants: { $all: [userId, botId] } + participants: { $all: [userId, botId] }, }); - + if (existing) { - return res.json({ ...existing, otherUser: { _id: bot._id, name: bot.name, imageUri: bot.imageUri, isBot: true } }); + return res.json({ + ...existing, + otherUser: { _id: bot._id, name: bot.name, imageUri: bot.imageUri, isBot: true }, + }); } - + // Create new conversation const conversation = { participants: [userId, botId], @@ -629,14 +681,14 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) createdAt: new Date(), updatedAt: new Date(), }; - + const result = await conversationsCollection.insertOne(conversation); conversation._id = result.insertedId; - + // Send initial greeting const greetings = bot.botConfig?.greetings || ['Hey! 🤙']; const greeting = greetings[Math.floor(Math.random() * greetings.length)]; - + const greetingMsg = { conversationId: conversation._id.toString(), senderId: botId, @@ -647,23 +699,29 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) readAt: null, createdAt: new Date(), }; - + await messagesCollection.insertOne(greetingMsg); - + await conversationsCollection.updateOne( { _id: conversation._id }, - { $set: { - lastMessage: { content: greeting, type: 'text', senderId: botId, createdAt: greetingMsg.createdAt }, - updatedAt: new Date() + { + $set: { + lastMessage: { + content: greeting, + type: 'text', + senderId: botId, + createdAt: greetingMsg.createdAt, + }, + updatedAt: new Date(), + }, + $inc: { [`unreadCount.${userId}`]: 1 }, }, - $inc: { [`unreadCount.${userId}`]: 1 } - } ); - + res.json({ ...conversation, otherUser: { _id: bot._id, name: bot.name, imageUri: bot.imageUri, isBot: true }, - lastMessage: { content: greeting, senderId: botId, createdAt: greetingMsg.createdAt } + lastMessage: { content: greeting, senderId: botId, createdAt: greetingMsg.createdAt }, }); } catch (error) { console.error('Error starting bot conversation:', error); @@ -676,32 +734,58 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) }); // Simple fallback responses when ElizaOS isn't running -function generateFallbackResponse(userMessage, character, greetings) { +function _generateFallbackResponse(userMessage, character, greetings) { const msg = (userMessage || '').toLowerCase(); - + if (character === 'kaori') { - if (msg.includes('hello') || msg.includes('hey') || msg.includes('hi') || msg.includes('sup') || msg.includes('yo')) { + if ( + msg.includes('hello') || + msg.includes('hey') || + msg.includes('hi') || + msg.includes('sup') || + msg.includes('yo') + ) { return greetings[Math.floor(Math.random() * greetings.length)]; } - if (msg.includes('trick') || msg.includes('ollie') || msg.includes('kickflip') || msg.includes('heelflip') || msg.includes('method') || msg.includes('grab')) { + if ( + msg.includes('trick') || + msg.includes('ollie') || + msg.includes('kickflip') || + msg.includes('heelflip') || + msg.includes('method') || + msg.includes('grab') + ) { const tips = [ "Ooh nice! That trick is so fun! Keep practicing — it's all about muscle memory! 💪🏂", "Sugoi! Great trick to work on! Make sure you're bending your knees enough! ❄️", - "I love that one! The key is committing to the rotation. You got this! Ganbare! 🏂✨", - "That trick took me forever to learn, but once it clicks, it's SO satisfying! Keep at it! 🤙" + 'I love that one! The key is committing to the rotation. You got this! Ganbare! 🏂✨', + "That trick took me forever to learn, but once it clicks, it's SO satisfying! Keep at it! 🤙", ]; return tips[Math.floor(Math.random() * tips.length)]; } - if (msg.includes('snow') || msg.includes('mountain') || msg.includes('resort') || msg.includes('powder') || msg.includes('ride') || msg.includes('board')) { + if ( + msg.includes('snow') || + msg.includes('mountain') || + msg.includes('resort') || + msg.includes('powder') || + msg.includes('ride') || + msg.includes('board') + ) { const snow = [ "Ahh I wish I was on the mountain right now! There's nothing like fresh powder! ❄️🏔️", - "Powder days are the BEST! Which mountain are you riding? 🏂", - "Nothing beats carving through fresh snow! The sound it makes is so satisfying! ✨❄️" + 'Powder days are the BEST! Which mountain are you riding? 🏂', + 'Nothing beats carving through fresh snow! The sound it makes is so satisfying! ✨❄️', ]; return snow[Math.floor(Math.random() * snow.length)]; } - if (msg.includes('spot') || msg.includes('park') || msg.includes('rail') || msg.includes('jump') || msg.includes('pipe')) { - return "That spot sounds amazing! Have you checked the spots section on The Trick Book? There might be some sick ones near you! 📍🏂"; + if ( + msg.includes('spot') || + msg.includes('park') || + msg.includes('rail') || + msg.includes('jump') || + msg.includes('pipe') + ) { + return 'That spot sounds amazing! Have you checked the spots section on The Trick Book? There might be some sick ones near you! 📍🏂'; } if (msg.includes('who') && msg.includes('you')) { return "I'm Kaori! Inspired by Kaori Nishidake from SSX Tricky 🎮 I'm your AI snowboard companion here on The Trick Book! I love talking about tricks, spots, gear — anything shred-related! 🏂❄️✨"; @@ -711,13 +795,13 @@ function generateFallbackResponse(userMessage, character, greetings) { } const defaults = [ "Haha that's awesome! Tell me more! 🏂✨", - "Sugoi! I love talking about this stuff! What else? 🤙", + 'Sugoi! I love talking about this stuff! What else? 🤙', "That's really cool! Snowboarding brings out the best vibes, ne? ❄️😄", - "Interesting! What tricks are you working on right now? I'd love to help! 🏂💪" + "Interesting! What tricks are you working on right now? I'd love to help! 🏂💪", ]; return defaults[Math.floor(Math.random() * defaults.length)]; } - + return greetings[Math.floor(Math.random() * greetings.length)]; } diff --git a/routes/feed.js b/routes/feed.js index 589e2bb..28e5f92 100644 --- a/routes/feed.js +++ b/routes/feed.js @@ -110,7 +110,7 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) // Add signed video URLs for video posts if (post.mediaType === 'video' && post.bunnyVideoId) { try { - const urls = getVideoUrls(post.bunnyVideoId); + const urls = getVideoUrls(post.bunnyVideoId, true); enrichedPost.signedHlsUrl = urls.hlsUrl; enrichedPost.signedMp4Url = urls.quality720p; enrichedPost.signedThumbnailUrl = urls.thumbnailUrl; @@ -459,7 +459,7 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) try { const spot = await spotsCollection.findOne( { _id: new ObjectId(post.spotId) }, - { projection: { name: 1, city: 1, state: 1 } } + { projection: { name: 1, city: 1, state: 1 } }, ); if (spot) enrichedPost.spot = spot; } catch (_e) {} @@ -468,7 +468,7 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) // Populate trick names if trickIds exist if (post.trickIds && post.trickIds.length > 0) { try { - const trickObjIds = post.trickIds.map(id => new ObjectId(id)); + const trickObjIds = post.trickIds.map((id) => new ObjectId(id)); const trickDocs = await trickCollection .find({ _id: { $in: trickObjIds } }) .project({ name: 1 }) @@ -504,7 +504,7 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) } // Get signed URLs (valid for 1 hour) - const urls = getVideoUrls(post.bunnyVideoId); + const urls = getVideoUrls(post.bunnyVideoId, true); res.send({ type: 'bunny', @@ -680,7 +680,14 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) } // Only allow updating certain fields - const allowedUpdates = ['caption', 'visibility', 'sportTypes', 'tricks', 'spotId', 'trickIds']; + const allowedUpdates = [ + 'caption', + 'visibility', + 'sportTypes', + 'tricks', + 'spotId', + 'trickIds', + ]; const updates = { updatedAt: new Date() }; allowedUpdates.forEach((field) => { @@ -763,7 +770,7 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) await feedCollection.updateOne( { _id: new ObjectId(postId) }, - { $set: { trickIds: trickIds, updatedAt: new Date() } } + { $set: { trickIds: trickIds, updatedAt: new Date() } }, ); // Update linked tricks to reference this feed post @@ -771,7 +778,7 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) if (ObjectId.isValid(trickId)) { await trickCollection.updateOne( { _id: new ObjectId(trickId) }, - { $set: { feedPostId: postId, updatedAt: new Date() } } + { $set: { feedPostId: postId, updatedAt: new Date() } }, ); } } @@ -810,7 +817,7 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) await feedCollection.updateOne( { _id: new ObjectId(postId) }, - { $set: { spotId: spotId || null, updatedAt: new Date() } } + { $set: { spotId: spotId || null, updatedAt: new Date() } }, ); res.send({ message: spotId ? 'Spot linked' : 'Spot unlinked', spotId }); diff --git a/routes/listing.js b/routes/listing.js index 7393806..e5db194 100644 --- a/routes/listing.js +++ b/routes/listing.js @@ -21,18 +21,19 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) // Helper: populate spot data for tricks that have spotId const populateSpotData = async (tricks) => { - const spotIds = tricks - .filter(t => t.spotId) - .map(t => new ObjectId(t.spotId)); + const spotIds = tricks.filter((t) => t.spotId).map((t) => new ObjectId(t.spotId)); if (spotIds.length === 0) return tricks; - const spots = await spotsCollection.find({ _id: { $in: spotIds } }) + const spots = await spotsCollection + .find({ _id: { $in: spotIds } }) .project({ name: 1, city: 1, state: 1 }) .toArray(); const spotMap = {}; - spots.forEach(s => { spotMap[s._id.toString()] = s; }); + spots.forEach((s) => { + spotMap[s._id.toString()] = s; + }); - return tricks.map(t => { + return tricks.map((t) => { if (t.spotId && spotMap[t.spotId.toString()]) { return { ...t, spot: spotMap[t.spotId.toString()] }; } @@ -243,7 +244,7 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) } await trickCollection.updateOne( { _id: new ObjectId(trickId) }, - { $set: { spotId: spotId, updatedAt: new Date() } } + { $set: { spotId: spotId, updatedAt: new Date() } }, ); // Return updated trick with spot data const trick = await trickCollection.findOne({ _id: new ObjectId(trickId) }); @@ -269,10 +270,7 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) if (req.body.feedPostId !== undefined) { setFields.feedPostId = req.body.feedPostId || null; } - await trickCollection.updateOne( - { _id: new ObjectId(trickId) }, - { $set: setFields } - ); + await trickCollection.updateOne({ _id: new ObjectId(trickId) }, { $set: setFields }); const trick = await trickCollection.findOne({ _id: new ObjectId(trickId) }); res.json(trick); } catch (error) { diff --git a/routes/listings.js b/routes/listings.js index 35e5e58..a177796 100644 --- a/routes/listings.js +++ b/routes/listings.js @@ -82,12 +82,12 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }).then((client const foundTrick = trickIdStr ? trickMap[trickIdStr] : null; return { ...trick, - name: (foundTrick && foundTrick.name) || trick.name || 'Unknown Trick', - checked: (foundTrick && foundTrick.checked) || trick.checked || 'To Do', - link: (foundTrick && foundTrick.link) || trick.link || '', - notes: (foundTrick && foundTrick.notes) || trick.notes || '', - createdAt: (foundTrick && foundTrick.createdAt) || trick.createdAt, - updatedAt: (foundTrick && foundTrick.updatedAt) || trick.updatedAt, + name: foundTrick?.name || trick.name || 'Unknown Trick', + checked: foundTrick?.checked || trick.checked || 'To Do', + link: foundTrick?.link || trick.link || '', + notes: foundTrick?.notes || trick.notes || '', + createdAt: foundTrick?.createdAt || trick.createdAt, + updatedAt: foundTrick?.updatedAt || trick.updatedAt, }; }) // Sort tricks by most recent activity (updatedAt or createdAt), newest first @@ -218,7 +218,9 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }).then((client const users = await db .collection('users') - .find({ _id: { $in: userIds.map((id) => (typeof id === 'string' ? new ObjectId(id) : id)) } }) + .find({ + _id: { $in: userIds.map((id) => (typeof id === 'string' ? new ObjectId(id) : id)) }, + }) .toArray(); const userMap = users.reduce((map, user) => { diff --git a/routes/spotTrickHistory.js b/routes/spotTrickHistory.js index 93dba19..33ad478 100644 --- a/routes/spotTrickHistory.js +++ b/routes/spotTrickHistory.js @@ -11,28 +11,40 @@ const connectionString = process.env.ATLAS_URI; let db; MongoClient.connect(connectionString, { useUnifiedTopology: true }) - .then(client => { + .then((client) => { db = client.db('TrickList2'); console.log('SpotTrickHistory: Connected to MongoDB'); }) - .catch(err => console.error('SpotTrickHistory DB error:', err)); + .catch((err) => console.error('SpotTrickHistory DB error:', err)); -function col() { return db.collection('spot_trick_history'); } +function col() { + return db.collection('spot_trick_history'); +} // GET /api/spot-tricks/:spotId — trick history for a spot router.get('/:spotId', async (req, res) => { try { const { spotId } = req.params; const { sort = 'year', page = 1, limit = 20 } = req.query; - const skip = (parseInt(page) - 1) * parseInt(limit); + const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); const sortObj = sort === 'upvotes' ? { upvotes: -1 } : { year: -1, createdAt: -1 }; const [tricks, total] = await Promise.all([ - col().find({ spotId: new ObjectId(spotId) }).sort(sortObj).skip(skip).limit(parseInt(limit)).toArray(), - col().countDocuments({ spotId: new ObjectId(spotId) }) + col() + .find({ spotId: new ObjectId(spotId) }) + .sort(sortObj) + .skip(skip) + .limit(parseInt(limit, 10)) + .toArray(), + col().countDocuments({ spotId: new ObjectId(spotId) }), ]); - res.json({ tricks, total, page: parseInt(page), totalPages: Math.ceil(total / parseInt(limit)) }); + res.json({ + tricks, + total, + page: parseInt(page, 10), + totalPages: Math.ceil(total / parseInt(limit, 10)), + }); } catch (err) { console.error('GET spot-tricks error:', err); res.status(500).json({ error: 'Server error' }); @@ -44,15 +56,20 @@ router.get('/skater/:skaterName', async (req, res) => { try { const { skaterName } = req.params; const { page = 1, limit = 20 } = req.query; - const skip = (parseInt(page) - 1) * parseInt(limit); + const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); const query = { skaterName: { $regex: new RegExp(skaterName, 'i') } }; const [tricks, total] = await Promise.all([ - col().find(query).sort({ year: -1 }).skip(skip).limit(parseInt(limit)).toArray(), - col().countDocuments(query) + col().find(query).sort({ year: -1 }).skip(skip).limit(parseInt(limit, 10)).toArray(), + col().countDocuments(query), ]); - res.json({ tricks, total, page: parseInt(page), totalPages: Math.ceil(total / parseInt(limit)) }); + res.json({ + tricks, + total, + page: parseInt(page, 10), + totalPages: Math.ceil(total / parseInt(limit, 10)), + }); } catch (err) { console.error('GET skater tricks error:', err); res.status(500).json({ error: 'Server error' }); @@ -62,7 +79,17 @@ router.get('/skater/:skaterName', async (req, res) => { // POST /api/spot-tricks — submit a trick (auth required) router.post('/', auth, async (req, res) => { try { - const { spotId, trickName, skaterName, videoUrl, thumbnailUrl, description, year, source, sourceUrl } = req.body; + const { + spotId, + trickName, + skaterName, + videoUrl, + thumbnailUrl, + description, + year, + source, + sourceUrl, + } = req.body; if (!spotId || !trickName || !skaterName) { return res.status(400).json({ error: 'spotId, trickName, and skaterName are required' }); } @@ -75,14 +102,14 @@ router.post('/', auth, async (req, res) => { thumbnailUrl: thumbnailUrl || null, source: source || 'user_submitted', sourceUrl: sourceUrl || null, - year: year ? parseInt(year) : null, + year: year ? parseInt(year, 10) : null, description: description || null, userId: req.user.userId, verified: req.user.role === 'admin', upvotes: 0, upvotedBy: [], createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), }; const result = await col().insertOne(trick); @@ -100,7 +127,7 @@ router.put('/:id/verify', auth, async (req, res) => { const result = await col().findOneAndUpdate( { _id: new ObjectId(req.params.id) }, { $set: { verified: true, updatedAt: new Date() } }, - { returnDocument: 'after' } + { returnDocument: 'after' }, ); if (!result.value && !result) return res.status(404).json({ error: 'Not found' }); res.json(result.value || result); diff --git a/routes/spots.js b/routes/spots.js index 6e77dd3..aee82c4 100644 --- a/routes/spots.js +++ b/routes/spots.js @@ -897,7 +897,10 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) }; // Add to userPhotos array - await spotsCollection.updateOne({ _id: new ObjectId(id) }, { $push: { userPhotos: newPhoto } }); + await spotsCollection.updateOne( + { _id: new ObjectId(id) }, + { $push: { userPhotos: newPhoto } }, + ); console.log(`[Spots] User ${req.user.userId} uploaded photo for spot ${id}`); diff --git a/routes/users.js b/routes/users.js index c41c225..e4b5f74 100644 --- a/routes/users.js +++ b/routes/users.js @@ -95,21 +95,21 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) await usersCollection.insertOne(user); // Don't send password back const { password: _, ...userResponse } = user; - // Auto-add Kaori as homie for new users - try { - const KAORI_BOT_ID = '69c15e55c7ebe2c6884f1267'; - const newUserId = result.insertedId.toString(); - await usersCollection.updateOne( - { _id: result.insertedId }, - { $addToSet: { homies: KAORI_BOT_ID } } - ); - await usersCollection.updateOne( - { _id: new ObjectId(KAORI_BOT_ID) }, - { $addToSet: { homies: newUserId } } - ); - } catch (kaoriErr) { - console.error('Auto-add Kaori error (non-fatal):', kaoriErr.message); - } + // Auto-add Kaori as homie for new users + try { + const KAORI_BOT_ID = '69c15e55c7ebe2c6884f1267'; + const newUserId = result.insertedId.toString(); + await usersCollection.updateOne( + { _id: result.insertedId }, + { $addToSet: { homies: KAORI_BOT_ID } }, + ); + await usersCollection.updateOne( + { _id: new ObjectId(KAORI_BOT_ID) }, + { $addToSet: { homies: newUserId } }, + ); + } catch (kaoriErr) { + console.error('Auto-add Kaori error (non-fatal):', kaoriErr.message); + } res.status(201).send(userResponse); } catch (error) { @@ -382,8 +382,8 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) } // Pagination: ?page=1&limit=20 (defaults: page 1, limit 20) - const page = Math.max(1, parseInt(req.query.page) || 1); - const limit = Math.min(50, Math.max(1, parseInt(req.query.limit) || 20)); + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const limit = Math.min(50, Math.max(1, parseInt(req.query.limit, 10) || 20)); const skip = (page - 1) * limit; const [discoverableUsers, totalCount] = await Promise.all([ @@ -393,7 +393,7 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) .skip(skip) .limit(limit) .toArray(), - usersCollection.countDocuments(filter) + usersCollection.countDocuments(filter), ]); // Backwards compatible: return flat array if no pagination params sent @@ -408,7 +408,7 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) total: totalCount, pages: Math.ceil(totalCount / limit), hasMore: page * limit < totalCount, - } + }, }); } } catch (error) { @@ -481,17 +481,17 @@ MongoClient.connect(connectionString, { useUnifiedTopology: true }) // Add each other as homies immediately await usersCollection.updateOne( { _id: new ObjectId(targetUserId) }, - { + { $addToSet: { homies: senderId }, - $pull: { 'homieRequests.received': { from: senderId } } - } + $pull: { 'homieRequests.received': { from: senderId } }, + }, ); await usersCollection.updateOne( { _id: new ObjectId(senderId) }, - { + { $addToSet: { homies: targetUserId }, - $pull: { 'homieRequests.sent': targetUserId } - } + $pull: { 'homieRequests.sent': targetUserId }, + }, ); return res.send({ message: 'You are now homies!', autoAccepted: true }); } From c30ba6a7d1103473f3b0a14265f2cf7605bf7c12 Mon Sep 17 00:00:00 2001 From: wbaxterh Date: Thu, 23 Apr 2026 16:23:46 -0700 Subject: [PATCH 4/6] fix: resolve remaining biome errors (noAssignInExpressions + format) Convert assignment expressions in arrow callbacks to block bodies and re-format to satisfy biome check. Co-Authored-By: Claude Opus 4.6 (1M context) --- kaori-ai-response.js | 4 +++- routes/dm.js | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/kaori-ai-response.js b/kaori-ai-response.js index 33ac06f..e69ad15 100644 --- a/kaori-ai-response.js +++ b/kaori-ai-response.js @@ -88,7 +88,9 @@ async function callOpenAI(messages, ragContext) { }, (res) => { let data = ''; - res.on('data', (chunk) => (data += chunk)); + res.on('data', (chunk) => { + data += chunk; + }); res.on('end', () => { try { const parsed = JSON.parse(data); diff --git a/routes/dm.js b/routes/dm.js index 4e8655e..3d18cec 100644 --- a/routes/dm.js +++ b/routes/dm.js @@ -12,7 +12,9 @@ async function _generateKaoriResponse(content, _db, _conversationId, senderId) { }, (res) => { let data = ''; - res.on('data', (chunk) => (data += chunk)); + res.on('data', (chunk) => { + data += chunk; + }); res.on('end', () => { try { const parsed = JSON.parse(data); @@ -385,7 +387,9 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) }, (res) => { let data = ''; - res.on('data', (chunk) => (data += chunk)); + res.on('data', (chunk) => { + data += chunk; + }); res.on('end', () => { try { const parsed = JSON.parse(data); From 09a62da717cea1795203a0fec8a9815b1dce573d Mon Sep 17 00:00:00 2001 From: wbaxterh Date: Sun, 26 Apr 2026 22:08:47 -0400 Subject: [PATCH 5/6] feat: tune Kaori voice settings, add OpenRouter fallback, text transforms - Voice tuning: stability 0.55, style 0.45, speed 1.05 for smoother output - Expanded slang dict with gen-z terms, laugh tags, extended vowel cleanup - Added cleanForTTS text transform (strips markdown, collapses punctuation) - Added OpenRouter/kaori-ai-response.js fallback when kaori-bot on :3001 unavailable - Added Kith session debug logging Co-Authored-By: Claude Opus 4.6 (1M context) --- kith-voice/package.json | 8 +++--- kith-voice/src/kaori-character.json | 42 ++++++++++++++++++++++++----- kith-voice/src/server.ts | 21 +++++++++++++-- routes/dm.js | 16 +++++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/kith-voice/package.json b/kith-voice/package.json index 1bbbe75..80a61ce 100644 --- a/kith-voice/package.json +++ b/kith-voice/package.json @@ -8,10 +8,10 @@ "start": "bun src/server.ts" }, "dependencies": { - "@kith/core": "file:../../../../kith/packages/core", - "@kith/runtime-pipecat": "file:../../../../kith/packages/runtime-pipecat", - "@kith/voice-router": "file:../../../../kith/packages/voice-router", - "@kith/observability": "file:../../../../kith/packages/observability" + "@kith/core": "file:/Users/weshuber/Documents/kith/packages/core", + "@kith/runtime-pipecat": "file:/Users/weshuber/Documents/kith/packages/runtime-pipecat", + "@kith/voice-router": "file:/Users/weshuber/Documents/kith/packages/voice-router", + "@kith/observability": "file:/Users/weshuber/Documents/kith/packages/observability" }, "devDependencies": { "typescript": "^5.6.0" diff --git a/kith-voice/src/kaori-character.json b/kith-voice/src/kaori-character.json index fdbf544..eca0bfa 100644 --- a/kith-voice/src/kaori-character.json +++ b/kith-voice/src/kaori-character.json @@ -1,17 +1,45 @@ { "voice": { - "stability": 0.35, - "similarityBoost": 0.85, - "style": 0.65, - "useSpeakerBoost": true + "stability": 0.55, + "similarityBoost": 0.82, + "style": 0.45, + "useSpeakerBoost": true, + "speed": 1.05 }, "slang": { "gg": "good game", "ngl": "not gonna lie", "fr": "for real", + "frfr": "for real for real", "fs": "frontside", "bs": "backside", - "tb": "TrickBook" + "tb": "TrickBook", + "omg": "oh my god", + "rn": "right now", + "tbh": "to be honest", + "nvm": "never mind", + "idk": "I dunno", + "wbu": "what about you", + "imo": "in my opinion", + "icl": "I can't lie", + "lol": "[laughs]", + "lmao": "[laughs]", + "haha": "[laughs]", + "hahaha": "[laughs]", + "hehe": "[giggles]", + "ahhh": "ahh", + "ahhhh": "ahh", + "sooo": "so", + "soooo": "so", + "nooo": "no", + "noooo": "no", + "yesss": "yes", + "yessss": "yes", + "waaait": "wait", + "okayy": "okay", + "okaaay": "okay", + "hmmmm": "hmm", + "hmmm": "hmm" }, "pronunciation": { "uso": "oo-so", @@ -25,7 +53,9 @@ "Kaori": "kah-oh-ree", "Nishidake": "nee-shee-dah-keh", "Sapporo": "sah-poh-roh", - "Hokkaido": "hoh-kai-doh" + "Hokkaido": "hoh-kai-doh", + "SSX": "S S X", + "VRM": "V R M" }, "personaMode": "hype" } diff --git a/kith-voice/src/server.ts b/kith-voice/src/server.ts index 6145f0d..cca239b 100644 --- a/kith-voice/src/server.ts +++ b/kith-voice/src/server.ts @@ -34,9 +34,9 @@ const PORT = Number(process.env.PORT ?? 3040); const ROOT = path.dirname(Bun.fileURLToPath(import.meta.url)); const PYTHON_VENV = path.resolve( ROOT, - '../../../../kith/packages/runtime-pipecat/python/.venv/bin/python', + '../../../../../kith/packages/runtime-pipecat/python/.venv/bin/python', ); -const PYTHON_CWD = path.resolve(ROOT, '../../../../kith/packages/runtime-pipecat/python'); +const PYTHON_CWD = path.resolve(ROOT, '../../../../../kith/packages/runtime-pipecat/python'); const apiKey = process.env.ELEVENLABS_API_KEY; const voiceId = process.env.ELEVENLABS_VOICE_ID ?? 'klHOJHbGA89BjwulA7MN'; @@ -89,10 +89,27 @@ async function createSession(sessionId: string, ws: ServerWebSocket): Pr await runtime.connect({ sessionId }); + /** Clean up AI-generated text for natural TTS output. */ + const cleanForTTS = (text: string): string => { + let t = text; + // Strip markdown bold/italic + t = t.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1'); + // Strip markdown links [text](url) → text + t = t.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); + // Collapse repeated punctuation (!!!! → !) + t = t.replace(/([!?.]){2,}/g, '$1'); + // Collapse extended vowels not caught by slang (e.g. "soooooo" → "so") + t = t.replace(/([a-z])\1{3,}/gi, '$1$1'); + // Strip emoji shortcodes like :sparkles: + t = t.replace(/:[a-z_]+:/g, ''); + return t; + }; + const voice = new VoiceRouter({ runtime, character, slang: KAORI_SLANG, + transforms: [cleanForTTS], }); const unsubscribe = voice.on((event: KithEvent) => { diff --git a/routes/dm.js b/routes/dm.js index 3d18cec..2660cc6 100644 --- a/routes/dm.js +++ b/routes/dm.js @@ -417,6 +417,21 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) 'ahh my brain is glitching rn, try again in a sec - the AI tokens might be out'; } + // Fallback: call OpenRouter directly via kaori-ai-response.js + if (!botResponseText) { + try { + const { generateKaoriResponse } = require('../kaori-ai-response'); + botResponseText = await generateKaoriResponse( + content ? content.trim() : '', + db, + conversationId, + senderId, + ); + } catch (fallbackErr) { + console.error('Kaori fallback error:', fallbackErr.message); + } + } + if (!botResponseText) { botResponseText = "Hey, what's up?"; } @@ -503,6 +518,7 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) } // 8. Fire-and-forget: send bot text to Kith voice service for TTS + console.log(`[Bot] Kith check: sessionId=${kithSessionId || '(none)'}, url=${process.env.KITH_VOICE_URL || '(none)'}`); if (kithSessionId && process.env.KITH_VOICE_URL) { const kithPayload = JSON.stringify({ text: botResponseText }); const kithUrl = new URL(`/speak/${kithSessionId}`, process.env.KITH_VOICE_URL); From 1e7961fbb5fae7315df0ae9937c0abc90dd8a26f Mon Sep 17 00:00:00 2001 From: wbaxterh Date: Tue, 28 Apr 2026 12:50:16 -0400 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20clean=20up=20for=20merge=20?= =?UTF-8?q?=E2=80=94=20remove=20debug=20logs,=20use=20npm=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Kith debug console.log from dm.js - Switch kith-voice deps from absolute file: paths to @kithjs/* npm packages - Rename imports from @kith/ to @kithjs/ - Make Python sidecar paths configurable via env vars for production - Update .env.example with new voice ID and optional path overrides Co-Authored-By: Claude Opus 4.6 (1M context) --- kith-voice/.env.example | 6 +++++- kith-voice/package.json | 8 ++++---- kith-voice/src/server.ts | 19 ++++++++++--------- routes/dm.js | 1 - 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/kith-voice/.env.example b/kith-voice/.env.example index 92d7d8c..296c8c5 100644 --- a/kith-voice/.env.example +++ b/kith-voice/.env.example @@ -1,9 +1,13 @@ # ElevenLabs credentials ELEVENLABS_API_KEY=sk_your_key_here -ELEVENLABS_VOICE_ID=klHOJHbGA89BjwulA7MN +ELEVENLABS_VOICE_ID=kPzsL2i3teMYv0FxEYQ6 # Server port (default 3040) PORT=3040 # ElevenLabs model (default eleven_v3, required for laugh tags) # ELEVENLABS_MODEL_ID=eleven_v3 + +# Python sidecar paths (optional, auto-resolved from node_modules) +# PIPECAT_PYTHON_PATH=/path/to/.venv/bin/python +# PIPECAT_PYTHON_CWD=/path/to/runtime-pipecat/python diff --git a/kith-voice/package.json b/kith-voice/package.json index 80a61ce..cc4615a 100644 --- a/kith-voice/package.json +++ b/kith-voice/package.json @@ -8,10 +8,10 @@ "start": "bun src/server.ts" }, "dependencies": { - "@kith/core": "file:/Users/weshuber/Documents/kith/packages/core", - "@kith/runtime-pipecat": "file:/Users/weshuber/Documents/kith/packages/runtime-pipecat", - "@kith/voice-router": "file:/Users/weshuber/Documents/kith/packages/voice-router", - "@kith/observability": "file:/Users/weshuber/Documents/kith/packages/observability" + "@kithjs/core": "^0.1.0", + "@kithjs/runtime-pipecat": "^0.1.0", + "@kithjs/voice-router": "^0.1.0", + "@kithjs/observability": "^0.1.0" }, "devDependencies": { "typescript": "^5.6.0" diff --git a/kith-voice/src/server.ts b/kith-voice/src/server.ts index cca239b..8bbd837 100644 --- a/kith-voice/src/server.ts +++ b/kith-voice/src/server.ts @@ -12,9 +12,9 @@ */ import path from 'node:path'; -import type { KithEvent } from '@kith/core'; -import { consoleExporter, InMemoryObservability } from '@kith/observability'; -import { PipecatRuntime } from '@kith/runtime-pipecat'; +import type { KithEvent } from '@kithjs/core'; +import { consoleExporter, InMemoryObservability } from '@kithjs/observability'; +import { PipecatRuntime } from '@kithjs/runtime-pipecat'; import { DEFAULT_BOARD_SPORTS_SLANG, DEFAULT_ENGLISH_SLANG, @@ -23,7 +23,7 @@ import { type VoiceCharacter, VoiceRouter, voiceCharacterToRuntimeConfig, -} from '@kith/voice-router'; +} from '@kithjs/voice-router'; import type { ServerWebSocket } from 'bun'; import kaoriProfile from './kaori-character.json' with { type: 'json' }; @@ -32,11 +32,12 @@ const character = kaoriProfile as VoiceCharacter; const PORT = Number(process.env.PORT ?? 3040); const ROOT = path.dirname(Bun.fileURLToPath(import.meta.url)); -const PYTHON_VENV = path.resolve( - ROOT, - '../../../../../kith/packages/runtime-pipecat/python/.venv/bin/python', -); -const PYTHON_CWD = path.resolve(ROOT, '../../../../../kith/packages/runtime-pipecat/python'); +const PYTHON_VENV = + process.env.PIPECAT_PYTHON_PATH || + path.resolve(ROOT, '../node_modules/@kithjs/runtime-pipecat/python/.venv/bin/python'); +const PYTHON_CWD = + process.env.PIPECAT_PYTHON_CWD || + path.resolve(ROOT, '../node_modules/@kithjs/runtime-pipecat/python'); const apiKey = process.env.ELEVENLABS_API_KEY; const voiceId = process.env.ELEVENLABS_VOICE_ID ?? 'klHOJHbGA89BjwulA7MN'; diff --git a/routes/dm.js b/routes/dm.js index 2660cc6..3627050 100644 --- a/routes/dm.js +++ b/routes/dm.js @@ -518,7 +518,6 @@ MongoClient.connect(process.env.ATLAS_URI, { useUnifiedTopology: true }) } // 8. Fire-and-forget: send bot text to Kith voice service for TTS - console.log(`[Bot] Kith check: sessionId=${kithSessionId || '(none)'}, url=${process.env.KITH_VOICE_URL || '(none)'}`); if (kithSessionId && process.env.KITH_VOICE_URL) { const kithPayload = JSON.stringify({ text: botResponseText }); const kithUrl = new URL(`/speak/${kithSessionId}`, process.env.KITH_VOICE_URL);