@canivel/ralph 0.2.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.
Files changed (40) hide show
  1. package/.agents/ralph/PROMPT_build.md +126 -0
  2. package/.agents/ralph/agents.sh +15 -0
  3. package/.agents/ralph/config.sh +25 -0
  4. package/.agents/ralph/log-activity.sh +15 -0
  5. package/.agents/ralph/loop.sh +1001 -0
  6. package/.agents/ralph/references/CONTEXT_ENGINEERING.md +126 -0
  7. package/.agents/ralph/references/GUARDRAILS.md +174 -0
  8. package/AGENTS.md +20 -0
  9. package/README.md +266 -0
  10. package/bin/ralph +766 -0
  11. package/diagram.svg +55 -0
  12. package/examples/commands.md +46 -0
  13. package/package.json +39 -0
  14. package/ralph.webp +0 -0
  15. package/skills/commit/SKILL.md +219 -0
  16. package/skills/commit/references/commit_examples.md +292 -0
  17. package/skills/dev-browser/SKILL.md +211 -0
  18. package/skills/dev-browser/bun.lock +443 -0
  19. package/skills/dev-browser/package-lock.json +2988 -0
  20. package/skills/dev-browser/package.json +31 -0
  21. package/skills/dev-browser/references/scraping.md +155 -0
  22. package/skills/dev-browser/scripts/start-relay.ts +32 -0
  23. package/skills/dev-browser/scripts/start-server.ts +117 -0
  24. package/skills/dev-browser/server.sh +24 -0
  25. package/skills/dev-browser/src/client.ts +474 -0
  26. package/skills/dev-browser/src/index.ts +287 -0
  27. package/skills/dev-browser/src/relay.ts +731 -0
  28. package/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts +223 -0
  29. package/skills/dev-browser/src/snapshot/browser-script.ts +877 -0
  30. package/skills/dev-browser/src/snapshot/index.ts +14 -0
  31. package/skills/dev-browser/src/snapshot/inject.ts +13 -0
  32. package/skills/dev-browser/src/types.ts +34 -0
  33. package/skills/dev-browser/tsconfig.json +36 -0
  34. package/skills/dev-browser/vitest.config.ts +12 -0
  35. package/skills/prd/SKILL.md +235 -0
  36. package/tests/agent-loops.mjs +79 -0
  37. package/tests/agent-ping.mjs +39 -0
  38. package/tests/audit.md +56 -0
  39. package/tests/cli-smoke.mjs +47 -0
  40. package/tests/real-agents.mjs +127 -0
@@ -0,0 +1,287 @@
1
+ import express, { type Express, type Request, type Response } from "express";
2
+ import { chromium, type BrowserContext, type Page } from "playwright";
3
+ import { mkdirSync } from "fs";
4
+ import { join } from "path";
5
+ import type { Socket } from "net";
6
+ import type {
7
+ ServeOptions,
8
+ GetPageRequest,
9
+ GetPageResponse,
10
+ ListPagesResponse,
11
+ ServerInfoResponse,
12
+ } from "./types";
13
+
14
+ export type { ServeOptions, GetPageResponse, ListPagesResponse, ServerInfoResponse };
15
+
16
+ export interface DevBrowserServer {
17
+ wsEndpoint: string;
18
+ port: number;
19
+ stop: () => Promise<void>;
20
+ }
21
+
22
+ // Helper to retry fetch with exponential backoff
23
+ async function fetchWithRetry(
24
+ url: string,
25
+ maxRetries = 5,
26
+ delayMs = 500
27
+ ): Promise<globalThis.Response> {
28
+ let lastError: Error | null = null;
29
+ for (let i = 0; i < maxRetries; i++) {
30
+ try {
31
+ const res = await fetch(url);
32
+ if (res.ok) return res;
33
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
34
+ } catch (err) {
35
+ lastError = err instanceof Error ? err : new Error(String(err));
36
+ if (i < maxRetries - 1) {
37
+ await new Promise((resolve) => setTimeout(resolve, delayMs * (i + 1)));
38
+ }
39
+ }
40
+ }
41
+ throw new Error(`Failed after ${maxRetries} retries: ${lastError?.message}`);
42
+ }
43
+
44
+ // Helper to add timeout to promises
45
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
46
+ return Promise.race([
47
+ promise,
48
+ new Promise<never>((_, reject) =>
49
+ setTimeout(() => reject(new Error(`Timeout: ${message}`)), ms)
50
+ ),
51
+ ]);
52
+ }
53
+
54
+ export async function serve(options: ServeOptions = {}): Promise<DevBrowserServer> {
55
+ const port = options.port ?? 9222;
56
+ const headless = options.headless ?? false;
57
+ const cdpPort = options.cdpPort ?? 9223;
58
+ const profileDir = options.profileDir;
59
+
60
+ // Validate port numbers
61
+ if (port < 1 || port > 65535) {
62
+ throw new Error(`Invalid port: ${port}. Must be between 1 and 65535`);
63
+ }
64
+ if (cdpPort < 1 || cdpPort > 65535) {
65
+ throw new Error(`Invalid cdpPort: ${cdpPort}. Must be between 1 and 65535`);
66
+ }
67
+ if (port === cdpPort) {
68
+ throw new Error("port and cdpPort must be different");
69
+ }
70
+
71
+ // Determine user data directory for persistent context
72
+ const userDataDir = profileDir
73
+ ? join(profileDir, "browser-data")
74
+ : join(process.cwd(), ".browser-data");
75
+
76
+ // Create directory if it doesn't exist
77
+ mkdirSync(userDataDir, { recursive: true });
78
+ console.log(`Using persistent browser profile: ${userDataDir}`);
79
+
80
+ console.log("Launching browser with persistent context...");
81
+
82
+ // Launch persistent context - this persists cookies, localStorage, cache, etc.
83
+ const context: BrowserContext = await chromium.launchPersistentContext(userDataDir, {
84
+ headless,
85
+ args: [`--remote-debugging-port=${cdpPort}`],
86
+ });
87
+ console.log("Browser launched with persistent profile...");
88
+
89
+ // Get the CDP WebSocket endpoint from Chrome's JSON API (with retry for slow startup)
90
+ const cdpResponse = await fetchWithRetry(`http://127.0.0.1:${cdpPort}/json/version`);
91
+ const cdpInfo = (await cdpResponse.json()) as { webSocketDebuggerUrl: string };
92
+ const wsEndpoint = cdpInfo.webSocketDebuggerUrl;
93
+ console.log(`CDP WebSocket endpoint: ${wsEndpoint}`);
94
+
95
+ // Registry entry type for page tracking
96
+ interface PageEntry {
97
+ page: Page;
98
+ targetId: string;
99
+ }
100
+
101
+ // Registry: name -> PageEntry
102
+ const registry = new Map<string, PageEntry>();
103
+
104
+ // Helper to get CDP targetId for a page
105
+ async function getTargetId(page: Page): Promise<string> {
106
+ const cdpSession = await context.newCDPSession(page);
107
+ try {
108
+ const { targetInfo } = await cdpSession.send("Target.getTargetInfo");
109
+ return targetInfo.targetId;
110
+ } finally {
111
+ await cdpSession.detach();
112
+ }
113
+ }
114
+
115
+ // Express server for page management
116
+ const app: Express = express();
117
+ app.use(express.json());
118
+
119
+ // GET / - server info
120
+ app.get("/", (_req: Request, res: Response) => {
121
+ const response: ServerInfoResponse = { wsEndpoint };
122
+ res.json(response);
123
+ });
124
+
125
+ // GET /pages - list all pages
126
+ app.get("/pages", (_req: Request, res: Response) => {
127
+ const response: ListPagesResponse = {
128
+ pages: Array.from(registry.keys()),
129
+ };
130
+ res.json(response);
131
+ });
132
+
133
+ // POST /pages - get or create page
134
+ app.post("/pages", async (req: Request, res: Response) => {
135
+ const body = req.body as GetPageRequest;
136
+ const { name, viewport } = body;
137
+
138
+ if (!name || typeof name !== "string") {
139
+ res.status(400).json({ error: "name is required and must be a string" });
140
+ return;
141
+ }
142
+
143
+ if (name.length === 0) {
144
+ res.status(400).json({ error: "name cannot be empty" });
145
+ return;
146
+ }
147
+
148
+ if (name.length > 256) {
149
+ res.status(400).json({ error: "name must be 256 characters or less" });
150
+ return;
151
+ }
152
+
153
+ // Check if page already exists
154
+ let entry = registry.get(name);
155
+ if (!entry) {
156
+ // Create new page in the persistent context (with timeout to prevent hangs)
157
+ const page = await withTimeout(context.newPage(), 30000, "Page creation timed out after 30s");
158
+
159
+ // Apply viewport if provided
160
+ if (viewport) {
161
+ await page.setViewportSize(viewport);
162
+ }
163
+
164
+ const targetId = await getTargetId(page);
165
+ entry = { page, targetId };
166
+ registry.set(name, entry);
167
+
168
+ // Clean up registry when page is closed (e.g., user clicks X)
169
+ page.on("close", () => {
170
+ registry.delete(name);
171
+ });
172
+ }
173
+
174
+ const response: GetPageResponse = { wsEndpoint, name, targetId: entry.targetId };
175
+ res.json(response);
176
+ });
177
+
178
+ // DELETE /pages/:name - close a page
179
+ app.delete("/pages/:name", async (req: Request<{ name: string }>, res: Response) => {
180
+ const name = decodeURIComponent(req.params.name);
181
+ const entry = registry.get(name);
182
+
183
+ if (entry) {
184
+ await entry.page.close();
185
+ registry.delete(name);
186
+ res.json({ success: true });
187
+ return;
188
+ }
189
+
190
+ res.status(404).json({ error: "page not found" });
191
+ });
192
+
193
+ // Start the server
194
+ const server = app.listen(port, () => {
195
+ console.log(`HTTP API server running on port ${port}`);
196
+ });
197
+
198
+ // Track active connections for clean shutdown
199
+ const connections = new Set<Socket>();
200
+ server.on("connection", (socket: Socket) => {
201
+ connections.add(socket);
202
+ socket.on("close", () => connections.delete(socket));
203
+ });
204
+
205
+ // Track if cleanup has been called to avoid double cleanup
206
+ let cleaningUp = false;
207
+
208
+ // Cleanup function
209
+ const cleanup = async () => {
210
+ if (cleaningUp) return;
211
+ cleaningUp = true;
212
+
213
+ console.log("\nShutting down...");
214
+
215
+ // Close all active HTTP connections
216
+ for (const socket of connections) {
217
+ socket.destroy();
218
+ }
219
+ connections.clear();
220
+
221
+ // Close all pages
222
+ for (const entry of registry.values()) {
223
+ try {
224
+ await entry.page.close();
225
+ } catch {
226
+ // Page might already be closed
227
+ }
228
+ }
229
+ registry.clear();
230
+
231
+ // Close context (this also closes the browser)
232
+ try {
233
+ await context.close();
234
+ } catch {
235
+ // Context might already be closed
236
+ }
237
+
238
+ server.close();
239
+ console.log("Server stopped.");
240
+ };
241
+
242
+ // Synchronous cleanup for forced exits
243
+ const syncCleanup = () => {
244
+ try {
245
+ context.close();
246
+ } catch {
247
+ // Best effort
248
+ }
249
+ };
250
+
251
+ // Signal handlers (consolidated to reduce duplication)
252
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
253
+
254
+ const signalHandler = async () => {
255
+ await cleanup();
256
+ process.exit(0);
257
+ };
258
+
259
+ const errorHandler = async (err: unknown) => {
260
+ console.error("Unhandled error:", err);
261
+ await cleanup();
262
+ process.exit(1);
263
+ };
264
+
265
+ // Register handlers
266
+ signals.forEach((sig) => process.on(sig, signalHandler));
267
+ process.on("uncaughtException", errorHandler);
268
+ process.on("unhandledRejection", errorHandler);
269
+ process.on("exit", syncCleanup);
270
+
271
+ // Helper to remove all handlers
272
+ const removeHandlers = () => {
273
+ signals.forEach((sig) => process.off(sig, signalHandler));
274
+ process.off("uncaughtException", errorHandler);
275
+ process.off("unhandledRejection", errorHandler);
276
+ process.off("exit", syncCleanup);
277
+ };
278
+
279
+ return {
280
+ wsEndpoint,
281
+ port,
282
+ async stop() {
283
+ removeHandlers();
284
+ await cleanup();
285
+ },
286
+ };
287
+ }