@cephalization/math 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.
package/src/loop.ts ADDED
@@ -0,0 +1,325 @@
1
+ import { join } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import { readTasks, countTasks, updateTaskStatus, writeTasks } from "./tasks";
4
+ import { DEFAULT_MODEL } from "./constants";
5
+ import { OpenCodeAgent, MockAgent, createLogEntry } from "./agent";
6
+ import type { Agent, LogCategory } from "./agent";
7
+ import { createOutputBuffer, type OutputBuffer } from "./ui/buffer";
8
+ import { startServer, DEFAULT_PORT } from "./ui/server";
9
+
10
+ const colors = {
11
+ reset: "\x1b[0m",
12
+ bold: "\x1b[1m",
13
+ dim: "\x1b[2m",
14
+ red: "\x1b[31m",
15
+ green: "\x1b[32m",
16
+ yellow: "\x1b[33m",
17
+ blue: "\x1b[34m",
18
+ };
19
+
20
+ export interface LoopOptions {
21
+ model?: string;
22
+ maxIterations?: number;
23
+ pauseSeconds?: number;
24
+ dryRun?: boolean;
25
+ agent?: Agent;
26
+ buffer?: OutputBuffer;
27
+ /** Enable web UI server (default: true) */
28
+ ui?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Create log functions that write to both console and an optional buffer.
33
+ */
34
+ function createLoggers(buffer?: OutputBuffer) {
35
+ const log = (message: string) => {
36
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
37
+ console.log(`${colors.blue}[${timestamp}]${colors.reset} ${message}`);
38
+ buffer?.appendLog("info", message);
39
+ };
40
+
41
+ const logSuccess = (message: string) => {
42
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
43
+ console.log(`${colors.green}[${timestamp}] ✓${colors.reset} ${message}`);
44
+ buffer?.appendLog("success", message);
45
+ };
46
+
47
+ const logWarning = (message: string) => {
48
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
49
+ console.log(`${colors.yellow}[${timestamp}] ⚠${colors.reset} ${message}`);
50
+ buffer?.appendLog("warning", message);
51
+ };
52
+
53
+ const logError = (message: string) => {
54
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
55
+ console.log(`${colors.red}[${timestamp}] ✗${colors.reset} ${message}`);
56
+ buffer?.appendLog("error", message);
57
+ };
58
+
59
+ return { log, logSuccess, logWarning, logError };
60
+ }
61
+
62
+ async function checkOpenCode(): Promise<boolean> {
63
+ try {
64
+ const result = await Bun.$`which opencode`.quiet();
65
+ return result.exitCode === 0;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ async function getDefaultBranch(): Promise<string> {
72
+ // Try to detect default branch (main or master)
73
+ try {
74
+ // Check if 'main' exists
75
+ const mainResult = await Bun.$`git rev-parse --verify main`.quiet();
76
+ if (mainResult.exitCode === 0) {
77
+ return "main";
78
+ }
79
+ } catch {}
80
+
81
+ try {
82
+ // Check if 'master' exists
83
+ const masterResult = await Bun.$`git rev-parse --verify master`.quiet();
84
+ if (masterResult.exitCode === 0) {
85
+ return "master";
86
+ }
87
+ } catch {}
88
+
89
+ throw new Error("Could not find main or master branch");
90
+ }
91
+
92
+ interface Loggers {
93
+ log: (message: string) => void;
94
+ logSuccess: (message: string) => void;
95
+ logWarning: (message: string) => void;
96
+ logError: (message: string) => void;
97
+ }
98
+
99
+ async function createWorkingBranch(loggers: Loggers): Promise<string> {
100
+ const { log, logWarning } = loggers;
101
+ const defaultBranch = await getDefaultBranch();
102
+
103
+ // Generate branch name with timestamp
104
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
105
+ const branchName = `math-loop-${timestamp}`;
106
+
107
+ // Fetch latest and checkout default branch
108
+ log(`Fetching latest from origin...`);
109
+ try {
110
+ await Bun.$`git fetch origin ${defaultBranch}`.quiet();
111
+ } catch {
112
+ logWarning("Could not fetch from origin, using local branch");
113
+ }
114
+
115
+ // Checkout default branch and pull
116
+ log(`Checking out ${defaultBranch}...`);
117
+ await Bun.$`git checkout ${defaultBranch}`.quiet();
118
+
119
+ try {
120
+ await Bun.$`git pull origin ${defaultBranch}`.quiet();
121
+ } catch {
122
+ logWarning("Could not pull from origin, using local state");
123
+ }
124
+
125
+ // Create and checkout new branch
126
+ log(`Creating branch: ${branchName}`);
127
+ await Bun.$`git checkout -b ${branchName}`.quiet();
128
+
129
+ return branchName;
130
+ }
131
+
132
+ export async function runLoop(options: LoopOptions = {}): Promise<void> {
133
+ const model = options.model || DEFAULT_MODEL;
134
+ const maxIterations = options.maxIterations || 100;
135
+ const pauseSeconds = options.pauseSeconds || 3;
136
+ const dryRun = options.dryRun || false;
137
+ const uiEnabled = options.ui !== false; // default: true
138
+
139
+ // Create or use provided buffer - needed for UI server
140
+ const buffer =
141
+ options.buffer ?? (uiEnabled ? createOutputBuffer() : undefined);
142
+
143
+ // Create loggers that write to both console and buffer
144
+ const { log, logSuccess, logWarning, logError } = createLoggers(buffer);
145
+
146
+ // Start web UI server if enabled
147
+ if (uiEnabled) {
148
+ const server = startServer({ buffer: buffer!, port: DEFAULT_PORT });
149
+ log(`Web UI available at http://localhost:${DEFAULT_PORT}`);
150
+ }
151
+
152
+ const todoDir = join(process.cwd(), "todo");
153
+ const promptPath = join(todoDir, "PROMPT.md");
154
+ const tasksPath = join(todoDir, "TASKS.md");
155
+
156
+ // Check required files exist
157
+ if (!existsSync(promptPath)) {
158
+ throw new Error(
159
+ `PROMPT.md not found at ${promptPath}. Run 'math init' first.`
160
+ );
161
+ }
162
+ if (!existsSync(tasksPath)) {
163
+ throw new Error(
164
+ `TASKS.md not found at ${tasksPath}. Run 'math init' first.`
165
+ );
166
+ }
167
+
168
+ // Select agent: use provided agent, or mock for dry-run, or real agent
169
+ let agent: Agent;
170
+ if (options.agent) {
171
+ agent = options.agent;
172
+ } else if (dryRun) {
173
+ agent = new MockAgent({
174
+ logs: [
175
+ { category: "info", message: "[DRY RUN] Agent would process tasks" },
176
+ {
177
+ category: "success",
178
+ message: "[DRY RUN] Agent completed (simulated)",
179
+ },
180
+ ],
181
+ output: ["[DRY RUN] No actual LLM call made\n"],
182
+ exitCode: 0,
183
+ });
184
+ log("[DRY RUN] Using mock agent - no LLM calls will be made");
185
+ } else {
186
+ agent = new OpenCodeAgent();
187
+ // Verify opencode is available (only for real agent)
188
+ if (!(await agent.isAvailable())) {
189
+ throw new Error(
190
+ "opencode not found in PATH.\n" +
191
+ "Install: curl -fsSL https://opencode.ai/install | bash\n" +
192
+ "See: https://opencode.ai/docs/cli/"
193
+ );
194
+ }
195
+ }
196
+
197
+ // Create a new branch for this loop run (skip in dry-run mode)
198
+ // let branchName: string | undefined;
199
+ // if (!dryRun) {
200
+ // log("Setting up git branch...");
201
+ // branchName = await createWorkingBranch({ log, logSuccess, logWarning, logError });
202
+ // logSuccess(`Working on branch: ${branchName}`);
203
+ // console.log();
204
+ // } else {
205
+ // log("[DRY RUN] Skipping git branch creation");
206
+ // console.log();
207
+ // }
208
+
209
+ log("Starting math loop");
210
+ log(`Model: ${model}`);
211
+ log(`Max iterations: ${maxIterations}`);
212
+ if (dryRun) {
213
+ log("[DRY RUN] Mode enabled - no actual changes will be made");
214
+ }
215
+ console.log();
216
+
217
+ let iteration = 0;
218
+
219
+ while (true) {
220
+ iteration++;
221
+
222
+ // Safety check
223
+ if (iteration > maxIterations) {
224
+ logError(`Exceeded max iterations (${maxIterations}). Stopping.`);
225
+ throw new Error(`Exceeded max iterations (${maxIterations})`);
226
+ }
227
+
228
+ log(`=== Iteration ${iteration} ===`);
229
+
230
+ // Read and count tasks
231
+ const { tasks, content } = await readTasks(todoDir);
232
+ const counts = countTasks(tasks);
233
+
234
+ log(
235
+ `Tasks: ${counts.complete}/${counts.total} complete, ${counts.in_progress} in progress, ${counts.pending} pending`
236
+ );
237
+
238
+ // Check if all tasks are complete
239
+ if (counts.total > 0 && counts.pending === 0 && counts.in_progress === 0) {
240
+ logSuccess(`All ${counts.complete} tasks complete!`);
241
+ logSuccess(`Total iterations: ${iteration}`);
242
+ return;
243
+ }
244
+
245
+ // Sanity check
246
+ if (counts.total === 0) {
247
+ logError("No tasks found in TASKS.md - check file format");
248
+ throw new Error("No tasks found in TASKS.md - check file format");
249
+ }
250
+
251
+ // Check for stuck in_progress tasks
252
+ if (counts.in_progress > 0) {
253
+ logWarning(
254
+ `Found ${counts.in_progress} task(s) marked in_progress from previous run`
255
+ );
256
+ logWarning("Agent will handle or reset these");
257
+ }
258
+
259
+ // Invoke agent
260
+ log("Invoking agent...");
261
+
262
+ try {
263
+ const prompt =
264
+ "Read the attached PROMPT.md and TASKS.md files. Follow the instructions in PROMPT.md to complete the next pending task.";
265
+ const files = ["todo/PROMPT.md", "todo/TASKS.md"];
266
+
267
+ const result = await agent.run({
268
+ model,
269
+ prompt,
270
+ files,
271
+ events: {
272
+ onLog: (entry) => {
273
+ // Log agent events to console
274
+ switch (entry.category) {
275
+ case "info":
276
+ log(entry.message);
277
+ break;
278
+ case "success":
279
+ logSuccess(entry.message);
280
+ break;
281
+ case "warning":
282
+ logWarning(entry.message);
283
+ break;
284
+ case "error":
285
+ logError(entry.message);
286
+ break;
287
+ }
288
+ },
289
+ onOutput: (output) => {
290
+ // Print agent output to stdout and buffer
291
+ process.stdout.write(output.text);
292
+ buffer?.appendOutput(output.text);
293
+ },
294
+ },
295
+ });
296
+
297
+ if (result.exitCode === 0) {
298
+ logSuccess(`Agent completed iteration ${iteration}`);
299
+ } else {
300
+ logError(`Agent exited with code ${result.exitCode}`);
301
+
302
+ // Check if any progress was made
303
+ const { tasks: newTasks } = await readTasks(todoDir);
304
+ const newCounts = countTasks(newTasks);
305
+
306
+ if (newCounts.complete > counts.complete) {
307
+ logWarning("Progress was made despite error, continuing...");
308
+ } else {
309
+ logError("No progress made. Check logs and LEARNINGS.md");
310
+ // Continue anyway - next iteration might succeed
311
+ }
312
+ }
313
+ } catch (error) {
314
+ logError(
315
+ `Error running agent: ${error instanceof Error ? error.message : error}`
316
+ );
317
+ // Continue anyway
318
+ }
319
+
320
+ // Pause between iterations
321
+ log(`Pausing ${pauseSeconds}s before next iteration...`);
322
+ await Bun.sleep(pauseSeconds * 1000);
323
+ console.log();
324
+ }
325
+ }
package/src/plan.ts ADDED
@@ -0,0 +1,263 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { join } from "node:path";
3
+ import { DEFAULT_MODEL } from "./constants";
4
+
5
+ const colors = {
6
+ reset: "\x1b[0m",
7
+ bold: "\x1b[1m",
8
+ dim: "\x1b[2m",
9
+ green: "\x1b[32m",
10
+ yellow: "\x1b[33m",
11
+ cyan: "\x1b[36m",
12
+ magenta: "\x1b[35m",
13
+ };
14
+
15
+ const CLARIFY_PROMPT = `You are a planning assistant. Before creating a task plan, you need to gather context.
16
+
17
+ Analyze the user's goal and the project structure, then ask 3-5 clarifying questions that would help you create a better plan.
18
+
19
+ Format your response EXACTLY like this:
20
+
21
+ QUESTIONS:
22
+ 1. [Your first question]
23
+ 2. [Your second question]
24
+ 3. [Your third question]
25
+ ...
26
+
27
+ Guidelines for questions:
28
+ - Ask about ambiguous requirements
29
+ - Ask about preferences (e.g., testing framework, code style)
30
+ - Ask about scope and priorities
31
+ - Ask about constraints or existing patterns to follow
32
+ - Don't ask questions you can answer by examining the codebase
33
+
34
+ If the goal is crystal clear and you have no questions, respond with:
35
+ QUESTIONS:
36
+ NONE`;
37
+
38
+ const PLAN_PROMPT = `You are a planning assistant helping to break down a project goal into actionable tasks.
39
+
40
+ ## Step 1: Discover Project Tooling
41
+
42
+ FIRST, examine the project to understand its technology stack:
43
+ - Look for package.json, Cargo.toml, go.mod, pyproject.toml, Makefile, etc.
44
+ - Identify the test runner (e.g., bun test, npm test, cargo test, pytest, go test)
45
+ - Identify the build system (e.g., bun build, npm run build, cargo build, make)
46
+ - Identify the linter/formatter (e.g., eslint, prettier, rustfmt, black)
47
+ - Note any existing scripts or commands defined in the project
48
+
49
+ ## Step 2: Plan the Tasks
50
+
51
+ Break the user's goal into discrete, implementable tasks using this format:
52
+
53
+ ### task-id
54
+ - content: Clear description of what to implement
55
+ - status: pending
56
+ - dependencies: comma-separated task IDs or "none"
57
+
58
+ Guidelines:
59
+ - Tasks should be small enough for one focused work session
60
+ - Use kebab-case for task IDs (e.g., setup-database, add-auth)
61
+ - Order tasks logically with proper dependencies
62
+ - Group related tasks into phases with markdown headers
63
+ - Each task should have a clear, testable outcome
64
+ - Reference the PROJECT'S test/build commands, not generic ones
65
+
66
+ ## Step 3: Update PROMPT.md Quick Reference
67
+
68
+ Update the "Quick Reference" table in PROMPT.md with project-specific commands:
69
+ - Replace generic commands with the actual commands for THIS project
70
+ - Include test, build, lint, and any other relevant commands
71
+ - Keep the table format intact
72
+
73
+ Example transformations:
74
+ - "bun test" -> project's actual test command
75
+ - "npm run build" -> project's actual build command
76
+
77
+ ## Step 4: Summarize
78
+
79
+ After updating both files, briefly summarize:
80
+ - What tasks were planned
81
+ - What project tooling was discovered
82
+ - Any assumptions made`;
83
+
84
+ /**
85
+ * Parse questions from the AI's response.
86
+ * Expects format: "QUESTIONS:\n1. Question one\n2. Question two\n..."
87
+ */
88
+ function parseQuestions(output: string): string[] {
89
+ const questionsMatch = output.match(
90
+ /QUESTIONS:\s*([\s\S]*?)(?:$|(?=\n\n[A-Z]))/i
91
+ );
92
+ if (!questionsMatch?.[1]) return [];
93
+
94
+ const questionsBlock = questionsMatch[1].trim();
95
+ if (questionsBlock.toUpperCase() === "NONE") return [];
96
+
97
+ // Extract numbered questions
98
+ const questions = questionsBlock
99
+ .split(/\n/)
100
+ .map((line) => line.replace(/^\d+\.\s*/, "").trim())
101
+ .filter((line) => line.length > 0);
102
+
103
+ return questions;
104
+ }
105
+
106
+ export async function runPlanningMode({
107
+ todoDir,
108
+ options,
109
+ }: {
110
+ todoDir: string;
111
+ options: { model?: string; quick?: boolean };
112
+ }): Promise<void> {
113
+ const model = options.model || DEFAULT_MODEL;
114
+ const skipQuestions = options.quick || false;
115
+
116
+ const rl = createInterface({
117
+ input: process.stdin,
118
+ output: process.stdout,
119
+ });
120
+
121
+ console.log();
122
+ console.log(`${colors.magenta}${colors.bold}Planning Mode${colors.reset}`);
123
+ console.log(
124
+ `${colors.dim}Let's break down your goal into actionable tasks.${colors.reset}`
125
+ );
126
+ console.log();
127
+
128
+ try {
129
+ const goal = await rl.question(
130
+ `${colors.cyan}What would you like to accomplish?${colors.reset}\n> `
131
+ );
132
+
133
+ if (!goal.trim()) {
134
+ console.log(
135
+ `${colors.yellow}No goal provided. Skipping planning.${colors.reset}`
136
+ );
137
+ console.log(
138
+ `You can run planning later with: ${colors.cyan}math plan${colors.reset}`
139
+ );
140
+ rl.close();
141
+ return;
142
+ }
143
+
144
+ const tasksPath = join(todoDir, "TASKS.md");
145
+ const promptPath = join(todoDir, "PROMPT.md");
146
+ const learningsPath = join(todoDir, "LEARNINGS.md");
147
+
148
+ let clarifications = "";
149
+
150
+ // Phase 1: Gather clarifying questions (unless --quick)
151
+ if (!skipQuestions) {
152
+ console.log();
153
+ console.log(
154
+ `${colors.dim}Analyzing your goal and gathering context...${colors.reset}`
155
+ );
156
+
157
+ const clarifyPrompt = `${CLARIFY_PROMPT}
158
+
159
+ USER'S GOAL:
160
+ ${goal}`;
161
+
162
+ const clarifyResult =
163
+ await Bun.$`opencode run -m ${model} ${clarifyPrompt} -f ${tasksPath} -f ${promptPath} --title ${
164
+ "Planning: " + goal.slice(0, 40)
165
+ }`.then((result) => result.text());
166
+
167
+ const questions = parseQuestions(clarifyResult);
168
+
169
+ if (questions.length > 0) {
170
+ console.log();
171
+ console.log(
172
+ `${colors.cyan}${colors.bold}A few questions to help create a better plan:${colors.reset}`
173
+ );
174
+ console.log();
175
+
176
+ const answers: string[] = [];
177
+ for (let i = 0; i < questions.length; i++) {
178
+ const question = questions[i];
179
+ console.log(`${colors.bold}${i + 1}. ${question}${colors.reset}`);
180
+ const answer = await rl.question(`${colors.dim}> ${colors.reset}`);
181
+ answers.push(`Q: ${question}\nA: ${answer || "(skipped)"}`);
182
+ console.log();
183
+ }
184
+
185
+ clarifications = `
186
+ CLARIFICATIONS FROM USER:
187
+ ${answers.join("\n\n")}`;
188
+ } else {
189
+ console.log(
190
+ `${colors.dim}No clarifying questions needed.${colors.reset}`
191
+ );
192
+ }
193
+ }
194
+
195
+ rl.close();
196
+
197
+ // Phase 2: Generate the plan (continue session if we asked questions)
198
+ console.log();
199
+ console.log(`${colors.dim}Generating task plan...${colors.reset}`);
200
+ console.log();
201
+
202
+ const planPrompt = `${PLAN_PROMPT}
203
+
204
+ USER'S GOAL:
205
+ ${goal}
206
+ ${clarifications}
207
+
208
+ Read the attached files and update TASKS.md with a well-structured task list for this goal.`;
209
+
210
+ // If we asked questions, continue the session; otherwise start fresh
211
+ const result =
212
+ !skipQuestions && clarifications
213
+ ? await Bun.$`opencode run -c -m ${model} ${planPrompt} -f ${tasksPath} -f ${promptPath} -f ${learningsPath}`
214
+ : await Bun.$`opencode run -m ${model} ${planPrompt} -f ${tasksPath} -f ${promptPath} -f ${learningsPath}`;
215
+
216
+ if (result.exitCode === 0) {
217
+ console.log();
218
+ console.log(`${colors.green}✓${colors.reset} Planning complete!`);
219
+ console.log();
220
+ console.log(`${colors.bold}Next steps:${colors.reset}`);
221
+ console.log(
222
+ ` 1. Review ${colors.cyan}todo/TASKS.md${colors.reset} to verify the plan`
223
+ );
224
+ console.log(
225
+ ` 2. Run ${colors.cyan}math run${colors.reset} to start executing tasks`
226
+ );
227
+ } else {
228
+ console.log(
229
+ `${colors.yellow}Planning completed with warnings. Check todo/TASKS.md${colors.reset}`
230
+ );
231
+ }
232
+ } catch (error) {
233
+ rl.close();
234
+ if ((error as Error).message?.includes("opencode")) {
235
+ console.log(
236
+ `${colors.yellow}OpenCode not available. Skipping planning.${colors.reset}`
237
+ );
238
+ console.log(
239
+ `Install OpenCode: ${colors.cyan}curl -fsSL https://opencode.ai/install | bash${colors.reset}`
240
+ );
241
+ } else {
242
+ throw error;
243
+ }
244
+ }
245
+ }
246
+
247
+ export async function askToRunPlanning(): Promise<boolean> {
248
+ const rl = createInterface({
249
+ input: process.stdin,
250
+ output: process.stdout,
251
+ });
252
+
253
+ try {
254
+ const answer = await rl.question(
255
+ `\n${colors.cyan}Would you like to plan your tasks now?${colors.reset} (Y/n) `
256
+ );
257
+ rl.close();
258
+ return answer.toLowerCase() !== "n";
259
+ } catch {
260
+ rl.close();
261
+ return false;
262
+ }
263
+ }