replicas-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +286 -0
- package/dist/index.js +24 -0
- package/dist/middleware/auth.js +14 -0
- package/dist/routes/codex.js +112 -0
- package/dist/routes/ping.js +9 -0
- package/dist/services/codex-manager.js +103 -0
- package/dist/utils/jsonl-reader.js +127 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# Replicas Engine
|
|
2
|
+
|
|
3
|
+
A lightweight REST API server that runs on each Replicas workspace, providing programmatic access to workspace functionality without requiring SSH access.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The Replicas Engine is automatically installed on every workspace and runs as a systemd service. It exposes a simple HTTP API protected by a secret key, allowing the Replicas backend to interact with workspaces for operations like reading logs, checking status, and more.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Lightweight**: Built with Hono for minimal overhead
|
|
12
|
+
- **Secure**: All endpoints (except health check) require authentication via secret header
|
|
13
|
+
- **Automatic**: Installed and configured automatically on workspace creation
|
|
14
|
+
- **Reliable**: Runs as a systemd service with automatic restart
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
The engine is automatically installed during workspace initialization. For manual installation:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
yarn global add replicas-engine
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
The engine requires the following environment variables:
|
|
27
|
+
|
|
28
|
+
- `PORT` - Port to listen on (default: 3737)
|
|
29
|
+
- `REPLICAS_ENGINE_SECRET` - Secret key for authentication (required)
|
|
30
|
+
- `NODE_ENV` - Environment mode (production/development)
|
|
31
|
+
- `WORKSPACE_HOME` - Working directory for Codex (default: `/home/ubuntu`)
|
|
32
|
+
|
|
33
|
+
### Running as a Service
|
|
34
|
+
|
|
35
|
+
The engine automatically runs as a systemd service named `replicas-engine`. You can manage it using:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Check status
|
|
39
|
+
systemctl status replicas-engine
|
|
40
|
+
|
|
41
|
+
# View logs
|
|
42
|
+
journalctl -u replicas-engine -f
|
|
43
|
+
|
|
44
|
+
# Restart service
|
|
45
|
+
sudo systemctl restart replicas-engine
|
|
46
|
+
|
|
47
|
+
# Stop service
|
|
48
|
+
sudo systemctl stop replicas-engine
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## API Endpoints
|
|
52
|
+
|
|
53
|
+
### Health Check
|
|
54
|
+
|
|
55
|
+
**GET /health**
|
|
56
|
+
|
|
57
|
+
Public endpoint for health checks (no authentication required).
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
curl http://localhost:3737/health
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Response:
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"status": "ok",
|
|
67
|
+
"timestamp": "2025-10-26T04:00:00.000Z"
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Ping
|
|
72
|
+
|
|
73
|
+
**GET /ping**
|
|
74
|
+
|
|
75
|
+
Authenticated endpoint for testing connectivity.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
curl http://localhost:3737/ping \
|
|
79
|
+
-H "X-Replicas-Engine-Secret: your-secret-here"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Response:
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"message": "pong",
|
|
86
|
+
"timestamp": "2025-10-26T04:00:00.000Z"
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Codex Integration
|
|
93
|
+
|
|
94
|
+
The engine provides remote control of Codex coding agents running on the workspace.
|
|
95
|
+
|
|
96
|
+
### Send Message (with Streaming)
|
|
97
|
+
|
|
98
|
+
**POST /codex/send**
|
|
99
|
+
|
|
100
|
+
Send a message to Codex and stream events back via Server-Sent Events (SSE).
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
curl -N http://localhost:3737/codex/send \
|
|
104
|
+
-H "X-Replicas-Engine-Secret: your-secret-here" \
|
|
105
|
+
-H "Content-Type: application/json" \
|
|
106
|
+
-d '{"message": "Add error handling to the login function"}'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Request Body:**
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"message": "Your instruction to Codex"
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Response (SSE Stream):**
|
|
117
|
+
```
|
|
118
|
+
event: thread.started
|
|
119
|
+
data: {"thread_id":"019a1caa-bceb-7731-b607-e9d22c1933a5"}
|
|
120
|
+
|
|
121
|
+
event: turn.started
|
|
122
|
+
data: {}
|
|
123
|
+
|
|
124
|
+
event: item.started
|
|
125
|
+
data: {"item":{"id":"...","type":"agent_message","text":"..."}}
|
|
126
|
+
|
|
127
|
+
event: item.completed
|
|
128
|
+
data: {"item":{"id":"...","type":"agent_message","text":"I'll help add error handling..."}}
|
|
129
|
+
|
|
130
|
+
event: turn.completed
|
|
131
|
+
data: {"usage":{"input_tokens":100,"output_tokens":50,"cached_input_tokens":0}}
|
|
132
|
+
|
|
133
|
+
event: done
|
|
134
|
+
data: {}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Event Types:**
|
|
138
|
+
- `thread.started` - New thread created (includes thread_id)
|
|
139
|
+
- `turn.started` - Turn begins
|
|
140
|
+
- `item.started` - New item (message, command, file change, etc.)
|
|
141
|
+
- `item.updated` - Item updated
|
|
142
|
+
- `item.completed` - Item finished
|
|
143
|
+
- `turn.completed` - Turn ends (includes token usage)
|
|
144
|
+
- `error` - Error occurred
|
|
145
|
+
- `done` - Stream completed
|
|
146
|
+
|
|
147
|
+
### Get Conversation History
|
|
148
|
+
|
|
149
|
+
**GET /codex/history**
|
|
150
|
+
|
|
151
|
+
Retrieve the full conversation history from the JSONL session file.
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
curl http://localhost:3737/codex/history \
|
|
155
|
+
-H "X-Replicas-Engine-Secret: your-secret-here"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Response:**
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"thread_id": "019a1caa-bceb-7731-b607-e9d22c1933a5",
|
|
162
|
+
"events": [
|
|
163
|
+
{
|
|
164
|
+
"timestamp": "2025-10-25T18:39:02.919Z",
|
|
165
|
+
"type": "session_meta",
|
|
166
|
+
"payload": {...}
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"timestamp": "2025-10-25T18:52:11.923Z",
|
|
170
|
+
"type": "response_item",
|
|
171
|
+
"payload": {"type": "message", "role": "user", ...}
|
|
172
|
+
}
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Get Thread Status
|
|
178
|
+
|
|
179
|
+
**GET /codex/status**
|
|
180
|
+
|
|
181
|
+
Get information about the current Codex thread.
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
curl http://localhost:3737/codex/status \
|
|
185
|
+
-H "X-Replicas-Engine-Secret: your-secret-here"
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Response:**
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"has_active_thread": true,
|
|
192
|
+
"thread_id": "019a1caa-bceb-7731-b607-e9d22c1933a5",
|
|
193
|
+
"session_file": "/home/ubuntu/.codex/sessions/2025/10/25/rollout-2025-10-25T18:39:02.891Z-019a1caa.jsonl",
|
|
194
|
+
"working_directory": "/home/ubuntu"
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Reset Thread
|
|
199
|
+
|
|
200
|
+
**POST /codex/reset**
|
|
201
|
+
|
|
202
|
+
Clear the current thread and start a fresh conversation.
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
curl -X POST http://localhost:3737/codex/reset \
|
|
206
|
+
-H "X-Replicas-Engine-Secret: your-secret-here"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Response:**
|
|
210
|
+
```json
|
|
211
|
+
{
|
|
212
|
+
"message": "Thread reset successfully",
|
|
213
|
+
"success": true
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Codex Usage Notes
|
|
218
|
+
|
|
219
|
+
- **Single Session**: Currently supports one active thread per workspace
|
|
220
|
+
- **Thread Continuity**: Subsequent calls to `/codex/send` continue the same conversation
|
|
221
|
+
- **Persistence**: Threads are automatically saved to `~/.codex/sessions/`
|
|
222
|
+
- **Working Directory**: Codex runs in `WORKSPACE_HOME` (default: `/home/ubuntu`)
|
|
223
|
+
- **Git Repository**: Git check is skipped to allow running in any directory
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Authentication
|
|
228
|
+
|
|
229
|
+
All endpoints (except `/health`) require the `X-Replicas-Engine-Secret` header:
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
X-Replicas-Engine-Secret: <workspace-engine-secret>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Requests without this header or with an invalid secret will receive a 401 Unauthorized response.
|
|
236
|
+
|
|
237
|
+
## Development
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# Install dependencies
|
|
241
|
+
yarn install
|
|
242
|
+
|
|
243
|
+
# Run in development mode
|
|
244
|
+
yarn dev
|
|
245
|
+
|
|
246
|
+
# Build for production
|
|
247
|
+
yarn build
|
|
248
|
+
|
|
249
|
+
# Run built version
|
|
250
|
+
yarn start
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Security
|
|
254
|
+
|
|
255
|
+
- The engine only listens on port 3737
|
|
256
|
+
- All requests require a secret key
|
|
257
|
+
- The secret is generated uniquely per workspace
|
|
258
|
+
- The service runs as the workspace user (no root access)
|
|
259
|
+
|
|
260
|
+
## Troubleshooting
|
|
261
|
+
|
|
262
|
+
### Engine not responding
|
|
263
|
+
|
|
264
|
+
1. Check if the service is running:
|
|
265
|
+
```bash
|
|
266
|
+
systemctl status replicas-engine
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
2. Check the logs:
|
|
270
|
+
```bash
|
|
271
|
+
journalctl -u replicas-engine -n 50
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
3. Verify the port is listening:
|
|
275
|
+
```bash
|
|
276
|
+
sudo netstat -tlnp | grep 3737
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Authentication errors
|
|
280
|
+
|
|
281
|
+
- Ensure you're using the correct `engine_secret` from the workspace record
|
|
282
|
+
- Verify the `X-Replicas-Engine-Secret` header is being sent
|
|
283
|
+
|
|
284
|
+
## License
|
|
285
|
+
|
|
286
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import 'dotenv/config';
|
|
3
|
+
import { serve } from '@hono/node-server';
|
|
4
|
+
import { Hono } from 'hono';
|
|
5
|
+
import { authMiddleware } from './middleware/auth.js';
|
|
6
|
+
import ping from './routes/ping.js';
|
|
7
|
+
import codex from './routes/codex.js';
|
|
8
|
+
const app = new Hono();
|
|
9
|
+
// Health check endpoint (no auth required)
|
|
10
|
+
app.get('/health', (c) => {
|
|
11
|
+
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
12
|
+
});
|
|
13
|
+
// Apply auth middleware to all routes below
|
|
14
|
+
app.use('*', authMiddleware);
|
|
15
|
+
// Protected routes
|
|
16
|
+
app.route('/ping', ping);
|
|
17
|
+
app.route('/codex', codex);
|
|
18
|
+
const port = Number(process.env.PORT) || 3737;
|
|
19
|
+
serve({
|
|
20
|
+
fetch: app.fetch,
|
|
21
|
+
port,
|
|
22
|
+
}, (info) => {
|
|
23
|
+
console.log(`Replicas Engine running on port ${info.port}`);
|
|
24
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const authMiddleware = async (c, next) => {
|
|
2
|
+
const secret = c.req.header('X-Replicas-Engine-Secret');
|
|
3
|
+
const expectedSecret = process.env.REPLICAS_ENGINE_SECRET;
|
|
4
|
+
if (!expectedSecret) {
|
|
5
|
+
return c.json({ error: 'Server configuration error: REPLICAS_ENGINE_SECRET not set' }, 500);
|
|
6
|
+
}
|
|
7
|
+
if (!secret) {
|
|
8
|
+
return c.json({ error: 'Unauthorized: X-Replicas-Engine-Secret header required' }, 401);
|
|
9
|
+
}
|
|
10
|
+
if (secret !== expectedSecret) {
|
|
11
|
+
return c.json({ error: 'Unauthorized: Invalid secret' }, 401);
|
|
12
|
+
}
|
|
13
|
+
await next();
|
|
14
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { stream } from 'hono/streaming';
|
|
3
|
+
import { CodexManager } from '../services/codex-manager.js';
|
|
4
|
+
const codex = new Hono();
|
|
5
|
+
// Create a singleton instance of CodexManager
|
|
6
|
+
const codexManager = new CodexManager();
|
|
7
|
+
/**
|
|
8
|
+
* POST /codex/send
|
|
9
|
+
* Send a message to Codex and stream events back via Server-Sent Events (SSE)
|
|
10
|
+
*/
|
|
11
|
+
codex.post('/send', async (c) => {
|
|
12
|
+
try {
|
|
13
|
+
const body = await c.req.json();
|
|
14
|
+
const { message } = body;
|
|
15
|
+
if (!message || typeof message !== 'string') {
|
|
16
|
+
return c.json({ error: 'Message is required and must be a string' }, 400);
|
|
17
|
+
}
|
|
18
|
+
// Stream events using SSE
|
|
19
|
+
return stream(c, async (stream) => {
|
|
20
|
+
// Set SSE headers
|
|
21
|
+
stream.onAbort(() => {
|
|
22
|
+
console.log('Client aborted SSE connection');
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
// Stream Codex events
|
|
26
|
+
for await (const event of codexManager.sendMessage(message)) {
|
|
27
|
+
// Format as Server-Sent Event
|
|
28
|
+
const sseData = `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
29
|
+
await stream.write(sseData);
|
|
30
|
+
}
|
|
31
|
+
// Send a final 'done' event to signal completion
|
|
32
|
+
await stream.write('event: done\ndata: {}\n\n');
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.error('Error during Codex streaming:', error);
|
|
36
|
+
const errorData = `event: error\ndata: ${JSON.stringify({
|
|
37
|
+
message: error instanceof Error ? error.message : 'Unknown error occurred',
|
|
38
|
+
})}\n\n`;
|
|
39
|
+
await stream.write(errorData);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
console.error('Error in /codex/send:', error);
|
|
45
|
+
return c.json({
|
|
46
|
+
error: 'Failed to process message',
|
|
47
|
+
details: error instanceof Error ? error.message : 'Unknown error',
|
|
48
|
+
}, 500);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* GET /codex/history
|
|
53
|
+
* Get the conversation history from the JSONL session file
|
|
54
|
+
*/
|
|
55
|
+
codex.get('/history', async (c) => {
|
|
56
|
+
try {
|
|
57
|
+
const history = await codexManager.getHistory();
|
|
58
|
+
if (!history.thread_id) {
|
|
59
|
+
return c.json({
|
|
60
|
+
message: 'No active thread',
|
|
61
|
+
thread_id: null,
|
|
62
|
+
events: [],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return c.json(history);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.error('Error in /codex/history:', error);
|
|
69
|
+
return c.json({
|
|
70
|
+
error: 'Failed to retrieve history',
|
|
71
|
+
details: error instanceof Error ? error.message : 'Unknown error',
|
|
72
|
+
}, 500);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
/**
|
|
76
|
+
* GET /codex/status
|
|
77
|
+
* Get current thread status and information
|
|
78
|
+
*/
|
|
79
|
+
codex.get('/status', async (c) => {
|
|
80
|
+
try {
|
|
81
|
+
const status = await codexManager.getStatus();
|
|
82
|
+
return c.json(status);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
console.error('Error in /codex/status:', error);
|
|
86
|
+
return c.json({
|
|
87
|
+
error: 'Failed to retrieve status',
|
|
88
|
+
details: error instanceof Error ? error.message : 'Unknown error',
|
|
89
|
+
}, 500);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
/**
|
|
93
|
+
* POST /codex/reset
|
|
94
|
+
* Reset the current thread and start fresh
|
|
95
|
+
*/
|
|
96
|
+
codex.post('/reset', async (c) => {
|
|
97
|
+
try {
|
|
98
|
+
codexManager.reset();
|
|
99
|
+
return c.json({
|
|
100
|
+
message: 'Thread reset successfully',
|
|
101
|
+
success: true,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
console.error('Error in /codex/reset:', error);
|
|
106
|
+
return c.json({
|
|
107
|
+
error: 'Failed to reset thread',
|
|
108
|
+
details: error instanceof Error ? error.message : 'Unknown error',
|
|
109
|
+
}, 500);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
export default codex;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Codex, Thread } from '@openai/codex-sdk';
|
|
2
|
+
import { findSessionFile, readJSONL } from '../utils/jsonl-reader.js';
|
|
3
|
+
/**
|
|
4
|
+
* Manages Codex thread lifecycle and session state
|
|
5
|
+
* Currently supports a single active thread per engine instance
|
|
6
|
+
*/
|
|
7
|
+
export class CodexManager {
|
|
8
|
+
codex;
|
|
9
|
+
currentThreadId = null;
|
|
10
|
+
currentThread = null;
|
|
11
|
+
workingDirectory;
|
|
12
|
+
constructor(workingDirectory) {
|
|
13
|
+
this.codex = new Codex();
|
|
14
|
+
this.workingDirectory = workingDirectory || process.env.WORKSPACE_HOME || process.env.HOME || '/home/ubuntu';
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Send a message to Codex and stream events back
|
|
18
|
+
* Creates a new thread on first call, resumes existing thread on subsequent calls
|
|
19
|
+
*/
|
|
20
|
+
async *sendMessage(message) {
|
|
21
|
+
// Initialize or resume thread
|
|
22
|
+
if (!this.currentThread) {
|
|
23
|
+
if (this.currentThreadId) {
|
|
24
|
+
console.log(`Resuming thread ${this.currentThreadId}`);
|
|
25
|
+
this.currentThread = this.codex.resumeThread(this.currentThreadId, {
|
|
26
|
+
workingDirectory: this.workingDirectory,
|
|
27
|
+
skipGitRepoCheck: true,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
console.log('Starting new thread');
|
|
32
|
+
this.currentThread = this.codex.startThread({
|
|
33
|
+
workingDirectory: this.workingDirectory,
|
|
34
|
+
skipGitRepoCheck: true,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Stream events from Codex
|
|
39
|
+
const { events } = await this.currentThread.runStreamed(message);
|
|
40
|
+
for await (const event of events) {
|
|
41
|
+
// Capture thread ID when thread starts
|
|
42
|
+
if (event.type === 'thread.started') {
|
|
43
|
+
this.currentThreadId = event.thread_id;
|
|
44
|
+
console.log(`Thread started: ${this.currentThreadId}`);
|
|
45
|
+
}
|
|
46
|
+
yield event;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Get conversation history by reading the JSONL session file
|
|
51
|
+
*/
|
|
52
|
+
async getHistory() {
|
|
53
|
+
if (!this.currentThreadId) {
|
|
54
|
+
return {
|
|
55
|
+
thread_id: null,
|
|
56
|
+
events: [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const sessionFile = await findSessionFile(this.currentThreadId);
|
|
60
|
+
if (!sessionFile) {
|
|
61
|
+
console.warn(`Session file not found for thread ${this.currentThreadId}`);
|
|
62
|
+
return {
|
|
63
|
+
thread_id: this.currentThreadId,
|
|
64
|
+
events: [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
console.log(`Reading session file: ${sessionFile}`);
|
|
68
|
+
const events = await readJSONL(sessionFile);
|
|
69
|
+
return {
|
|
70
|
+
thread_id: this.currentThreadId,
|
|
71
|
+
events,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Get current thread status
|
|
76
|
+
*/
|
|
77
|
+
async getStatus() {
|
|
78
|
+
let sessionFile = null;
|
|
79
|
+
if (this.currentThreadId) {
|
|
80
|
+
sessionFile = await findSessionFile(this.currentThreadId);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
has_active_thread: this.currentThreadId !== null,
|
|
84
|
+
thread_id: this.currentThreadId,
|
|
85
|
+
session_file: sessionFile,
|
|
86
|
+
working_directory: this.workingDirectory,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Reset the current thread (start fresh conversation)
|
|
91
|
+
*/
|
|
92
|
+
reset() {
|
|
93
|
+
console.log('Resetting Codex thread');
|
|
94
|
+
this.currentThread = null;
|
|
95
|
+
this.currentThreadId = null;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Get the current thread ID
|
|
99
|
+
*/
|
|
100
|
+
getThreadId() {
|
|
101
|
+
return this.currentThreadId;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
/**
|
|
5
|
+
* Read and parse a JSONL file
|
|
6
|
+
*/
|
|
7
|
+
export async function readJSONL(filePath) {
|
|
8
|
+
try {
|
|
9
|
+
const content = await readFile(filePath, 'utf-8');
|
|
10
|
+
return content
|
|
11
|
+
.split('\n')
|
|
12
|
+
.filter((line) => line.trim())
|
|
13
|
+
.map((line) => {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(line);
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
console.error(`Failed to parse JSONL line: ${line}`, e);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
.filter((event) => event !== null);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
console.error(`Failed to read JSONL file ${filePath}:`, error);
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Find the session file for a given thread ID
|
|
31
|
+
* Sessions are stored in ~/.codex/sessions/YYYY/MM/DD/rollout-*-{threadId}.jsonl
|
|
32
|
+
*/
|
|
33
|
+
export async function findSessionFile(threadId) {
|
|
34
|
+
const sessionsDir = join(homedir(), '.codex', 'sessions');
|
|
35
|
+
try {
|
|
36
|
+
// Get current date for searching
|
|
37
|
+
const now = new Date();
|
|
38
|
+
const year = now.getFullYear();
|
|
39
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
40
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
41
|
+
// Search in today's directory first
|
|
42
|
+
const todayDir = join(sessionsDir, String(year), month, day);
|
|
43
|
+
const file = await findFileInDirectory(todayDir, threadId);
|
|
44
|
+
if (file)
|
|
45
|
+
return file;
|
|
46
|
+
// If not found, search recent days (last 7 days)
|
|
47
|
+
for (let daysAgo = 1; daysAgo <= 7; daysAgo++) {
|
|
48
|
+
const date = new Date(now);
|
|
49
|
+
date.setDate(date.getDate() - daysAgo);
|
|
50
|
+
const searchYear = date.getFullYear();
|
|
51
|
+
const searchMonth = String(date.getMonth() + 1).padStart(2, '0');
|
|
52
|
+
const searchDay = String(date.getDate()).padStart(2, '0');
|
|
53
|
+
const searchDir = join(sessionsDir, String(searchYear), searchMonth, searchDay);
|
|
54
|
+
const file = await findFileInDirectory(searchDir, threadId);
|
|
55
|
+
if (file)
|
|
56
|
+
return file;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
console.error('Error finding session file:', error);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Search for a file containing the thread ID in a specific directory
|
|
67
|
+
*/
|
|
68
|
+
async function findFileInDirectory(directory, threadId) {
|
|
69
|
+
try {
|
|
70
|
+
const files = await readdir(directory);
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
if (file.endsWith('.jsonl') && file.includes(threadId)) {
|
|
73
|
+
const fullPath = join(directory, file);
|
|
74
|
+
const stats = await stat(fullPath);
|
|
75
|
+
if (stats.isFile()) {
|
|
76
|
+
return fullPath;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// Directory might not exist, which is fine
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Get the most recent session file path (for debugging/info)
|
|
89
|
+
*/
|
|
90
|
+
export async function getMostRecentSessionFile() {
|
|
91
|
+
const sessionsDir = join(homedir(), '.codex', 'sessions');
|
|
92
|
+
try {
|
|
93
|
+
const now = new Date();
|
|
94
|
+
// Search last 7 days for any session file
|
|
95
|
+
for (let daysAgo = 0; daysAgo <= 7; daysAgo++) {
|
|
96
|
+
const date = new Date(now);
|
|
97
|
+
date.setDate(date.getDate() - daysAgo);
|
|
98
|
+
const year = date.getFullYear();
|
|
99
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
100
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
101
|
+
const searchDir = join(sessionsDir, String(year), month, day);
|
|
102
|
+
try {
|
|
103
|
+
const files = await readdir(searchDir);
|
|
104
|
+
const jsonlFiles = files
|
|
105
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
106
|
+
.map((f) => join(searchDir, f));
|
|
107
|
+
if (jsonlFiles.length > 0) {
|
|
108
|
+
// Return the most recent file based on modification time
|
|
109
|
+
const stats = await Promise.all(jsonlFiles.map(async (f) => ({
|
|
110
|
+
path: f,
|
|
111
|
+
mtime: (await stat(f)).mtime,
|
|
112
|
+
})));
|
|
113
|
+
stats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
114
|
+
return stats[0].path;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
console.error('Error finding most recent session file:', error);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "replicas-engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight API server for Replicas workspaces",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"replicas-engine": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "tsx watch src/index.ts",
|
|
15
|
+
"build": "tsc && node scripts/postbuild.js",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"prepublishOnly": "yarn build"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"replicas",
|
|
21
|
+
"workspace",
|
|
22
|
+
"api",
|
|
23
|
+
"hono"
|
|
24
|
+
],
|
|
25
|
+
"author": "Replicas",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@hono/node-server": "^1.19.5",
|
|
29
|
+
"@openai/codex-sdk": "^0.50.0",
|
|
30
|
+
"dotenv": "^17.2.3",
|
|
31
|
+
"hono": "^4.10.3"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^20.11.17",
|
|
35
|
+
"tsx": "^4.7.1",
|
|
36
|
+
"typescript": "^5.8.3"
|
|
37
|
+
}
|
|
38
|
+
}
|