Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
**/dist
.mentat/logs
**/package-lock.json
persist/**
4 changes: 2 additions & 2 deletions .mentat/README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down
129 changes: 125 additions & 4 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import mentatLogo from '/mentat.png';

function App() {
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

// Polling state
const [log, setLog] = useState<string[]>([]);
const [input, setInput] = useState('');
const latestIdRef = useRef(0);

useEffect(() => {
const fetchBackendMessage = async () => {
setLoading(true);
Expand Down Expand Up @@ -33,14 +38,63 @@ 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 (
<div
style={{
backgroundColor: '#fafafa',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
height: '100vh',
minHeight: '100vh',
width: '100vw',
justifyContent: 'center',
padding: '20px',
Expand All @@ -59,7 +113,7 @@ function App() {
<div
className="paper"
style={{
maxWidth: '500px',
maxWidth: '560px',
display: 'flex',
flexDirection: 'column',
gap: '24px',
Expand All @@ -81,7 +135,11 @@ function App() {
['Backend', 'Node.js, Express, Jest'],
['Utilities', 'TypeScript, ESLint, Prettier'],
].map(([title, techs]) => (
<div className="section" style={{ textAlign: 'center' }} key={title}>
<div
className="section"
style={{ textAlign: 'center' }}
key={title}
>
<div
style={{
fontWeight: '500',
Expand Down Expand Up @@ -124,6 +182,69 @@ function App() {
</div>
</div>

{/* Simple messaging via HTTP + polling */}
<div className="section">
<div
style={{
fontSize: '14px',
fontWeight: '500',
color: '#1f2937',
marginBottom: '8px',
}}
>
Simple Messages (1s polling)
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message"
style={{
flex: 1,
padding: '8px 10px',
borderRadius: 6,
border: '1px solid #e5e7eb',
fontSize: 14,
}}
/>
<button
onClick={sendMessage}
style={{
padding: '8px 12px',
borderRadius: 6,
border: '1px solid #1f2937',
backgroundColor: '#111827',
color: 'white',
fontSize: 14,
cursor: 'pointer',
}}
>
Send
</button>
</div>
<div
style={{
marginTop: '12px',
maxHeight: 180,
overflow: 'auto',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: 12,
background: '#f8fafc',
border: '1px solid #e5e7eb',
borderRadius: 8,
padding: 8,
}}
>
{log.length === 0 ? (
<div style={{ color: '#6b7280', fontStyle: 'italic' }}>
No messages yet. Type above and press Send.
</div>
) : (
log.map((line, i) => <div key={i}>• {line}</div>)
)}
</div>
</div>

{/* Call to action */}
<div
style={{
Expand Down
11 changes: 8 additions & 3 deletions client/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@ describe('App Component', () => {
it('renders App component correctly', () => {
render(<App />);
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();
});

Expand Down
6 changes: 6 additions & 0 deletions client/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
},
});
4 changes: 3 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@
"description": "",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2"
"express": "^4.21.2",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.1",
"@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",
Expand Down
68 changes: 68 additions & 0 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand Down
27 changes: 25 additions & 2 deletions server/src/server.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});