diff --git a/.gitignore b/.gitignore index 4e04de0..e78ae7c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ **/dist .mentat/logs **/package-lock.json +persist/** diff --git a/.mentat/README.md b/.mentat/README.md index 6b60e46..ea20875 100644 --- a/.mentat/README.md +++ b/.mentat/README.md @@ -1,13 +1,13 @@ # Mentat Party This is the Mentat Party Agent, an experiment in massively-multiplayer Mentats. -This agent will be publicly available at mentat.ai/party, and anyone can message it. +This agent will be publicly available at mentat.ai/party, and anyone can message it. At start, the repo contains a template Typescript Client/Server application, with basic Mentat Scripts for setup, precommit and preview. Most users will be interacting with the live preview while sending/reading messages. -Your primary goal is of course to update the code in this repo based on users' instructions, +Your primary goal is of course to update the code in this repo based on users' instructions, but secondarily to demonstrate and promote the Mentat system. There will undoubtedly arise unusual situations where your discretion is needed, but in general: diff --git a/client/src/App.tsx b/client/src/App.tsx index 43b4b68..66812c6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import mentatLogo from '/mentat.png'; function App() { @@ -6,6 +6,11 @@ function App() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Polling state + const [log, setLog] = useState([]); + const [input, setInput] = useState(''); + const latestIdRef = useRef(0); + useEffect(() => { const fetchBackendMessage = async () => { setLoading(true); @@ -33,6 +38,55 @@ function App() { fetchBackendMessage(); }, []); + // Poll every 1s for new messages (stable background loop) + useEffect(() => { + let cancelled = false; + const tick = async () => { + try { + const res = await fetch(`/messages?sinceId=${latestIdRef.current}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: { + messages: { id: number; text: string }[]; + latestId: number; + } = await res.json(); + if (cancelled) return; + if (data.messages.length) { + setLog((prev) => [ + ...prev, + ...data.messages.map((m) => `Server: ${m.text}`), + ]); + latestIdRef.current = data.latestId; + } + } catch { + // ignore transient polling errors + } + }; + const id = setInterval(tick, 1000); + // run once immediately + tick(); + return () => { + cancelled = true; + clearInterval(id); + }; + }, []); + + const sendMessage = async () => { + const text = input || 'Hello'; + setInput(''); + // Optimistic append + setLog((prev) => [...prev, `You: ${text}`]); + const res = await fetch('/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + if (res.ok) { + const created: { id: number } = await res.json(); + // Advance the ref so next poll only fetches newer items + latestIdRef.current = Math.max(latestIdRef.current, created.id); + } + }; + return (
( -
+
+ {/* Simple messaging via HTTP + polling */} +
+
+ Simple Messages (1s polling) +
+
+ setInput(e.target.value)} + placeholder="Type a message" + style={{ + flex: 1, + padding: '8px 10px', + borderRadius: 6, + border: '1px solid #e5e7eb', + fontSize: 14, + }} + /> + +
+
+ {log.length === 0 ? ( +
+ No messages yet. Type above and press Send. +
+ ) : ( + log.map((line, i) =>
• {line}
) + )} +
+
+ {/* Call to action */}
{ it('renders App component correctly', () => { render(); expect(screen.getByText('Mentat Template JS')).toBeInTheDocument(); - expect(screen.getByText(/Frontend: React, Vite/)).toBeInTheDocument(); - expect(screen.getByText(/Backend: Node.js, Express/)).toBeInTheDocument(); + // Tech stack sections (titles) + expect(screen.getByText('Frontend')).toBeInTheDocument(); + expect(screen.getByText('Backend')).toBeInTheDocument(); + expect(screen.getByText('Utilities')).toBeInTheDocument(); + // Tech stack details (content) + expect(screen.getByText('React, Vite, Vitest')).toBeInTheDocument(); + expect(screen.getByText('Node.js, Express, Jest')).toBeInTheDocument(); expect( - screen.getByText(/Utilities: Typescript, ESLint, Prettier/) + screen.getByText('TypeScript, ESLint, Prettier') ).toBeInTheDocument(); }); diff --git a/client/vite.config.ts b/client/vite.config.ts index fffec08..4785f92 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -10,6 +10,12 @@ export default defineConfig({ target: 'http://localhost:5000', changeOrigin: true, }, + // Proxy WebSocket connections in dev so we can use relative ws path + '/socket': { + target: 'ws://localhost:5000', + changeOrigin: true, + ws: true, + }, }, }, }); diff --git a/server/package.json b/server/package.json index 7d8a761..70b0987 100644 --- a/server/package.json +++ b/server/package.json @@ -15,7 +15,8 @@ "description": "", "dependencies": { "cors": "^2.8.5", - "express": "^4.21.2" + "express": "^4.21.2", + "ws": "^8.18.0" }, "devDependencies": { "@types/cors": "^2.8.17", @@ -23,6 +24,7 @@ "@types/jest": "^29.5.12", "@types/node": "^22.13.13", "@types/supertest": "^6.0.2", + "@types/ws": "^8.18.1", "jest": "^29.7.0", "supertest": "^6.3.4", "ts-jest": "^29.1.2", diff --git a/server/src/app.ts b/server/src/app.ts index 2cbac38..032bab9 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -2,6 +2,7 @@ import express, { Request, Response } from 'express'; import cors from 'cors'; import path from 'path'; import { existsSync } from 'fs'; +import { promises as fsp } from 'fs'; export const app = express(); export const PORT = process.env.PORT || 5000; @@ -17,6 +18,73 @@ app.get('/api', (req: Request, res: Response) => { res.json({ message: 'Welcome to the Mentat API!' }); }); +/** + * Simple in-memory message store for polling demo + */ +type Message = { id: number; text: string; ts: number }; +const messages: Message[] = []; +let nextId = 1; + +// Persist messages to a simple JSON file for demo purposes +const DATA_DIR = path.join(__dirname, '../../persist'); +const DATA_FILE = path.join(DATA_DIR, 'messages.json'); + +// Load messages at startup +(async () => { + try { + if (existsSync(DATA_FILE)) { + const raw = await fsp.readFile(DATA_FILE, 'utf-8'); + const parsed: Message[] = JSON.parse(raw); + messages.push(...parsed); + nextId = parsed.reduce((max, m) => Math.max(max, m.id), 0) + 1; + } else { + // ensure directory exists + await fsp.mkdir(DATA_DIR, { recursive: true }); + await fsp.writeFile( + DATA_FILE, + JSON.stringify(messages, null, 2), + 'utf-8' + ); + } + } catch (e) { + // Non-fatal: keep in-memory only if persistence fails + + console.warn('Failed to load persisted messages:', e); + } +})(); + +async function persistMessages() { + try { + await fsp.writeFile(DATA_FILE, JSON.stringify(messages, null, 2), 'utf-8'); + } catch (e) { + + console.warn('Failed to persist messages:', e); + } +} + +// Post a new message +app.post('/messages', (req: Request, res: Response) => { + const text = String(req.body?.text ?? '').trim(); + if (!text) { + return res.status(400).json({ error: 'text is required' }); + } + const msg: Message = { id: nextId++, text, ts: Date.now() }; + messages.push(msg); + // fire-and-forget persistence + void persistMessages(); + res.status(201).json(msg); +}); + +// Poll for messages after a given id +app.get('/messages', (req: Request, res: Response) => { + const sinceId = Number(req.query.sinceId ?? 0); + const result = messages.filter((m) => m.id > sinceId); + res.json({ + messages: result, + latestId: messages.length ? messages[messages.length - 1].id : sinceId, + }); +}); + // Serve React app or fallback page app.get('*', (req: Request, res: Response) => { const indexPath = path.join(CLIENT_DIST_PATH, 'index.html'); diff --git a/server/src/server.ts b/server/src/server.ts index 37252da..315ee91 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,6 +1,29 @@ import { app, PORT } from './app'; +import { WebSocketServer } from 'ws'; +import type { IncomingMessage } from 'http'; +import type { WebSocket, RawData } from 'ws'; -// Start server -app.listen(PORT, () => { +// Start HTTP server and attach WebSocket server +const server = app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); }); + +const wss = new WebSocketServer({ server, path: '/socket' }); + +wss.on('connection', (ws: WebSocket, req: IncomingMessage) => { + console.log('WebSocket client connected', req.socket.remoteAddress); + + ws.send( + JSON.stringify({ type: 'welcome', message: 'Connected to echo server' }) + ); + + ws.on('message', (data: RawData) => { + // Echo back whatever was sent, as text + const text = typeof data === 'string' ? data : data.toString('utf-8'); + ws.send(JSON.stringify({ type: 'echo', message: text })); + }); + + ws.on('close', () => { + console.log('WebSocket client disconnected'); + }); +});