clearclaw 0.2.0 → 0.4.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/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/channel/slack.d.ts +29 -0
- package/dist/channel/slack.js +402 -0
- package/dist/channel/telegram.d.ts +15 -2
- package/dist/channel/telegram.js +223 -33
- package/dist/config.d.ts +13 -3
- package/dist/config.js +18 -4
- package/dist/engine/claude-code.d.ts +2 -1
- package/dist/engine/claude-code.js +111 -12
- package/dist/files.d.ts +3 -0
- package/dist/files.js +33 -0
- package/dist/format.d.ts +18 -9
- package/dist/format.js +86 -49
- package/dist/index.js +10 -2
- package/dist/orchestrator.d.ts +3 -0
- package/dist/orchestrator.js +205 -47
- package/dist/types.d.ts +33 -2
- package/package.json +22 -1
package/dist/channel/telegram.js
CHANGED
|
@@ -4,37 +4,61 @@ import log from "../logger.js";
|
|
|
4
4
|
export class TelegramChannel extends EventEmitter {
|
|
5
5
|
name = "telegram";
|
|
6
6
|
bot;
|
|
7
|
+
botToken;
|
|
7
8
|
allowedUserIds;
|
|
8
9
|
typingIntervals = new Map();
|
|
10
|
+
statusHandles = new Map(); // chatId → message handle for pinned status
|
|
9
11
|
// Pending interactive responses: callbackId → resolve function
|
|
10
12
|
pendingCallbacks = new Map();
|
|
11
13
|
// Pending text input: chatId → resolve function (for requestText follow-ups)
|
|
12
14
|
pendingTextResolvers = new Map();
|
|
13
15
|
constructor(botToken, allowedUserIds) {
|
|
14
16
|
super();
|
|
17
|
+
this.botToken = botToken;
|
|
15
18
|
this.allowedUserIds = allowedUserIds;
|
|
16
19
|
this.bot = new Bot(botToken);
|
|
17
20
|
}
|
|
18
21
|
async connect() {
|
|
19
|
-
// Auth check: only respond to allowed user
|
|
20
22
|
this.bot.on("message:text", (ctx) => {
|
|
21
|
-
|
|
23
|
+
const sender = this.extractSender(ctx);
|
|
24
|
+
if (!sender)
|
|
22
25
|
return;
|
|
23
|
-
const chatId = `tg:${ctx.chat.id}`;
|
|
24
26
|
// Intercept for requestText follow-ups before emitting as a new message
|
|
25
|
-
const textResolver = this.pendingTextResolvers.get(chatId);
|
|
27
|
+
const textResolver = this.pendingTextResolvers.get(sender.chatId);
|
|
26
28
|
if (textResolver) {
|
|
27
|
-
this.pendingTextResolvers.delete(chatId);
|
|
29
|
+
this.pendingTextResolvers.delete(sender.chatId);
|
|
28
30
|
textResolver(ctx.message.text);
|
|
29
31
|
return;
|
|
30
32
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
this.emit("message", { ...sender, text: ctx.message.text });
|
|
34
|
+
});
|
|
35
|
+
// Photos: Telegram sends each photo as an array of PhotoSize objects
|
|
36
|
+
// (same image at different resolutions, sorted smallest → largest).
|
|
37
|
+
// We grab the last element for the highest resolution version.
|
|
38
|
+
this.bot.on("message:photo", async (ctx) => {
|
|
39
|
+
const photo = ctx.message.photo;
|
|
40
|
+
const largest = photo[photo.length - 1];
|
|
41
|
+
await this.handleMediaMessage(ctx, largest.file_id, "image/jpeg", undefined, ctx.message.caption);
|
|
37
42
|
});
|
|
43
|
+
// Documents: download with original MIME + filename
|
|
44
|
+
this.bot.on("message:document", async (ctx) => {
|
|
45
|
+
const doc = ctx.message.document;
|
|
46
|
+
await this.handleMediaMessage(ctx, doc.file_id, doc.mime_type ?? "application/octet-stream", doc.file_name, ctx.message.caption);
|
|
47
|
+
});
|
|
48
|
+
// Placeholder handlers for unsupported media types
|
|
49
|
+
for (const [filter, label] of [
|
|
50
|
+
["message:voice", "Voice message"],
|
|
51
|
+
["message:audio", "Audio message"],
|
|
52
|
+
["message:video", "Video"],
|
|
53
|
+
["message:sticker", "Sticker"],
|
|
54
|
+
]) {
|
|
55
|
+
this.bot.on(filter, (ctx) => {
|
|
56
|
+
const sender = this.extractSender(ctx);
|
|
57
|
+
if (!sender)
|
|
58
|
+
return;
|
|
59
|
+
this.emit("message", { ...sender, text: `[${label} — not supported]` });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
38
62
|
// Handle inline keyboard button presses
|
|
39
63
|
this.bot.on("callback_query:data", async (ctx) => {
|
|
40
64
|
const data = ctx.callbackQuery.data;
|
|
@@ -62,6 +86,7 @@ export class TelegramChannel extends EventEmitter {
|
|
|
62
86
|
clearInterval(interval);
|
|
63
87
|
}
|
|
64
88
|
this.typingIntervals.clear();
|
|
89
|
+
this.statusHandles.clear();
|
|
65
90
|
await this.bot.stop();
|
|
66
91
|
}
|
|
67
92
|
ownsId(chatId) {
|
|
@@ -70,64 +95,99 @@ export class TelegramChannel extends EventEmitter {
|
|
|
70
95
|
async sendMessage(chatId, text, opts) {
|
|
71
96
|
const numId = this.numericId(chatId);
|
|
72
97
|
const chunks = splitMessage(text);
|
|
98
|
+
const handles = [];
|
|
73
99
|
for (const chunk of chunks) {
|
|
74
100
|
try {
|
|
75
|
-
await this.bot.api.sendMessage(numId, chunk, {
|
|
101
|
+
const sent = await this.bot.api.sendMessage(numId, chunk, {
|
|
76
102
|
parse_mode: opts?.parseMode,
|
|
77
103
|
});
|
|
104
|
+
handles.push(String(sent.message_id));
|
|
78
105
|
}
|
|
79
106
|
catch (err) {
|
|
80
107
|
if (opts?.parseMode) {
|
|
81
108
|
log.warn("[channel] sendMessage failed with %s, retrying as plain text", opts.parseMode);
|
|
82
|
-
await this.bot.api.sendMessage(numId, chunk);
|
|
109
|
+
const sent = await this.bot.api.sendMessage(numId, chunk);
|
|
110
|
+
handles.push(String(sent.message_id));
|
|
83
111
|
}
|
|
84
112
|
else {
|
|
85
113
|
throw err;
|
|
86
114
|
}
|
|
87
115
|
}
|
|
88
116
|
}
|
|
117
|
+
return handles;
|
|
89
118
|
}
|
|
90
119
|
async sendInteractive(chatId, text, buttons) {
|
|
91
120
|
const numId = this.numericId(chatId);
|
|
92
121
|
const callbackId = crypto.randomUUID().slice(0, 8);
|
|
93
|
-
// Pre-compute callback data keys (use index to avoid collisions when buttons share values)
|
|
94
|
-
const cbEntries =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
122
|
+
// Pre-compute callback data keys (use flat index to avoid collisions when buttons share values)
|
|
123
|
+
const cbEntries = [];
|
|
124
|
+
let idx = 0;
|
|
125
|
+
for (const row of buttons) {
|
|
126
|
+
for (const btn of row) {
|
|
127
|
+
cbEntries.push({ btn, cbData: `${callbackId}:${idx++}` });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Build inline keyboard — each inner array is a row
|
|
99
131
|
const keyboard = new InlineKeyboard();
|
|
100
|
-
|
|
101
|
-
|
|
132
|
+
let flatIdx = 0;
|
|
133
|
+
for (const row of buttons) {
|
|
134
|
+
for (const _btn of row) {
|
|
135
|
+
const { cbData } = cbEntries[flatIdx++];
|
|
136
|
+
keyboard.text(_btn.label, cbData);
|
|
137
|
+
}
|
|
138
|
+
keyboard.row();
|
|
139
|
+
}
|
|
140
|
+
// Convert universal markdown to MarkdownV2, fall back to plain text on failure
|
|
141
|
+
const mdv2Text = convertToMarkdownV2(text);
|
|
142
|
+
let sent;
|
|
143
|
+
try {
|
|
144
|
+
sent = await this.bot.api.sendMessage(numId, mdv2Text, {
|
|
145
|
+
reply_markup: keyboard,
|
|
146
|
+
parse_mode: "MarkdownV2",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
log.warn("[channel] sendInteractive failed with MarkdownV2, retrying as plain text");
|
|
151
|
+
sent = await this.bot.api.sendMessage(numId, text, {
|
|
152
|
+
reply_markup: keyboard,
|
|
153
|
+
});
|
|
102
154
|
}
|
|
103
|
-
await this.bot.api.sendMessage(numId, text, {
|
|
104
|
-
reply_markup: keyboard,
|
|
105
|
-
});
|
|
106
155
|
const cleanupAll = () => {
|
|
107
156
|
for (const { cbData } of cbEntries) {
|
|
108
157
|
this.pendingCallbacks.delete(cbData);
|
|
109
158
|
}
|
|
110
159
|
};
|
|
111
|
-
// Wait for button press
|
|
160
|
+
// Wait for button press (no timeout — matches CLI behavior)
|
|
112
161
|
const pressed = await new Promise((resolve) => {
|
|
113
|
-
const timeout = setTimeout(() => {
|
|
114
|
-
cleanupAll();
|
|
115
|
-
resolve(null);
|
|
116
|
-
}, 30 * 60 * 1000);
|
|
117
162
|
for (const { btn, cbData } of cbEntries) {
|
|
118
163
|
this.pendingCallbacks.set(cbData, () => {
|
|
119
|
-
clearTimeout(timeout);
|
|
120
164
|
cleanupAll();
|
|
121
165
|
resolve(btn);
|
|
122
166
|
});
|
|
123
167
|
}
|
|
124
168
|
});
|
|
125
|
-
|
|
126
|
-
|
|
169
|
+
// Update keyboard to highlight the selected button (others become inert — no callbacks)
|
|
170
|
+
const feedbackKeyboard = new InlineKeyboard();
|
|
171
|
+
let entryIndex = 0;
|
|
172
|
+
for (const row of buttons) {
|
|
173
|
+
for (const button of row) {
|
|
174
|
+
const entry = cbEntries[entryIndex++];
|
|
175
|
+
const label = button === pressed ? `✅ ${button.label}` : button.label;
|
|
176
|
+
feedbackKeyboard.text(label, entry.cbData);
|
|
177
|
+
}
|
|
178
|
+
feedbackKeyboard.row();
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
await this.bot.api.editMessageReplyMarkup(numId, sent.message_id, {
|
|
182
|
+
reply_markup: feedbackKeyboard,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch { /* best-effort */ }
|
|
127
186
|
// If the button requests text, prompt for follow-up input
|
|
128
187
|
if (pressed.requestText) {
|
|
129
|
-
await this.bot.api.sendMessage(numId, "
|
|
188
|
+
await this.bot.api.sendMessage(numId, "Add your feedback:", {
|
|
130
189
|
reply_markup: { force_reply: true },
|
|
190
|
+
reply_parameters: { message_id: sent.message_id },
|
|
131
191
|
});
|
|
132
192
|
const followUpText = await new Promise((resolve) => {
|
|
133
193
|
this.pendingTextResolvers.set(chatId, resolve);
|
|
@@ -136,6 +196,57 @@ export class TelegramChannel extends EventEmitter {
|
|
|
136
196
|
}
|
|
137
197
|
return { value: pressed.value };
|
|
138
198
|
}
|
|
199
|
+
async editMessage(chatId, handle, text) {
|
|
200
|
+
const numId = this.numericId(chatId);
|
|
201
|
+
await this.bot.api.editMessageText(numId, Number(handle), text);
|
|
202
|
+
}
|
|
203
|
+
async deleteMessage(chatId, handle) {
|
|
204
|
+
const numId = this.numericId(chatId);
|
|
205
|
+
await this.bot.api.deleteMessage(numId, Number(handle));
|
|
206
|
+
}
|
|
207
|
+
async pinMessage(chatId, handle) {
|
|
208
|
+
const numId = this.numericId(chatId);
|
|
209
|
+
await this.bot.api.pinChatMessage(numId, Number(handle), {
|
|
210
|
+
disable_notification: true,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async unpinAllMessages(chatId) {
|
|
214
|
+
const numId = this.numericId(chatId);
|
|
215
|
+
await this.bot.api.unpinAllChatMessages(numId);
|
|
216
|
+
}
|
|
217
|
+
async updateStatus(chatId, text) {
|
|
218
|
+
const existing = this.statusHandles.get(chatId);
|
|
219
|
+
if (existing) {
|
|
220
|
+
try {
|
|
221
|
+
await this.editMessage(chatId, existing, text);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
if (!(err instanceof Error && err.message.includes("message is not modified"))) {
|
|
226
|
+
// Edit failed for a real reason — fall through to create new
|
|
227
|
+
log.warn("[channel] failed to edit status message, will recreate: %s", err instanceof Error ? err.message : String(err));
|
|
228
|
+
this.statusHandles.delete(chatId);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
return; // text unchanged, nothing to do
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// No existing handle, or edit failed — create new pinned message
|
|
236
|
+
try {
|
|
237
|
+
await this.unpinAllMessages(chatId);
|
|
238
|
+
}
|
|
239
|
+
catch { /* not admin */ }
|
|
240
|
+
const handles = await this.sendMessage(chatId, text);
|
|
241
|
+
const handle = handles[0];
|
|
242
|
+
this.statusHandles.set(chatId, handle);
|
|
243
|
+
try {
|
|
244
|
+
await this.pinMessage(chatId, handle);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
log.warn("[channel] failed to pin status message (bot may not be admin): %s", err instanceof Error ? err.message : String(err));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
139
250
|
async setTyping(chatId, isTyping) {
|
|
140
251
|
if (isTyping) {
|
|
141
252
|
if (this.typingIntervals.has(chatId))
|
|
@@ -158,10 +269,89 @@ export class TelegramChannel extends EventEmitter {
|
|
|
158
269
|
}
|
|
159
270
|
}
|
|
160
271
|
}
|
|
272
|
+
/** Extract auth-checked sender info from a grammY context. Returns null if unauthorized. */
|
|
273
|
+
extractSender(ctx) {
|
|
274
|
+
if (!ctx.from?.id || !ctx.chat?.id || !this.allowedUserIds.has(`tg:${ctx.from.id}`))
|
|
275
|
+
return null;
|
|
276
|
+
return {
|
|
277
|
+
chatId: `tg:${ctx.chat.id}`,
|
|
278
|
+
user: {
|
|
279
|
+
id: `tg:${ctx.from.id}`,
|
|
280
|
+
name: [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(" "),
|
|
281
|
+
...(ctx.from.username && { handle: ctx.from.username }),
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/** Shared handler for photo/document messages: download, emit attachment or fallback. */
|
|
286
|
+
async handleMediaMessage(ctx, fileId, mimeType, filename, caption) {
|
|
287
|
+
const sender = this.extractSender(ctx);
|
|
288
|
+
if (!sender)
|
|
289
|
+
return;
|
|
290
|
+
try {
|
|
291
|
+
const buffer = await this.downloadFile(fileId);
|
|
292
|
+
this.emit("message", {
|
|
293
|
+
...sender,
|
|
294
|
+
text: caption ?? "",
|
|
295
|
+
attachments: [{ buffer, mimeType, filename }],
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
log.warn({ err }, "[channel] failed to download file %s", filename ?? fileId);
|
|
300
|
+
this.emit("message", {
|
|
301
|
+
...sender,
|
|
302
|
+
text: caption
|
|
303
|
+
? `${caption}\n[File download failed: ${filename ?? mimeType}]`
|
|
304
|
+
: `[File download failed: ${filename ?? mimeType}]`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** Download a Telegram file by file_id, returning the raw buffer. */
|
|
309
|
+
async downloadFile(fileId) {
|
|
310
|
+
const file = await this.bot.api.getFile(fileId);
|
|
311
|
+
if (!file.file_path)
|
|
312
|
+
throw new Error("Telegram returned no file_path");
|
|
313
|
+
const url = `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`;
|
|
314
|
+
const resp = await fetch(url);
|
|
315
|
+
if (!resp.ok)
|
|
316
|
+
throw new Error(`File download failed: ${resp.status}`);
|
|
317
|
+
return Buffer.from(await resp.arrayBuffer());
|
|
318
|
+
}
|
|
161
319
|
numericId(chatId) {
|
|
162
320
|
return Number(chatId.replace(/^tg:/, ""));
|
|
163
321
|
}
|
|
164
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Escape for Telegram MarkdownV2.
|
|
325
|
+
* Outside code blocks: all special chars. Inside: only backtick and backslash.
|
|
326
|
+
*/
|
|
327
|
+
function escapeMarkdownV2(text, codeBlock = false) {
|
|
328
|
+
const pattern = codeBlock ? /[`\\]/g : /[_*\[\]()~`>#+\-=|{}.!\\]/g;
|
|
329
|
+
return text.replace(pattern, "\\$&");
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Convert universal markdown (with code fences) to Telegram MarkdownV2.
|
|
333
|
+
* Handles code blocks specially: fence markers stay literal, inner content
|
|
334
|
+
* only escapes ` and \, outer text gets full MarkdownV2 escaping.
|
|
335
|
+
*/
|
|
336
|
+
function convertToMarkdownV2(markdown) {
|
|
337
|
+
const result = [];
|
|
338
|
+
let pos = 0;
|
|
339
|
+
const fenceRegex = /```(\w*)\n([\s\S]*?)```/g;
|
|
340
|
+
let match;
|
|
341
|
+
while ((match = fenceRegex.exec(markdown)) !== null) {
|
|
342
|
+
if (match.index > pos) {
|
|
343
|
+
result.push(escapeMarkdownV2(markdown.slice(pos, match.index)));
|
|
344
|
+
}
|
|
345
|
+
result.push("```" + match[1] + "\n");
|
|
346
|
+
result.push(escapeMarkdownV2(match[2], true));
|
|
347
|
+
result.push("```");
|
|
348
|
+
pos = match.index + match[0].length;
|
|
349
|
+
}
|
|
350
|
+
if (pos < markdown.length) {
|
|
351
|
+
result.push(escapeMarkdownV2(markdown.slice(pos)));
|
|
352
|
+
}
|
|
353
|
+
return result.join("");
|
|
354
|
+
}
|
|
165
355
|
const MAX_MSG_LEN = 4096;
|
|
166
356
|
/** Split text into ≤4096-char chunks, preferring newline boundaries. */
|
|
167
357
|
function splitMessage(text) {
|
package/dist/config.d.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import type { PermissionMode } from "./types.js";
|
|
2
|
-
export interface
|
|
2
|
+
export interface TelegramConfig {
|
|
3
|
+
type: "telegram";
|
|
4
|
+
botToken: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SlackConfig {
|
|
7
|
+
type: "slack";
|
|
3
8
|
botToken: string;
|
|
9
|
+
appToken: string;
|
|
10
|
+
}
|
|
11
|
+
export type ChannelConfig = TelegramConfig | SlackConfig;
|
|
12
|
+
export interface Config {
|
|
13
|
+
channel: ChannelConfig;
|
|
4
14
|
allowedUserIds: Set<string>;
|
|
5
15
|
permissionMode: PermissionMode;
|
|
6
|
-
dataDir: string;
|
|
7
16
|
defaultPromptPath: string;
|
|
8
|
-
|
|
17
|
+
workspaceStorePath: string;
|
|
18
|
+
filesPath: string;
|
|
9
19
|
logPath: string;
|
|
10
20
|
}
|
|
11
21
|
export declare function loadConfig(): Config;
|
package/dist/config.js
CHANGED
|
@@ -4,7 +4,21 @@ import path from "node:path";
|
|
|
4
4
|
export function loadConfig() {
|
|
5
5
|
const dataDir = process.env.CLEARCLAW_HOME ?? path.join(os.homedir(), ".clearclaw");
|
|
6
6
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
7
|
-
|
|
7
|
+
fs.mkdirSync(path.join(dataDir, "files"), { recursive: true });
|
|
8
|
+
// Channel config: Slack takes priority if both SLACK_BOT_TOKEN and SLACK_APP_TOKEN are set
|
|
9
|
+
const slackBotToken = process.env.SLACK_BOT_TOKEN;
|
|
10
|
+
const slackAppToken = process.env.SLACK_APP_TOKEN;
|
|
11
|
+
const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
12
|
+
let channel;
|
|
13
|
+
if (slackBotToken && slackAppToken) {
|
|
14
|
+
channel = { type: "slack", botToken: slackBotToken, appToken: slackAppToken };
|
|
15
|
+
}
|
|
16
|
+
else if (telegramBotToken) {
|
|
17
|
+
channel = { type: "telegram", botToken: telegramBotToken };
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
throw new Error("Missing channel config: set TELEGRAM_BOT_TOKEN or both SLACK_BOT_TOKEN + SLACK_APP_TOKEN");
|
|
21
|
+
}
|
|
8
22
|
const rawIds = process.env.ALLOWED_USER_IDS ?? process.env.ALLOWED_USER_ID;
|
|
9
23
|
if (!rawIds)
|
|
10
24
|
throw new Error("Missing required env var: ALLOWED_USER_IDS");
|
|
@@ -21,12 +35,12 @@ export function loadConfig() {
|
|
|
21
35
|
throw new Error(`PERMISSION_MODE must be one of: ${valid.join(", ")}`);
|
|
22
36
|
}
|
|
23
37
|
return {
|
|
24
|
-
|
|
38
|
+
channel,
|
|
25
39
|
allowedUserIds,
|
|
26
40
|
permissionMode,
|
|
27
|
-
dataDir,
|
|
28
41
|
defaultPromptPath: path.join(dataDir, "workspace", "CLAUDE.md"),
|
|
29
|
-
|
|
42
|
+
workspaceStorePath: path.join(dataDir, "workspaces.json"),
|
|
43
|
+
filesPath: path.join(dataDir, "files"),
|
|
30
44
|
logPath: path.join(dataDir, "clearclaw.log"),
|
|
31
45
|
};
|
|
32
46
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { Engine, EngineEvent, RunTurnOpts } from "../types.js";
|
|
1
|
+
import type { Engine, EngineEvent, RunTurnOpts, SessionInfo } from "../types.js";
|
|
2
2
|
export declare class ClaudeCodeEngine implements Engine {
|
|
3
3
|
name: string;
|
|
4
|
+
listSessions(cwd: string): Promise<SessionInfo[]>;
|
|
4
5
|
runTurn(opts: RunTurnOpts): AsyncIterable<EngineEvent>;
|
|
5
6
|
}
|
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import { query, } from "@anthropic-ai/claude-agent-sdk";
|
|
1
|
+
import { query, listSessions, } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import log from "../logger.js";
|
|
3
3
|
import { formatToolDescription } from "../format.js";
|
|
4
4
|
export class ClaudeCodeEngine {
|
|
5
5
|
name = "claude-code";
|
|
6
|
+
async listSessions(cwd) {
|
|
7
|
+
const sessions = await listSessions({ dir: cwd, limit: 10 });
|
|
8
|
+
return sessions
|
|
9
|
+
.sort((a, b) => b.lastModified - a.lastModified)
|
|
10
|
+
.map((s) => ({
|
|
11
|
+
sessionId: s.sessionId,
|
|
12
|
+
summary: s.summary,
|
|
13
|
+
lastModified: s.lastModified,
|
|
14
|
+
gitBranch: s.gitBranch,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
6
17
|
async *runTurn(opts) {
|
|
7
|
-
const { sessionId, cwd, prompt, permissionMode, onPermissionRequest, appendSystemPrompt, signal, } = opts;
|
|
18
|
+
const { sessionId, cwd, prompt: textPrompt, attachments, permissionMode, onPermissionRequest, appendSystemPrompt, signal, } = opts;
|
|
8
19
|
const abortController = new AbortController();
|
|
9
20
|
// Wire external signal to our abort controller
|
|
10
21
|
if (signal) {
|
|
@@ -22,6 +33,7 @@ export class ClaudeCodeEngine {
|
|
|
22
33
|
input,
|
|
23
34
|
description,
|
|
24
35
|
reason: options.decisionReason,
|
|
36
|
+
toolUseId: options.toolUseID,
|
|
25
37
|
});
|
|
26
38
|
if (resp.decision === "allow") {
|
|
27
39
|
return { behavior: "allow", updatedInput: input };
|
|
@@ -40,6 +52,10 @@ export class ClaudeCodeEngine {
|
|
|
40
52
|
interrupt: !resp.message,
|
|
41
53
|
};
|
|
42
54
|
};
|
|
55
|
+
// Plain string when no attachments, content blocks when attachments present
|
|
56
|
+
const prompt = attachments?.length
|
|
57
|
+
? buildAttachmentPrompt(textPrompt, attachments)
|
|
58
|
+
: textPrompt;
|
|
43
59
|
const q = query({
|
|
44
60
|
prompt,
|
|
45
61
|
options: {
|
|
@@ -62,6 +78,8 @@ export class ClaudeCodeEngine {
|
|
|
62
78
|
},
|
|
63
79
|
});
|
|
64
80
|
let resultSessionId;
|
|
81
|
+
let turnStats;
|
|
82
|
+
let lastInputTokens = 0;
|
|
65
83
|
const toolUseIdToName = new Map();
|
|
66
84
|
try {
|
|
67
85
|
for await (const msg of q) {
|
|
@@ -71,17 +89,28 @@ export class ClaudeCodeEngine {
|
|
|
71
89
|
}
|
|
72
90
|
// Extract text and tool_use from assistant messages
|
|
73
91
|
if (msg.type === "assistant") {
|
|
74
|
-
|
|
92
|
+
// BetaMessage type isn't directly importable (@anthropic-ai/sdk not installed);
|
|
93
|
+
// .message is typed as `any` so property access works without casting.
|
|
94
|
+
const betaMsg = msg.message;
|
|
95
|
+
if (betaMsg.usage) {
|
|
96
|
+
const u = betaMsg.usage;
|
|
97
|
+
lastInputTokens = (u.input_tokens ?? 0)
|
|
98
|
+
+ (u.cache_read_input_tokens ?? 0)
|
|
99
|
+
+ (u.cache_creation_input_tokens ?? 0);
|
|
100
|
+
}
|
|
101
|
+
const { content } = betaMsg;
|
|
75
102
|
for (const block of content) {
|
|
76
103
|
if (block.type === "text" && block.text) {
|
|
77
104
|
yield { type: "text", text: block.text };
|
|
78
105
|
}
|
|
79
106
|
if (block.type === "tool_use") {
|
|
80
|
-
|
|
107
|
+
const toolUseId = block.id;
|
|
108
|
+
toolUseIdToName.set(toolUseId, block.name);
|
|
81
109
|
yield {
|
|
82
110
|
type: "tool_use",
|
|
83
111
|
toolName: block.name,
|
|
84
112
|
input: block.input,
|
|
113
|
+
toolUseId,
|
|
85
114
|
};
|
|
86
115
|
}
|
|
87
116
|
}
|
|
@@ -91,15 +120,12 @@ export class ClaudeCodeEngine {
|
|
|
91
120
|
const content = msg.message.content;
|
|
92
121
|
if (Array.isArray(content)) {
|
|
93
122
|
for (const block of content) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const toolName = toolUseIdToName.get(b.tool_use_id) ?? "unknown";
|
|
98
|
-
const output = extractToolResultText(b.content);
|
|
123
|
+
if (block.type === "tool_result") {
|
|
124
|
+
const toolName = toolUseIdToName.get(block.tool_use_id) ?? "unknown";
|
|
125
|
+
const output = extractToolResultText(block.content);
|
|
99
126
|
if (output) {
|
|
100
127
|
yield { type: "tool_result", toolName, output };
|
|
101
128
|
}
|
|
102
|
-
toolUseIdToName.delete(b.tool_use_id);
|
|
103
129
|
}
|
|
104
130
|
}
|
|
105
131
|
}
|
|
@@ -122,10 +148,26 @@ export class ClaudeCodeEngine {
|
|
|
122
148
|
yield { type: "text", text: cmdOut.content };
|
|
123
149
|
}
|
|
124
150
|
}
|
|
125
|
-
// Capture result
|
|
151
|
+
// Capture result and build turn stats
|
|
126
152
|
if (msg.type === "result") {
|
|
127
153
|
const result = msg;
|
|
128
154
|
resultSessionId = result.session_id;
|
|
155
|
+
const models = Object.keys(result.modelUsage);
|
|
156
|
+
if (models.length > 0) {
|
|
157
|
+
const model = models[0];
|
|
158
|
+
const mu = result.modelUsage[model];
|
|
159
|
+
// Build per-tool call counts from accumulated map
|
|
160
|
+
const toolCalls = {};
|
|
161
|
+
for (const name of toolUseIdToName.values()) {
|
|
162
|
+
toolCalls[name] = (toolCalls[name] ?? 0) + 1;
|
|
163
|
+
}
|
|
164
|
+
turnStats = {
|
|
165
|
+
model,
|
|
166
|
+
contextUsed: lastInputTokens,
|
|
167
|
+
contextWindow: mu.contextWindow,
|
|
168
|
+
toolCalls,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
129
171
|
if (result.subtype !== "success" && result.errors.length) {
|
|
130
172
|
yield { type: "error", message: result.errors.join("\n") };
|
|
131
173
|
}
|
|
@@ -144,10 +186,67 @@ export class ClaudeCodeEngine {
|
|
|
144
186
|
}
|
|
145
187
|
}
|
|
146
188
|
if (resultSessionId) {
|
|
147
|
-
yield { type: "done", sessionId: resultSessionId };
|
|
189
|
+
yield { type: "done", sessionId: resultSessionId, stats: turnStats };
|
|
148
190
|
}
|
|
149
191
|
}
|
|
150
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Build an AsyncIterable<SDKUserMessage> that yields a single user message
|
|
195
|
+
* with content blocks for attachments + text.
|
|
196
|
+
*/
|
|
197
|
+
function buildAttachmentPrompt(text, attachments) {
|
|
198
|
+
const contentBlocks = attachments.map(encodeAttachment);
|
|
199
|
+
// Text prompt always comes last
|
|
200
|
+
if (text) {
|
|
201
|
+
contentBlocks.push({ type: "text", text });
|
|
202
|
+
}
|
|
203
|
+
const message = { role: "user", content: contentBlocks };
|
|
204
|
+
// SDKUserMessage requires these fields for type satisfaction, but they're
|
|
205
|
+
// not semantically meaningful here — we're injecting a top-level prompt,
|
|
206
|
+
// not replaying a conversation. Session management is handled by the
|
|
207
|
+
// `resume` option passed to query().
|
|
208
|
+
const msg = {
|
|
209
|
+
type: "user",
|
|
210
|
+
message,
|
|
211
|
+
parent_tool_use_id: null,
|
|
212
|
+
session_id: "",
|
|
213
|
+
};
|
|
214
|
+
return (async function* () {
|
|
215
|
+
yield msg;
|
|
216
|
+
})();
|
|
217
|
+
}
|
|
218
|
+
const SUPPORTED_IMAGE_TYPES = new Set([
|
|
219
|
+
"image/jpeg", "image/png", "image/gif", "image/webp",
|
|
220
|
+
]);
|
|
221
|
+
/** Encode an attachment as an SDK content block using its in-memory buffer. */
|
|
222
|
+
function encodeAttachment(att) {
|
|
223
|
+
const data = att.buffer.toString("base64");
|
|
224
|
+
if (SUPPORTED_IMAGE_TYPES.has(att.mimeType)) {
|
|
225
|
+
return {
|
|
226
|
+
type: "image",
|
|
227
|
+
source: {
|
|
228
|
+
type: "base64",
|
|
229
|
+
media_type: att.mimeType,
|
|
230
|
+
data,
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (att.mimeType === "application/pdf") {
|
|
235
|
+
return {
|
|
236
|
+
type: "document",
|
|
237
|
+
source: { type: "base64", media_type: "application/pdf", data },
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (att.mimeType.startsWith("text/")) {
|
|
241
|
+
const content = att.buffer.toString("utf-8");
|
|
242
|
+
const header = att.filename ? `--- ${att.filename} ---\n` : "";
|
|
243
|
+
return { type: "text", text: `${header}${content}` };
|
|
244
|
+
}
|
|
245
|
+
// Unsupported (includes non-standard image types like svg, bmp, tiff)
|
|
246
|
+
const name = att.filename ?? att.mimeType;
|
|
247
|
+
log.warn("[engine] unsupported attachment type: %s", att.mimeType);
|
|
248
|
+
return { type: "text", text: `[Unsupported file: ${name}]` };
|
|
249
|
+
}
|
|
151
250
|
/** Pull plain text out of a tool_result content block. */
|
|
152
251
|
function extractToolResultText(content) {
|
|
153
252
|
if (typeof content === "string")
|
package/dist/files.d.ts
ADDED
package/dist/files.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Map common MIME types to file extensions. */
|
|
5
|
+
const MIME_TO_EXT = {
|
|
6
|
+
"image/jpeg": ".jpg",
|
|
7
|
+
"image/png": ".png",
|
|
8
|
+
"image/gif": ".gif",
|
|
9
|
+
"image/webp": ".webp",
|
|
10
|
+
"application/pdf": ".pdf",
|
|
11
|
+
"text/plain": ".txt",
|
|
12
|
+
"text/csv": ".csv",
|
|
13
|
+
};
|
|
14
|
+
/** Save an attachment to disk for audit logging. Returns the saved file path. */
|
|
15
|
+
export async function saveFile(att, workspace, filesPath) {
|
|
16
|
+
const epoch = Math.floor(Date.now() / 1000);
|
|
17
|
+
const hex = crypto.randomBytes(2).toString("hex");
|
|
18
|
+
const name = att.filename
|
|
19
|
+
? sanitizeFilename(att.filename)
|
|
20
|
+
: `file${MIME_TO_EXT[att.mimeType] ?? ""}`;
|
|
21
|
+
const filename = `${workspace}-${epoch}-${hex}-${name}`;
|
|
22
|
+
const filePath = path.join(filesPath, filename);
|
|
23
|
+
await fs.writeFile(filePath, att.buffer);
|
|
24
|
+
return filePath;
|
|
25
|
+
}
|
|
26
|
+
/** Strip path separators and null bytes, collapse whitespace. */
|
|
27
|
+
function sanitizeFilename(name) {
|
|
28
|
+
return name
|
|
29
|
+
.replace(/[/\\\0]/g, "_")
|
|
30
|
+
.replace(/\s+/g, "_")
|
|
31
|
+
.slice(0, 200);
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=files.js.map
|