@towles/tool 0.0.20 → 0.0.48
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/{LICENSE.md → LICENSE} +1 -1
- package/README.md +86 -85
- package/bin/run.ts +5 -0
- package/package.json +84 -64
- package/patches/prompts.patch +34 -0
- package/src/commands/base.ts +27 -0
- package/src/commands/config.test.ts +15 -0
- package/src/commands/config.ts +44 -0
- package/src/commands/doctor.ts +136 -0
- package/src/commands/gh/branch-clean.ts +116 -0
- package/src/commands/gh/branch.test.ts +124 -0
- package/src/commands/gh/branch.ts +135 -0
- package/src/commands/gh/pr.ts +175 -0
- package/src/commands/graph-template.html +1214 -0
- package/src/commands/graph.test.ts +176 -0
- package/src/commands/graph.ts +970 -0
- package/src/commands/install.ts +154 -0
- package/src/commands/journal/daily-notes.ts +70 -0
- package/src/commands/journal/meeting.ts +89 -0
- package/src/commands/journal/note.ts +89 -0
- package/src/commands/ralph/plan/add.ts +75 -0
- package/src/commands/ralph/plan/done.ts +82 -0
- package/src/commands/ralph/plan/list.test.ts +48 -0
- package/src/commands/ralph/plan/list.ts +99 -0
- package/src/commands/ralph/plan/remove.ts +71 -0
- package/src/commands/ralph/run.test.ts +521 -0
- package/src/commands/ralph/run.ts +345 -0
- package/src/commands/ralph/show.ts +88 -0
- package/src/config/settings.ts +136 -0
- package/src/lib/journal/utils.ts +399 -0
- package/src/lib/ralph/execution.ts +292 -0
- package/src/lib/ralph/formatter.ts +238 -0
- package/src/lib/ralph/index.ts +4 -0
- package/src/lib/ralph/state.ts +166 -0
- package/src/types/journal.ts +16 -0
- package/src/utils/date-utils.test.ts +97 -0
- package/src/utils/date-utils.ts +54 -0
- package/src/utils/git/gh-cli-wrapper.test.ts +14 -0
- package/src/utils/git/gh-cli-wrapper.ts +54 -0
- package/src/utils/git/git-wrapper.test.ts +26 -0
- package/src/utils/git/git-wrapper.ts +15 -0
- package/src/utils/render.test.ts +71 -0
- package/src/utils/render.ts +34 -0
- package/dist/index.d.mts +0 -1
- package/dist/index.mjs +0 -805
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import consola from "consola";
|
|
6
|
+
import { colors } from "consola/utils";
|
|
7
|
+
import { DateTime } from "luxon";
|
|
8
|
+
import { formatDate, getMondayOfWeek, getWeekInfo } from "../../utils/date-utils.js";
|
|
9
|
+
import type { JournalSettings } from "../../config/settings.js";
|
|
10
|
+
import { JOURNAL_TYPES } from "../../types/journal.js";
|
|
11
|
+
import type { JournalType } from "../../types/journal.js";
|
|
12
|
+
|
|
13
|
+
// Default template file names
|
|
14
|
+
const TEMPLATE_FILES = {
|
|
15
|
+
dailyNotes: "daily-notes.md",
|
|
16
|
+
meeting: "meeting.md",
|
|
17
|
+
note: "note.md",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
const execAsync = promisify(exec);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create journal directory if it doesn't exist
|
|
24
|
+
*/
|
|
25
|
+
export function ensureDirectoryExists(folderPath: string): void {
|
|
26
|
+
if (!existsSync(folderPath)) {
|
|
27
|
+
consola.info(`Creating journal directory: ${colors.cyan(folderPath)}`);
|
|
28
|
+
mkdirSync(folderPath, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Load template from external file or return null if not found
|
|
34
|
+
*/
|
|
35
|
+
export function loadTemplate(templateDir: string, templateFile: string): string | null {
|
|
36
|
+
const templatePath = path.join(templateDir, templateFile);
|
|
37
|
+
if (existsSync(templatePath)) {
|
|
38
|
+
return readFileSync(templatePath, "utf8");
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get default template content for initial setup
|
|
45
|
+
*/
|
|
46
|
+
function getDefaultDailyNotesTemplate(): string {
|
|
47
|
+
return `# Journal for Week {monday:yyyy-MM-dd}
|
|
48
|
+
|
|
49
|
+
## {monday:yyyy-MM-dd} Monday
|
|
50
|
+
|
|
51
|
+
## {tuesday:yyyy-MM-dd} Tuesday
|
|
52
|
+
|
|
53
|
+
## {wednesday:yyyy-MM-dd} Wednesday
|
|
54
|
+
|
|
55
|
+
## {thursday:yyyy-MM-dd} Thursday
|
|
56
|
+
|
|
57
|
+
## {friday:yyyy-MM-dd} Friday
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getDefaultMeetingTemplate(): string {
|
|
62
|
+
return `# Meeting: {title}
|
|
63
|
+
|
|
64
|
+
**Date:** {date}
|
|
65
|
+
**Time:** {time}
|
|
66
|
+
**Attendees:**
|
|
67
|
+
|
|
68
|
+
## Agenda
|
|
69
|
+
|
|
70
|
+
-
|
|
71
|
+
|
|
72
|
+
## Notes
|
|
73
|
+
|
|
74
|
+
## Action Items
|
|
75
|
+
|
|
76
|
+
- [ ]
|
|
77
|
+
|
|
78
|
+
## Follow-up
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getDefaultNoteTemplate(): string {
|
|
83
|
+
return `# {title}
|
|
84
|
+
|
|
85
|
+
**Created:** {date} {time}
|
|
86
|
+
|
|
87
|
+
## Summary
|
|
88
|
+
|
|
89
|
+
## Details
|
|
90
|
+
|
|
91
|
+
## References
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Initialize template directory with default templates (first run)
|
|
97
|
+
*/
|
|
98
|
+
export function ensureTemplatesExist(templateDir: string): void {
|
|
99
|
+
ensureDirectoryExists(templateDir);
|
|
100
|
+
|
|
101
|
+
const templates = [
|
|
102
|
+
{ file: TEMPLATE_FILES.dailyNotes, content: getDefaultDailyNotesTemplate() },
|
|
103
|
+
{ file: TEMPLATE_FILES.meeting, content: getDefaultMeetingTemplate() },
|
|
104
|
+
{ file: TEMPLATE_FILES.note, content: getDefaultNoteTemplate() },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
for (const { file, content } of templates) {
|
|
108
|
+
const templatePath = path.join(templateDir, file);
|
|
109
|
+
if (!existsSync(templatePath)) {
|
|
110
|
+
writeFileSync(templatePath, content, "utf8");
|
|
111
|
+
consola.info(`Created default template: ${colors.cyan(templatePath)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Render template with variables
|
|
118
|
+
*/
|
|
119
|
+
function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
120
|
+
return template.replace(/\{([^}]+)\}/g, (match, key) => {
|
|
121
|
+
return vars[key] ?? match;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create initial journal content with date header
|
|
127
|
+
*/
|
|
128
|
+
export function createJournalContent({
|
|
129
|
+
mondayDate,
|
|
130
|
+
templateDir,
|
|
131
|
+
}: {
|
|
132
|
+
mondayDate: Date;
|
|
133
|
+
templateDir?: string;
|
|
134
|
+
}): string {
|
|
135
|
+
const weekInfo = getWeekInfo(mondayDate);
|
|
136
|
+
|
|
137
|
+
// Try external template first
|
|
138
|
+
if (templateDir) {
|
|
139
|
+
const externalTemplate = loadTemplate(templateDir, TEMPLATE_FILES.dailyNotes);
|
|
140
|
+
if (externalTemplate) {
|
|
141
|
+
return renderTemplate(externalTemplate, {
|
|
142
|
+
"monday:yyyy-MM-dd": formatDate(weekInfo.mondayDate),
|
|
143
|
+
"tuesday:yyyy-MM-dd": formatDate(weekInfo.tuesdayDate),
|
|
144
|
+
"wednesday:yyyy-MM-dd": formatDate(weekInfo.wednesdayDate),
|
|
145
|
+
"thursday:yyyy-MM-dd": formatDate(weekInfo.thursdayDate),
|
|
146
|
+
"friday:yyyy-MM-dd": formatDate(weekInfo.fridayDate),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Fallback to hardcoded template
|
|
152
|
+
const content = [`# Journal for Week ${formatDate(mondayDate)}`];
|
|
153
|
+
content.push(``);
|
|
154
|
+
content.push(`## ${formatDate(weekInfo.mondayDate)} Monday`);
|
|
155
|
+
content.push(``);
|
|
156
|
+
content.push(`## ${formatDate(weekInfo.tuesdayDate)} Tuesday`);
|
|
157
|
+
content.push(``);
|
|
158
|
+
content.push(`## ${formatDate(weekInfo.wednesdayDate)} Wednesday`);
|
|
159
|
+
content.push(``);
|
|
160
|
+
content.push(`## ${formatDate(weekInfo.thursdayDate)} Thursday`);
|
|
161
|
+
content.push(``);
|
|
162
|
+
content.push(`## ${formatDate(weekInfo.fridayDate)} Friday`);
|
|
163
|
+
content.push(``);
|
|
164
|
+
|
|
165
|
+
return content.join("\n");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Create meeting template content
|
|
170
|
+
*/
|
|
171
|
+
export function createMeetingContent({
|
|
172
|
+
title,
|
|
173
|
+
date,
|
|
174
|
+
templateDir,
|
|
175
|
+
}: {
|
|
176
|
+
title?: string;
|
|
177
|
+
date: Date;
|
|
178
|
+
templateDir?: string;
|
|
179
|
+
}): string {
|
|
180
|
+
const dateStr = formatDate(date);
|
|
181
|
+
const timeStr = date.toLocaleTimeString("en-US", {
|
|
182
|
+
hour12: false,
|
|
183
|
+
hour: "2-digit",
|
|
184
|
+
minute: "2-digit",
|
|
185
|
+
});
|
|
186
|
+
const meetingTitle = title || "Meeting";
|
|
187
|
+
|
|
188
|
+
// Try external template first
|
|
189
|
+
if (templateDir) {
|
|
190
|
+
const externalTemplate = loadTemplate(templateDir, TEMPLATE_FILES.meeting);
|
|
191
|
+
if (externalTemplate) {
|
|
192
|
+
return renderTemplate(externalTemplate, {
|
|
193
|
+
title: meetingTitle,
|
|
194
|
+
date: dateStr,
|
|
195
|
+
time: timeStr,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Fallback to hardcoded template
|
|
201
|
+
const content = [`# Meeting: ${meetingTitle}`];
|
|
202
|
+
content.push(``);
|
|
203
|
+
content.push(`**Date:** ${dateStr}`);
|
|
204
|
+
content.push(`**Time:** ${timeStr}`);
|
|
205
|
+
content.push(`**Attendees:** `);
|
|
206
|
+
content.push(``);
|
|
207
|
+
content.push(`## Agenda`);
|
|
208
|
+
content.push(``);
|
|
209
|
+
content.push(`- `);
|
|
210
|
+
content.push(``);
|
|
211
|
+
content.push(`## Notes`);
|
|
212
|
+
content.push(``);
|
|
213
|
+
content.push(`## Action Items`);
|
|
214
|
+
content.push(``);
|
|
215
|
+
content.push(`- [ ] `);
|
|
216
|
+
content.push(``);
|
|
217
|
+
content.push(`## Follow-up`);
|
|
218
|
+
content.push(``);
|
|
219
|
+
|
|
220
|
+
return content.join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Create note template content
|
|
225
|
+
*/
|
|
226
|
+
export function createNoteContent({
|
|
227
|
+
title,
|
|
228
|
+
date,
|
|
229
|
+
templateDir,
|
|
230
|
+
}: {
|
|
231
|
+
title?: string;
|
|
232
|
+
date: Date;
|
|
233
|
+
templateDir?: string;
|
|
234
|
+
}): string {
|
|
235
|
+
const dateStr = formatDate(date);
|
|
236
|
+
const timeStr = date.toLocaleTimeString("en-US", {
|
|
237
|
+
hour12: false,
|
|
238
|
+
hour: "2-digit",
|
|
239
|
+
minute: "2-digit",
|
|
240
|
+
});
|
|
241
|
+
const noteTitle = title || "Note";
|
|
242
|
+
|
|
243
|
+
// Try external template first
|
|
244
|
+
if (templateDir) {
|
|
245
|
+
const externalTemplate = loadTemplate(templateDir, TEMPLATE_FILES.note);
|
|
246
|
+
if (externalTemplate) {
|
|
247
|
+
return renderTemplate(externalTemplate, {
|
|
248
|
+
title: noteTitle,
|
|
249
|
+
date: dateStr,
|
|
250
|
+
time: timeStr,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Fallback to hardcoded template
|
|
256
|
+
const content = [`# ${noteTitle}`];
|
|
257
|
+
content.push(``);
|
|
258
|
+
content.push(`**Created:** ${dateStr} ${timeStr}`);
|
|
259
|
+
content.push(``);
|
|
260
|
+
content.push(`## Summary`);
|
|
261
|
+
content.push(``);
|
|
262
|
+
content.push(`## Details`);
|
|
263
|
+
content.push(``);
|
|
264
|
+
content.push(`## References`);
|
|
265
|
+
content.push(``);
|
|
266
|
+
|
|
267
|
+
return content.join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Open file in default editor with folder context
|
|
272
|
+
*/
|
|
273
|
+
export async function openInEditor({
|
|
274
|
+
editor,
|
|
275
|
+
filePath,
|
|
276
|
+
folderPath,
|
|
277
|
+
}: {
|
|
278
|
+
editor: string;
|
|
279
|
+
filePath: string;
|
|
280
|
+
folderPath?: string;
|
|
281
|
+
}): Promise<void> {
|
|
282
|
+
try {
|
|
283
|
+
if (folderPath) {
|
|
284
|
+
// Open both folder and file - this works with VS Code and similar editors
|
|
285
|
+
// the purpose is to open the folder context for better navigation
|
|
286
|
+
await execAsync(`"${editor}" "${folderPath}" "${filePath}"`);
|
|
287
|
+
} else {
|
|
288
|
+
await execAsync(`"${editor}" "${filePath}"`);
|
|
289
|
+
}
|
|
290
|
+
} catch (ex) {
|
|
291
|
+
consola.warn(
|
|
292
|
+
`Could not open in editor : '${editor}'. Modify your editor in the config: examples include 'code', 'code-insiders', etc...`,
|
|
293
|
+
ex,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function resolvePathTemplate(
|
|
299
|
+
template: string,
|
|
300
|
+
title: string,
|
|
301
|
+
date: Date,
|
|
302
|
+
mondayDate: Date,
|
|
303
|
+
): string {
|
|
304
|
+
const dateTime = DateTime.fromJSDate(date, { zone: "utc" });
|
|
305
|
+
|
|
306
|
+
// Replace Luxon format tokens wrapped in curly braces
|
|
307
|
+
return template.replace(/\{([^}]+)\}/g, (match, token) => {
|
|
308
|
+
try {
|
|
309
|
+
if (token === "title") {
|
|
310
|
+
return title.toLowerCase().replace(/\s+/g, "-");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (token.startsWith("monday:")) {
|
|
314
|
+
const mondayToken = token.substring(7); // Remove 'monday:' prefix
|
|
315
|
+
const mondayDateTime = DateTime.fromJSDate(mondayDate, { zone: "utc" });
|
|
316
|
+
return mondayDateTime.toFormat(mondayToken);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const result = dateTime.toFormat(token);
|
|
320
|
+
// Check if the result contains suspicious patterns that indicate invalid tokens
|
|
321
|
+
// This is a heuristic to detect when Luxon produces garbage output for invalid tokens
|
|
322
|
+
const isLikelyInvalid =
|
|
323
|
+
token.includes("invalid") ||
|
|
324
|
+
result.length > 20 || // Very long results are likely garbage
|
|
325
|
+
(result.length > token.length * 2 && /\d{10,}/.test(result)) || // Contains very long numbers
|
|
326
|
+
result.includes("UTC");
|
|
327
|
+
|
|
328
|
+
if (isLikelyInvalid) {
|
|
329
|
+
consola.warn(`Invalid date format token: ${token}`);
|
|
330
|
+
return match;
|
|
331
|
+
}
|
|
332
|
+
return result;
|
|
333
|
+
} catch (error) {
|
|
334
|
+
consola.warn(`Invalid date format token: ${token}`);
|
|
335
|
+
return match; // Return original token if format is invalid
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
interface GenerateJournalFileResult {
|
|
341
|
+
fullPath: string;
|
|
342
|
+
mondayDate: Date;
|
|
343
|
+
currentDate: Date;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface GenerateJournalFileParams {
|
|
347
|
+
date: Date;
|
|
348
|
+
type: JournalType;
|
|
349
|
+
title: string;
|
|
350
|
+
journalSettings: JournalSettings;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Generate journal file info for different types using individual path templates
|
|
355
|
+
*/
|
|
356
|
+
export function generateJournalFileInfoByType({
|
|
357
|
+
journalSettings,
|
|
358
|
+
date = new Date(),
|
|
359
|
+
type,
|
|
360
|
+
title,
|
|
361
|
+
}: GenerateJournalFileParams): GenerateJournalFileResult {
|
|
362
|
+
const currentDate = new Date(date);
|
|
363
|
+
|
|
364
|
+
let templatePath: string = "";
|
|
365
|
+
let mondayDate: Date = getMondayOfWeek(currentDate);
|
|
366
|
+
|
|
367
|
+
switch (type) {
|
|
368
|
+
case JOURNAL_TYPES.DAILY_NOTES: {
|
|
369
|
+
const monday = getMondayOfWeek(currentDate);
|
|
370
|
+
templatePath = journalSettings.dailyPathTemplate;
|
|
371
|
+
mondayDate = monday;
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
case JOURNAL_TYPES.MEETING: {
|
|
375
|
+
templatePath = journalSettings.meetingPathTemplate;
|
|
376
|
+
mondayDate = currentDate;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
case JOURNAL_TYPES.NOTE: {
|
|
380
|
+
templatePath = journalSettings.notePathTemplate;
|
|
381
|
+
mondayDate = currentDate;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
default:
|
|
385
|
+
throw new Error(`Unknown JournalType: ${type}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Resolve the path template and extract directory structure
|
|
389
|
+
const resolvedPath = resolvePathTemplate(templatePath, title, currentDate, mondayDate);
|
|
390
|
+
|
|
391
|
+
// Join baseFolder with the resolved path
|
|
392
|
+
const fullPath = path.join(journalSettings.baseFolder, resolvedPath);
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
currentDate: currentDate,
|
|
396
|
+
fullPath: fullPath,
|
|
397
|
+
mondayDate,
|
|
398
|
+
} satisfies GenerateJournalFileResult;
|
|
399
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { WriteStream } from "node:fs";
|
|
2
|
+
// NOTE: We use spawn instead of tinyexec for runIteration because we need
|
|
3
|
+
// real-time streaming of stdout/stderr. tinyexec waits for command completion
|
|
4
|
+
// before returning output, which doesn't work for long-running claude sessions.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
import { x } from "tinyexec";
|
|
8
|
+
import { CLAUDE_DEFAULT_ARGS } from "./state.js";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
interface StreamEvent {
|
|
15
|
+
type: string;
|
|
16
|
+
message?: {
|
|
17
|
+
content?: Array<{ type: string; text?: string }>;
|
|
18
|
+
usage?: {
|
|
19
|
+
input_tokens?: number;
|
|
20
|
+
output_tokens?: number;
|
|
21
|
+
cache_read_input_tokens?: number;
|
|
22
|
+
cache_creation_input_tokens?: number;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
result?: string;
|
|
26
|
+
total_cost_usd?: number;
|
|
27
|
+
num_turns?: number;
|
|
28
|
+
session_id?: string;
|
|
29
|
+
usage?: {
|
|
30
|
+
input_tokens?: number;
|
|
31
|
+
output_tokens?: number;
|
|
32
|
+
cache_read_input_tokens?: number;
|
|
33
|
+
cache_creation_input_tokens?: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Claude model context windows (tokens)
|
|
38
|
+
const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|
39
|
+
"claude-sonnet-4-20250514": 200000,
|
|
40
|
+
"claude-opus-4-20250514": 200000,
|
|
41
|
+
"claude-3-5-sonnet-20241022": 200000,
|
|
42
|
+
"claude-3-opus-20240229": 200000,
|
|
43
|
+
default: 200000,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export interface IterationResult {
|
|
47
|
+
output: string;
|
|
48
|
+
exitCode: number;
|
|
49
|
+
contextUsedPercent?: number;
|
|
50
|
+
sessionId?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ParsedLine {
|
|
54
|
+
text: string | null;
|
|
55
|
+
tool?: { name: string; summary: string };
|
|
56
|
+
usage?: StreamEvent["usage"];
|
|
57
|
+
sessionId?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// Claude CLI Check
|
|
62
|
+
// ============================================================================
|
|
63
|
+
|
|
64
|
+
export async function checkClaudeCli(): Promise<boolean> {
|
|
65
|
+
try {
|
|
66
|
+
const result = await x("which", ["claude"]);
|
|
67
|
+
return result.exitCode === 0;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Stream Parsing
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
// Track accumulated text from assistant messages to compute deltas
|
|
78
|
+
let lastAssistantText = "";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Reset stream parsing state between iterations.
|
|
82
|
+
*/
|
|
83
|
+
export function resetStreamState(): void {
|
|
84
|
+
lastAssistantText = "";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function summarizeTool(name: string, input: Record<string, unknown>): string {
|
|
88
|
+
switch (name) {
|
|
89
|
+
case "Read":
|
|
90
|
+
return (
|
|
91
|
+
String(input.file_path || input.path || "")
|
|
92
|
+
.split("/")
|
|
93
|
+
.pop() || "file"
|
|
94
|
+
);
|
|
95
|
+
case "Write":
|
|
96
|
+
case "Edit":
|
|
97
|
+
return (
|
|
98
|
+
String(input.file_path || input.path || "")
|
|
99
|
+
.split("/")
|
|
100
|
+
.pop() || "file"
|
|
101
|
+
);
|
|
102
|
+
case "Glob":
|
|
103
|
+
return String(input.pattern || "");
|
|
104
|
+
case "Grep":
|
|
105
|
+
return String(input.pattern || "");
|
|
106
|
+
case "Bash":
|
|
107
|
+
return String(input.command || "").substring(0, 40);
|
|
108
|
+
case "TodoWrite":
|
|
109
|
+
return "updating todos";
|
|
110
|
+
default:
|
|
111
|
+
return Object.values(input)[0]?.toString().substring(0, 30) || "";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseStreamLine(line: string): ParsedLine {
|
|
116
|
+
if (!line.trim()) return { text: null };
|
|
117
|
+
try {
|
|
118
|
+
const data = JSON.parse(line) as StreamEvent & {
|
|
119
|
+
tool_use?: { name: string; input: Record<string, unknown> };
|
|
120
|
+
content_block?: { type: string; name?: string; input?: Record<string, unknown> };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Handle tool_use events
|
|
124
|
+
if (data.type === "tool_use" && data.tool_use) {
|
|
125
|
+
const name = data.tool_use.name;
|
|
126
|
+
const summary = summarizeTool(name, data.tool_use.input || {});
|
|
127
|
+
return { text: null, tool: { name, summary } };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Handle content_block with tool_use (streaming format)
|
|
131
|
+
if (data.type === "content_block" && data.content_block?.type === "tool_use") {
|
|
132
|
+
const name = data.content_block.name || "Tool";
|
|
133
|
+
const summary = summarizeTool(name, data.content_block.input || {});
|
|
134
|
+
return { text: null, tool: { name, summary } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Handle assistant messages with content array
|
|
138
|
+
if (data.type === "assistant" && data.message) {
|
|
139
|
+
// Check for tool_use in content blocks
|
|
140
|
+
const toolBlocks = data.message.content?.filter((c) => c.type === "tool_use") || [];
|
|
141
|
+
if (toolBlocks.length > 0) {
|
|
142
|
+
const tb = toolBlocks[toolBlocks.length - 1] as {
|
|
143
|
+
name?: string;
|
|
144
|
+
input?: Record<string, unknown>;
|
|
145
|
+
};
|
|
146
|
+
const name = tb.name || "Tool";
|
|
147
|
+
const summary = summarizeTool(name, tb.input || {});
|
|
148
|
+
return {
|
|
149
|
+
text: null,
|
|
150
|
+
tool: { name, summary },
|
|
151
|
+
usage: data.message.usage || data.usage,
|
|
152
|
+
sessionId: data.session_id,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Extract full text from content blocks
|
|
157
|
+
const fullText =
|
|
158
|
+
data.message.content
|
|
159
|
+
?.filter((c) => c.type === "text" && c.text)
|
|
160
|
+
.map((c) => c.text)
|
|
161
|
+
.join("") || "";
|
|
162
|
+
|
|
163
|
+
// Compute delta (only new portion) to avoid duplicate output
|
|
164
|
+
let delta: string | null = null;
|
|
165
|
+
if (fullText.startsWith(lastAssistantText)) {
|
|
166
|
+
delta = fullText.slice(lastAssistantText.length) || null;
|
|
167
|
+
} else {
|
|
168
|
+
// Text doesn't match prefix - new context
|
|
169
|
+
delta = fullText || null;
|
|
170
|
+
}
|
|
171
|
+
lastAssistantText = fullText;
|
|
172
|
+
|
|
173
|
+
return { text: delta, usage: data.message.usage || data.usage, sessionId: data.session_id };
|
|
174
|
+
}
|
|
175
|
+
// Capture final result with usage and session_id
|
|
176
|
+
if (data.type === "result") {
|
|
177
|
+
const resultText = data.result
|
|
178
|
+
? `\n[Result: ${data.result.substring(0, 100)}${data.result.length > 100 ? "..." : ""}]\n`
|
|
179
|
+
: null;
|
|
180
|
+
return { text: resultText, usage: data.usage, sessionId: data.session_id };
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// Not JSON, return raw
|
|
184
|
+
return { text: line };
|
|
185
|
+
}
|
|
186
|
+
return { text: null };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ============================================================================
|
|
190
|
+
// Run Iteration
|
|
191
|
+
// ============================================================================
|
|
192
|
+
|
|
193
|
+
export async function runIteration(
|
|
194
|
+
prompt: string,
|
|
195
|
+
claudeArgs: string[],
|
|
196
|
+
logStream?: WriteStream,
|
|
197
|
+
): Promise<IterationResult> {
|
|
198
|
+
// Reset accumulated text state from previous iteration
|
|
199
|
+
resetStreamState();
|
|
200
|
+
|
|
201
|
+
// Pass task context as system prompt via --append-system-prompt
|
|
202
|
+
// 'continue' is the user prompt - required by claude CLI when using --print
|
|
203
|
+
const allArgs = [
|
|
204
|
+
...CLAUDE_DEFAULT_ARGS,
|
|
205
|
+
...claudeArgs,
|
|
206
|
+
"--append-system-prompt",
|
|
207
|
+
prompt,
|
|
208
|
+
"continue",
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
let output = "";
|
|
212
|
+
let lineBuffer = "";
|
|
213
|
+
let finalUsage: StreamEvent["usage"] | undefined;
|
|
214
|
+
let sessionId: string | undefined;
|
|
215
|
+
let lastCharWasNewline = true;
|
|
216
|
+
|
|
217
|
+
const processLine = (line: string) => {
|
|
218
|
+
const { text: parsed, tool, usage, sessionId: sid } = parseStreamLine(line);
|
|
219
|
+
if (usage) finalUsage = usage;
|
|
220
|
+
if (sid) sessionId = sid;
|
|
221
|
+
if (tool) {
|
|
222
|
+
const prefix = lastCharWasNewline ? "" : "\n";
|
|
223
|
+
const toolLine = `${prefix}${pc.yellow("⚡")} ${pc.cyan(tool.name)}: ${tool.summary}\n`;
|
|
224
|
+
process.stdout.write(toolLine);
|
|
225
|
+
logStream?.write(`${prefix}⚡ ${tool.name}: ${tool.summary}\n`);
|
|
226
|
+
lastCharWasNewline = true;
|
|
227
|
+
}
|
|
228
|
+
if (parsed) {
|
|
229
|
+
process.stdout.write(parsed);
|
|
230
|
+
logStream?.write(parsed);
|
|
231
|
+
output += parsed;
|
|
232
|
+
lastCharWasNewline = parsed.endsWith("\n");
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return new Promise((resolve) => {
|
|
237
|
+
const proc = spawn("claude", allArgs, {
|
|
238
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
proc.stdout.on("data", (chunk: Buffer) => {
|
|
242
|
+
const text = chunk.toString();
|
|
243
|
+
lineBuffer += text;
|
|
244
|
+
|
|
245
|
+
const lines = lineBuffer.split("\n");
|
|
246
|
+
lineBuffer = lines.pop() || "";
|
|
247
|
+
|
|
248
|
+
for (const line of lines) {
|
|
249
|
+
processLine(line);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
proc.stderr.on("data", (chunk: Buffer) => {
|
|
254
|
+
const text = chunk.toString();
|
|
255
|
+
process.stderr.write(text);
|
|
256
|
+
logStream?.write(text);
|
|
257
|
+
output += text;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
proc.on("close", (code: number | null) => {
|
|
261
|
+
if (lineBuffer) {
|
|
262
|
+
processLine(lineBuffer);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (output && !output.endsWith("\n")) {
|
|
266
|
+
process.stdout.write("\n");
|
|
267
|
+
logStream?.write("\n");
|
|
268
|
+
output += "\n";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Calculate context usage percent
|
|
272
|
+
let contextUsedPercent: number | undefined;
|
|
273
|
+
if (finalUsage) {
|
|
274
|
+
const totalTokens =
|
|
275
|
+
(finalUsage.input_tokens || 0) +
|
|
276
|
+
(finalUsage.output_tokens || 0) +
|
|
277
|
+
(finalUsage.cache_read_input_tokens || 0) +
|
|
278
|
+
(finalUsage.cache_creation_input_tokens || 0);
|
|
279
|
+
const maxContext = MODEL_CONTEXT_WINDOWS.default;
|
|
280
|
+
contextUsedPercent = Math.round((totalTokens / maxContext) * 100);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
resolve({ output, exitCode: code ?? 0, contextUsedPercent, sessionId });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
proc.on("error", (err: Error) => {
|
|
287
|
+
console.error(pc.red(`Error running claude: ${err}`));
|
|
288
|
+
logStream?.write(`Error running claude: ${err}\n`);
|
|
289
|
+
resolve({ output, exitCode: 1 });
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
}
|