@xqli02/mneme 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.
@@ -0,0 +1,654 @@
1
+ /**
2
+ * mneme auto — Autonomous agent supervisor loop.
3
+ *
4
+ * Starts an opencode server (or attaches to existing), then continuously:
5
+ * 1. Picks the highest-priority unblocked bead from `mneme ready`
6
+ * 2. Composes a prompt with bead context + OpenClaw facts
7
+ * 3. Sends it to opencode via HTTP API
8
+ * 4. Streams progress to terminal
9
+ * 5. Accepts user input at any time (queued, injected between turns)
10
+ * 6. Updates bead status based on results
11
+ * 7. Picks next bead and repeats
12
+ *
13
+ * Usage:
14
+ * mneme auto # Auto-pick from ready beads
15
+ * mneme auto "Build auth module" # Start with a specific goal
16
+ * mneme auto --attach http://localhost:4096 # Attach to existing server
17
+ * mneme auto --port 4096 # Use specific port for server
18
+ */
19
+
20
+ import { spawn } from "node:child_process";
21
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { createInterface } from "node:readline";
24
+ import { createClient } from "../opencode-client.mjs";
25
+ import { color, log, run, has } from "../utils.mjs";
26
+
27
+ // ── Argument parsing ────────────────────────────────────────────────────────
28
+
29
+ function parseArgs(argv) {
30
+ const opts = {
31
+ goal: null,
32
+ attach: null,
33
+ port: 4097, // default port for auto mode server
34
+ maxTurns: 100, // safety limit
35
+ };
36
+ const positional = [];
37
+
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const arg = argv[i];
40
+ if (arg === "--attach" && argv[i + 1]) {
41
+ opts.attach = argv[++i];
42
+ } else if (arg.startsWith("--attach=")) {
43
+ opts.attach = arg.split("=")[1];
44
+ } else if (arg === "--port" && argv[i + 1]) {
45
+ opts.port = parseInt(argv[++i], 10);
46
+ } else if (arg.startsWith("--port=")) {
47
+ opts.port = parseInt(arg.split("=")[1], 10);
48
+ } else if (arg === "--max-turns" && argv[i + 1]) {
49
+ opts.maxTurns = parseInt(argv[++i], 10);
50
+ } else if (!arg.startsWith("-")) {
51
+ positional.push(arg);
52
+ }
53
+ }
54
+
55
+ if (positional.length > 0) {
56
+ opts.goal = positional.join(" ");
57
+ }
58
+
59
+ return opts;
60
+ }
61
+
62
+ // ── Server lifecycle ────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Start opencode serve as a child process.
66
+ * Returns { client, serverProcess, url }.
67
+ */
68
+ async function startServer(port) {
69
+ log.info(`Starting opencode server on port ${port}...`);
70
+
71
+ const serverProcess = spawn("opencode", ["serve", "--port", String(port)], {
72
+ stdio: ["ignore", "pipe", "pipe"],
73
+ detached: false,
74
+ });
75
+
76
+ // Capture server output for debugging
77
+ let serverOutput = "";
78
+ serverProcess.stdout.on("data", (d) => {
79
+ serverOutput += d.toString();
80
+ });
81
+ serverProcess.stderr.on("data", (d) => {
82
+ serverOutput += d.toString();
83
+ });
84
+
85
+ const url = `http://127.0.0.1:${port}`;
86
+ const client = createClient(url);
87
+
88
+ // Wait for server to be ready (poll health endpoint)
89
+ const maxWait = 30_000;
90
+ const start = Date.now();
91
+ while (Date.now() - start < maxWait) {
92
+ try {
93
+ const health = await client.health();
94
+ if (health && health.healthy) {
95
+ log.ok(`Server ready at ${url} (opencode ${health.version})`);
96
+ return { client, serverProcess, url };
97
+ }
98
+ } catch {
99
+ // Not ready yet
100
+ }
101
+ await sleep(500);
102
+ }
103
+
104
+ serverProcess.kill();
105
+ throw new Error(
106
+ `Server failed to start within ${maxWait / 1000}s.\n${serverOutput}`,
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Attach to an existing opencode server.
112
+ */
113
+ async function attachServer(baseUrl) {
114
+ log.info(`Attaching to server at ${baseUrl}...`);
115
+ const client = createClient(baseUrl);
116
+
117
+ try {
118
+ const health = await client.health();
119
+ if (!health || !health.healthy) {
120
+ throw new Error("Server unhealthy");
121
+ }
122
+ log.ok(`Attached to ${baseUrl} (opencode ${health.version})`);
123
+ return { client, serverProcess: null, url: baseUrl };
124
+ } catch (err) {
125
+ throw new Error(`Cannot connect to ${baseUrl}: ${err.message}`);
126
+ }
127
+ }
128
+
129
+ // ── Bead management ─────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Get ready beads (unblocked, open). Returns parsed list or [].
133
+ */
134
+ function getReadyBeads() {
135
+ const output = run("bd ready --json");
136
+ if (!output) return [];
137
+ try {
138
+ return JSON.parse(output);
139
+ } catch {
140
+ // Fallback: parse text output
141
+ return parseBeadText(run("bd ready") || "");
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Get all open beads.
147
+ */
148
+ function getOpenBeads() {
149
+ const output = run("bd list --status=open --json");
150
+ if (!output) return [];
151
+ try {
152
+ return JSON.parse(output);
153
+ } catch {
154
+ return [];
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Get in-progress beads.
160
+ */
161
+ function getInProgressBeads() {
162
+ const output = run("bd list --status=in_progress --json");
163
+ if (!output) return [];
164
+ try {
165
+ return JSON.parse(output);
166
+ } catch {
167
+ return [];
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Get bead details.
173
+ */
174
+ function getBeadDetails(id) {
175
+ return run(`bd show ${id}`) || "";
176
+ }
177
+
178
+ /**
179
+ * Parse text output from bd (fallback when --json fails).
180
+ */
181
+ function parseBeadText(text) {
182
+ if (!text || text.includes("No ready work")) return [];
183
+ return text
184
+ .split("\n")
185
+ .filter((l) => l.trim())
186
+ .map((line) => {
187
+ const idMatch = line.match(/([\w-]+)\s/);
188
+ return idMatch ? { id: idMatch[1], raw: line } : null;
189
+ })
190
+ .filter(Boolean);
191
+ }
192
+
193
+ // ── Prompt composition ──────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Read OpenClaw facts as context string.
197
+ */
198
+ function readFacts() {
199
+ const factsDir = ".openclaw/facts";
200
+ if (!existsSync(factsDir)) return "";
201
+
202
+ const files = readdirSync(factsDir).filter((f) => f.endsWith(".md"));
203
+ const parts = [];
204
+ for (const file of files) {
205
+ const content = readFileSync(join(factsDir, file), "utf-8");
206
+ parts.push(`## ${file}\n\n${content}`);
207
+ }
208
+ return parts.join("\n\n---\n\n");
209
+ }
210
+
211
+ /**
212
+ * Read AGENTS.md
213
+ */
214
+ function readAgentsRules() {
215
+ if (existsSync("AGENTS.md")) {
216
+ return readFileSync("AGENTS.md", "utf-8");
217
+ }
218
+ return "";
219
+ }
220
+
221
+ /**
222
+ * Build the system context for the session.
223
+ * This is sent as the first message to establish context.
224
+ */
225
+ function buildSystemContext() {
226
+ const facts = readFacts();
227
+ const agents = readAgentsRules();
228
+
229
+ let context = "# Session Context (injected by mneme auto)\n\n";
230
+ context +=
231
+ "You are running in autonomous mode under mneme supervision.\n";
232
+ context +=
233
+ "mneme will feed you tasks from the beads system. Complete each task, then report what you did.\n";
234
+ context +=
235
+ "Use `mneme update <id> --notes=\"...\"` to record progress.\n";
236
+ context +=
237
+ "Use `mneme close <id> --reason=\"...\"` when a task is done.\n";
238
+ context += "Use `mneme create --title=\"...\" ...` for newly discovered subtasks.\n\n";
239
+
240
+ if (agents) {
241
+ context += "## Agent Rules (AGENTS.md)\n\n";
242
+ context += agents + "\n\n";
243
+ }
244
+
245
+ if (facts) {
246
+ context += "## Long-term Facts (OpenClaw)\n\n";
247
+ context += facts + "\n\n";
248
+ }
249
+
250
+ return context;
251
+ }
252
+
253
+ /**
254
+ * Build prompt for working on a specific bead.
255
+ */
256
+ function buildBeadPrompt(beadId) {
257
+ const details = getBeadDetails(beadId);
258
+ let prompt = `## Current Task\n\n`;
259
+ prompt += `Work on the following bead. Here are its details:\n\n`;
260
+ prompt += "```\n" + details + "\n```\n\n";
261
+ prompt += `Instructions:\n`;
262
+ prompt += `1. Understand the task from the description and notes above\n`;
263
+ prompt += `2. Implement the required changes\n`;
264
+ prompt += `3. Update progress with: mneme update ${beadId} --notes="your progress"\n`;
265
+ prompt += `4. If the task is complete, close it: mneme close ${beadId} --reason="completion summary"\n`;
266
+ prompt += `5. If you discover sub-tasks, create them: mneme create --title="..." --description="..." --type=task -p 2\n`;
267
+ prompt += `6. Commit your changes with a clear commit message\n`;
268
+ return prompt;
269
+ }
270
+
271
+ /**
272
+ * Build prompt for a user-specified goal (no specific bead).
273
+ */
274
+ function buildGoalPrompt(goal) {
275
+ let prompt = `## Goal\n\n`;
276
+ prompt += `The user wants you to accomplish the following:\n\n`;
277
+ prompt += `> ${goal}\n\n`;
278
+ prompt += `Instructions:\n`;
279
+ prompt += `1. Check existing beads with \`mneme ready\` and \`mneme list --status=open\`\n`;
280
+ prompt += `2. If this goal maps to an existing bead, claim it: mneme update <id> --status=in_progress\n`;
281
+ prompt += `3. If not, create a new bead: mneme create --title="..." --description="..." --type=task -p 2\n`;
282
+ prompt += `4. Work on the goal step by step\n`;
283
+ prompt += `5. Update progress and close beads as you go\n`;
284
+ prompt += `6. Commit your changes\n`;
285
+ return prompt;
286
+ }
287
+
288
+ // ── User input handling ─────────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Non-blocking stdin reader with message queue.
292
+ * User can type while the agent is working; messages are queued.
293
+ */
294
+ function createInputQueue() {
295
+ const queue = [];
296
+ let rl = null;
297
+ let closed = false;
298
+
299
+ function start() {
300
+ if (!process.stdin.isTTY) return; // non-interactive, skip
301
+
302
+ rl = createInterface({
303
+ input: process.stdin,
304
+ output: process.stdout,
305
+ prompt: "",
306
+ });
307
+
308
+ rl.on("line", (line) => {
309
+ const trimmed = line.trim();
310
+ if (!trimmed) return;
311
+
312
+ if (trimmed === "/quit" || trimmed === "/exit" || trimmed === "/stop") {
313
+ queue.push({ type: "quit" });
314
+ return;
315
+ }
316
+
317
+ if (trimmed === "/status") {
318
+ queue.push({ type: "status" });
319
+ return;
320
+ }
321
+
322
+ if (trimmed === "/skip") {
323
+ queue.push({ type: "skip" });
324
+ return;
325
+ }
326
+
327
+ // Regular message → inject into session
328
+ queue.push({ type: "message", text: trimmed });
329
+ console.log(
330
+ color.dim(` [queued] Will inject after current turn: "${trimmed}"`),
331
+ );
332
+ });
333
+
334
+ rl.on("close", () => {
335
+ closed = true;
336
+ });
337
+ }
338
+
339
+ function drain() {
340
+ const items = queue.splice(0);
341
+ return items;
342
+ }
343
+
344
+ function hasMessages() {
345
+ return queue.length > 0;
346
+ }
347
+
348
+ function stop() {
349
+ if (rl) {
350
+ rl.close();
351
+ rl = null;
352
+ }
353
+ closed = true;
354
+ }
355
+
356
+ return { start, drain, hasMessages, stop, get closed() { return closed; } };
357
+ }
358
+
359
+ // ── Event display ───────────────────────────────────────────────────────────
360
+
361
+ /**
362
+ * Subscribe to SSE events and display agent progress.
363
+ * Runs in background, returns a controller to stop.
364
+ */
365
+ function createEventDisplay(client) {
366
+ let running = false;
367
+ let iterator = null;
368
+
369
+ async function start() {
370
+ running = true;
371
+ try {
372
+ iterator = await client.events.subscribe();
373
+ for await (const event of iterator) {
374
+ if (!running) break;
375
+ displayEvent(event);
376
+ }
377
+ } catch (err) {
378
+ if (running) {
379
+ // Connection lost, not intentional stop
380
+ console.error(color.dim(` [events] Stream ended: ${err.message}`));
381
+ }
382
+ }
383
+ }
384
+
385
+ function displayEvent(event) {
386
+ const type = event.type || "";
387
+ const props = event.properties || {};
388
+
389
+ // Only show interesting events, skip noise
390
+ if (type === "message.part.updated" && props.part) {
391
+ const part = props.part;
392
+ if (part.type === "text" && part.text) {
393
+ // Show last line of text as progress indicator
394
+ const lines = part.text.split("\n").filter((l) => l.trim());
395
+ if (lines.length > 0) {
396
+ const last = lines[lines.length - 1];
397
+ const truncated =
398
+ last.length > 100 ? last.slice(0, 100) + "..." : last;
399
+ process.stdout.write(`\r${color.dim(" > " + truncated)} `);
400
+ }
401
+ } else if (part.type === "tool-invocation") {
402
+ const toolName = part.toolInvocation?.toolName || part.tool || "tool";
403
+ process.stdout.write(
404
+ `\r${color.dim(` [${toolName}] working...`)} `,
405
+ );
406
+ }
407
+ } else if (type === "session.updated" && props.status === "completed") {
408
+ process.stdout.write("\n");
409
+ }
410
+ }
411
+
412
+ function stop() {
413
+ running = false;
414
+ }
415
+
416
+ return { start, stop };
417
+ }
418
+
419
+ // ── Supervisor loop ─────────────────────────────────────────────────────────
420
+
421
+ /**
422
+ * Main supervisor loop.
423
+ */
424
+ async function supervisorLoop(client, opts, inputQueue) {
425
+ // Create a session
426
+ log.info("Creating session...");
427
+ const session = await client.session.create({ title: "mneme auto" });
428
+ const sessionId = session.id;
429
+ log.ok(`Session created: ${sessionId}`);
430
+
431
+ // Send system context as first message (noReply — just inject context)
432
+ const systemContext = buildSystemContext();
433
+ await client.session.prompt(sessionId, {
434
+ noReply: true,
435
+ parts: [{ type: "text", text: systemContext }],
436
+ });
437
+ log.ok("System context injected");
438
+
439
+ // Start event display
440
+ const eventDisplay = createEventDisplay(client);
441
+ // Run in background (don't await)
442
+ eventDisplay.start().catch(() => {});
443
+
444
+ let turnCount = 0;
445
+
446
+ // Determine first prompt
447
+ let nextPrompt = null;
448
+ if (opts.goal) {
449
+ nextPrompt = buildGoalPrompt(opts.goal);
450
+ }
451
+
452
+ try {
453
+ while (turnCount < opts.maxTurns) {
454
+ // If no prompt queued, pick a bead
455
+ if (!nextPrompt) {
456
+ // Check in-progress beads first (resume)
457
+ const inProgress = getInProgressBeads();
458
+ if (inProgress.length > 0) {
459
+ const bead = inProgress[0];
460
+ const beadId = bead.id || bead.raw?.match(/([\w-]+)/)?.[1];
461
+ if (beadId) {
462
+ log.info(`Resuming in-progress bead: ${beadId}`);
463
+ nextPrompt = buildBeadPrompt(beadId);
464
+ }
465
+ }
466
+ }
467
+
468
+ if (!nextPrompt) {
469
+ // Check ready beads
470
+ const ready = getReadyBeads();
471
+ if (ready.length === 0) {
472
+ log.info("No ready beads. Checking if there's open work...");
473
+ const open = getOpenBeads();
474
+ if (open.length === 0) {
475
+ log.ok("All beads completed! Nothing left to do.");
476
+ break;
477
+ } else {
478
+ log.warn(
479
+ `${open.length} open bead(s) but all blocked. Waiting for user input...`,
480
+ );
481
+ // Wait for user to provide direction
482
+ await waitForInput(inputQueue);
483
+ const items = inputQueue.drain();
484
+ const msg = items.find((i) => i.type === "message");
485
+ if (msg) {
486
+ nextPrompt = msg.text;
487
+ }
488
+ const quit = items.find((i) => i.type === "quit");
489
+ if (quit) break;
490
+ if (!nextPrompt) continue;
491
+ }
492
+ } else {
493
+ const bead = ready[0];
494
+ const beadId = bead.id || bead.raw?.match(/([\w-]+)/)?.[1];
495
+ if (beadId) {
496
+ // Claim it
497
+ run(`bd update ${beadId} --status=in_progress`);
498
+ log.info(`Picked bead: ${beadId}`);
499
+ nextPrompt = buildBeadPrompt(beadId);
500
+ }
501
+ }
502
+ }
503
+
504
+ if (!nextPrompt) {
505
+ log.warn("No task available. Waiting...");
506
+ await sleep(5000);
507
+ continue;
508
+ }
509
+
510
+ // Send prompt
511
+ turnCount++;
512
+ console.log(
513
+ `\n${color.bold(`── Turn ${turnCount} ──────────────────────────────`)}`,
514
+ );
515
+
516
+ try {
517
+ const result = await client.session.prompt(sessionId, {
518
+ parts: [{ type: "text", text: nextPrompt }],
519
+ });
520
+
521
+ // Show response summary
522
+ if (result && result.parts) {
523
+ const textParts = result.parts.filter((p) => p.type === "text");
524
+ if (textParts.length > 0) {
525
+ const fullText = textParts.map((p) => p.text).join("\n");
526
+ // Show last ~500 chars as summary
527
+ const summary =
528
+ fullText.length > 500
529
+ ? "..." + fullText.slice(-500)
530
+ : fullText;
531
+ console.log(color.dim("\n Response summary:"));
532
+ console.log(
533
+ summary
534
+ .split("\n")
535
+ .map((l) => " " + l)
536
+ .join("\n"),
537
+ );
538
+ }
539
+ }
540
+ } catch (err) {
541
+ log.fail(`Turn failed: ${err.message}`);
542
+ // Don't abort the loop — maybe next turn works
543
+ }
544
+
545
+ nextPrompt = null;
546
+
547
+ // Process queued user input
548
+ if (inputQueue.hasMessages()) {
549
+ const items = inputQueue.drain();
550
+ for (const item of items) {
551
+ if (item.type === "quit") {
552
+ log.info("User requested quit.");
553
+ return;
554
+ }
555
+ if (item.type === "skip") {
556
+ log.info("Skipping current bead...");
557
+ nextPrompt = null; // will pick next bead
558
+ }
559
+ if (item.type === "status") {
560
+ showStatus();
561
+ }
562
+ if (item.type === "message") {
563
+ log.info(`Injecting user message: "${item.text}"`);
564
+ nextPrompt = `## User Feedback\n\nThe user has provided the following input. Prioritize this over your current task plan:\n\n> ${item.text}\n\nAdjust your approach accordingly and continue working.`;
565
+ }
566
+ }
567
+ }
568
+
569
+ // Small pause between turns to avoid hammering
570
+ await sleep(1000);
571
+ }
572
+
573
+ if (turnCount >= opts.maxTurns) {
574
+ log.warn(`Reached max turns (${opts.maxTurns}). Stopping.`);
575
+ }
576
+ } finally {
577
+ eventDisplay.stop();
578
+ }
579
+ }
580
+
581
+ function showStatus() {
582
+ console.log(`\n${color.bold("── Status ──")}`);
583
+ const ready = run("bd ready") || " (none)";
584
+ const inProgress = run("bd list --status=in_progress") || " (none)";
585
+ console.log(` ${color.bold("Ready:")} ${ready}`);
586
+ console.log(` ${color.bold("In Progress:")} ${inProgress}`);
587
+ }
588
+
589
+ async function waitForInput(inputQueue) {
590
+ while (!inputQueue.hasMessages() && !inputQueue.closed) {
591
+ await sleep(500);
592
+ }
593
+ }
594
+
595
+ // ── Main entry point ────────────────────────────────────────────────────────
596
+
597
+ export async function auto(argv) {
598
+ const opts = parseArgs(argv);
599
+
600
+ // Preflight checks
601
+ if (!has("opencode")) {
602
+ log.fail("opencode is not installed. Run: curl -fsSL https://opencode.ai/install | bash");
603
+ process.exit(1);
604
+ }
605
+ if (!has("bd")) {
606
+ log.fail("bd (beads) is not installed. Run: mneme init");
607
+ process.exit(1);
608
+ }
609
+
610
+ console.log(`\n${color.bold("mneme auto")} — autonomous agent supervisor\n`);
611
+ console.log(color.dim("Commands while running:"));
612
+ console.log(color.dim(" Type any message → inject feedback into agent"));
613
+ console.log(color.dim(" /status → show bead status"));
614
+ console.log(color.dim(" /skip → skip current bead"));
615
+ console.log(color.dim(" /quit → stop and exit\n"));
616
+
617
+ // Start or attach to server
618
+ let serverCtx;
619
+ try {
620
+ if (opts.attach) {
621
+ serverCtx = await attachServer(opts.attach);
622
+ } else {
623
+ serverCtx = await startServer(opts.port);
624
+ }
625
+ } catch (err) {
626
+ log.fail(err.message);
627
+ process.exit(1);
628
+ }
629
+
630
+ // Start input queue
631
+ const inputQueue = createInputQueue();
632
+ inputQueue.start();
633
+
634
+ // Run supervisor
635
+ try {
636
+ await supervisorLoop(serverCtx.client, opts, inputQueue);
637
+ } catch (err) {
638
+ log.fail(`Supervisor error: ${err.message}`);
639
+ } finally {
640
+ inputQueue.stop();
641
+ // Kill server if we started it
642
+ if (serverCtx.serverProcess) {
643
+ log.info("Shutting down server...");
644
+ serverCtx.serverProcess.kill("SIGTERM");
645
+ }
646
+ log.ok("mneme auto finished.");
647
+ }
648
+ }
649
+
650
+ // ── Helpers ─────────────────────────────────────────────────────────────────
651
+
652
+ function sleep(ms) {
653
+ return new Promise((resolve) => setTimeout(resolve, ms));
654
+ }