clearclaw 0.3.0 → 0.5.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/dist/channel/slack.d.ts +30 -0
- package/dist/channel/slack.js +411 -0
- package/dist/channel/telegram.d.ts +12 -2
- package/dist/channel/telegram.js +196 -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 +78 -8
- package/dist/files.d.ts +3 -0
- package/dist/files.js +24 -0
- package/dist/format.d.ts +4 -0
- package/dist/format.js +22 -18
- package/dist/index.js +10 -2
- package/dist/logger.js +1 -1
- package/dist/orchestrator.d.ts +2 -1
- package/dist/orchestrator.js +175 -102
- package/dist/types.d.ts +25 -4
- package/package.json +4 -1
package/dist/channel/telegram.js
CHANGED
|
@@ -1,40 +1,65 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import { Bot, InlineKeyboard } from "grammy";
|
|
2
|
+
import { Bot, InlineKeyboard, InputFile } from "grammy";
|
|
3
|
+
import mime from "mime";
|
|
3
4
|
import log from "../logger.js";
|
|
4
5
|
export class TelegramChannel extends EventEmitter {
|
|
5
6
|
name = "telegram";
|
|
6
7
|
bot;
|
|
8
|
+
botToken;
|
|
7
9
|
allowedUserIds;
|
|
8
10
|
typingIntervals = new Map();
|
|
11
|
+
statusHandles = new Map(); // chatId → message handle for pinned status
|
|
9
12
|
// Pending interactive responses: callbackId → resolve function
|
|
10
13
|
pendingCallbacks = new Map();
|
|
11
14
|
// Pending text input: chatId → resolve function (for requestText follow-ups)
|
|
12
15
|
pendingTextResolvers = new Map();
|
|
13
16
|
constructor(botToken, allowedUserIds) {
|
|
14
17
|
super();
|
|
18
|
+
this.botToken = botToken;
|
|
15
19
|
this.allowedUserIds = allowedUserIds;
|
|
16
20
|
this.bot = new Bot(botToken);
|
|
17
21
|
}
|
|
18
22
|
async connect() {
|
|
19
|
-
// Auth check: only respond to allowed user
|
|
20
23
|
this.bot.on("message:text", (ctx) => {
|
|
21
|
-
|
|
24
|
+
const sender = this.extractSender(ctx);
|
|
25
|
+
if (!sender)
|
|
22
26
|
return;
|
|
23
|
-
const chatId = `tg:${ctx.chat.id}`;
|
|
24
27
|
// Intercept for requestText follow-ups before emitting as a new message
|
|
25
|
-
const textResolver = this.pendingTextResolvers.get(chatId);
|
|
28
|
+
const textResolver = this.pendingTextResolvers.get(sender.chatId);
|
|
26
29
|
if (textResolver) {
|
|
27
|
-
this.pendingTextResolvers.delete(chatId);
|
|
30
|
+
this.pendingTextResolvers.delete(sender.chatId);
|
|
28
31
|
textResolver(ctx.message.text);
|
|
29
32
|
return;
|
|
30
33
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
this.emit("message", { ...sender, text: ctx.message.text });
|
|
35
|
+
});
|
|
36
|
+
// Photos: Telegram sends each photo as an array of PhotoSize objects
|
|
37
|
+
// (same image at different resolutions, sorted smallest → largest).
|
|
38
|
+
// We grab the last element for the highest resolution version.
|
|
39
|
+
this.bot.on("message:photo", async (ctx) => {
|
|
40
|
+
const photo = ctx.message.photo;
|
|
41
|
+
const largest = photo[photo.length - 1];
|
|
42
|
+
await this.handleFileMessage(ctx, largest.file_id, "image/jpeg", undefined, ctx.message.caption);
|
|
37
43
|
});
|
|
44
|
+
// Documents: download with original MIME + filename
|
|
45
|
+
this.bot.on("message:document", async (ctx) => {
|
|
46
|
+
const doc = ctx.message.document;
|
|
47
|
+
await this.handleFileMessage(ctx, doc.file_id, doc.mime_type ?? "application/octet-stream", doc.file_name, ctx.message.caption);
|
|
48
|
+
});
|
|
49
|
+
// Placeholder handlers for unsupported media types
|
|
50
|
+
for (const [filter, label] of [
|
|
51
|
+
["message:voice", "Voice message"],
|
|
52
|
+
["message:audio", "Audio message"],
|
|
53
|
+
["message:video", "Video"],
|
|
54
|
+
["message:sticker", "Sticker"],
|
|
55
|
+
]) {
|
|
56
|
+
this.bot.on(filter, (ctx) => {
|
|
57
|
+
const sender = this.extractSender(ctx);
|
|
58
|
+
if (!sender)
|
|
59
|
+
return;
|
|
60
|
+
this.emit("message", { ...sender, text: `[${label} — not supported]` });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
38
63
|
// Handle inline keyboard button presses
|
|
39
64
|
this.bot.on("callback_query:data", async (ctx) => {
|
|
40
65
|
const data = ctx.callbackQuery.data;
|
|
@@ -62,6 +87,7 @@ export class TelegramChannel extends EventEmitter {
|
|
|
62
87
|
clearInterval(interval);
|
|
63
88
|
}
|
|
64
89
|
this.typingIntervals.clear();
|
|
90
|
+
this.statusHandles.clear();
|
|
65
91
|
await this.bot.stop();
|
|
66
92
|
}
|
|
67
93
|
ownsId(chatId) {
|
|
@@ -91,7 +117,7 @@ export class TelegramChannel extends EventEmitter {
|
|
|
91
117
|
}
|
|
92
118
|
return handles;
|
|
93
119
|
}
|
|
94
|
-
async sendInteractive(chatId, text, buttons
|
|
120
|
+
async sendInteractive(chatId, text, buttons) {
|
|
95
121
|
const numId = this.numericId(chatId);
|
|
96
122
|
const callbackId = crypto.randomUUID().slice(0, 8);
|
|
97
123
|
// Pre-compute callback data keys (use flat index to avoid collisions when buttons share values)
|
|
@@ -112,45 +138,52 @@ export class TelegramChannel extends EventEmitter {
|
|
|
112
138
|
}
|
|
113
139
|
keyboard.row();
|
|
114
140
|
}
|
|
141
|
+
// Convert universal markdown to MarkdownV2, fall back to plain text on failure
|
|
142
|
+
const mdv2Text = convertToMarkdownV2(text);
|
|
115
143
|
let sent;
|
|
116
144
|
try {
|
|
117
|
-
sent = await this.bot.api.sendMessage(numId,
|
|
145
|
+
sent = await this.bot.api.sendMessage(numId, mdv2Text, {
|
|
118
146
|
reply_markup: keyboard,
|
|
119
|
-
|
|
147
|
+
parse_mode: "MarkdownV2",
|
|
120
148
|
});
|
|
121
149
|
}
|
|
122
|
-
catch
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
else {
|
|
130
|
-
throw err;
|
|
131
|
-
}
|
|
150
|
+
catch {
|
|
151
|
+
log.warn("[channel] sendInteractive failed with MarkdownV2, retrying as plain text");
|
|
152
|
+
sent = await this.bot.api.sendMessage(numId, text, {
|
|
153
|
+
reply_markup: keyboard,
|
|
154
|
+
});
|
|
132
155
|
}
|
|
133
156
|
const cleanupAll = () => {
|
|
134
157
|
for (const { cbData } of cbEntries) {
|
|
135
158
|
this.pendingCallbacks.delete(cbData);
|
|
136
159
|
}
|
|
137
160
|
};
|
|
138
|
-
// Wait for button press
|
|
161
|
+
// Wait for button press (no timeout — matches CLI behavior)
|
|
139
162
|
const pressed = await new Promise((resolve) => {
|
|
140
|
-
const timeout = setTimeout(() => {
|
|
141
|
-
cleanupAll();
|
|
142
|
-
resolve(null);
|
|
143
|
-
}, 30 * 60 * 1000);
|
|
144
163
|
for (const { btn, cbData } of cbEntries) {
|
|
145
164
|
this.pendingCallbacks.set(cbData, () => {
|
|
146
|
-
clearTimeout(timeout);
|
|
147
165
|
cleanupAll();
|
|
148
166
|
resolve(btn);
|
|
149
167
|
});
|
|
150
168
|
}
|
|
151
169
|
});
|
|
152
|
-
|
|
153
|
-
|
|
170
|
+
// Update keyboard to highlight the selected button (others become inert — no callbacks)
|
|
171
|
+
const feedbackKeyboard = new InlineKeyboard();
|
|
172
|
+
let entryIndex = 0;
|
|
173
|
+
for (const row of buttons) {
|
|
174
|
+
for (const button of row) {
|
|
175
|
+
const entry = cbEntries[entryIndex++];
|
|
176
|
+
const label = button === pressed ? `✅ ${button.label}` : button.label;
|
|
177
|
+
feedbackKeyboard.text(label, entry.cbData);
|
|
178
|
+
}
|
|
179
|
+
feedbackKeyboard.row();
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
await this.bot.api.editMessageReplyMarkup(numId, sent.message_id, {
|
|
183
|
+
reply_markup: feedbackKeyboard,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch { /* best-effort */ }
|
|
154
187
|
// If the button requests text, prompt for follow-up input
|
|
155
188
|
if (pressed.requestText) {
|
|
156
189
|
await this.bot.api.sendMessage(numId, "Add your feedback:", {
|
|
@@ -182,6 +215,39 @@ export class TelegramChannel extends EventEmitter {
|
|
|
182
215
|
const numId = this.numericId(chatId);
|
|
183
216
|
await this.bot.api.unpinAllChatMessages(numId);
|
|
184
217
|
}
|
|
218
|
+
async updateStatus(chatId, text) {
|
|
219
|
+
const existing = this.statusHandles.get(chatId);
|
|
220
|
+
if (existing) {
|
|
221
|
+
try {
|
|
222
|
+
await this.editMessage(chatId, existing, text);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
if (!(err instanceof Error && err.message.includes("message is not modified"))) {
|
|
227
|
+
// Edit failed for a real reason — fall through to create new
|
|
228
|
+
log.warn("[channel] failed to edit status message, will recreate: %s", err instanceof Error ? err.message : String(err));
|
|
229
|
+
this.statusHandles.delete(chatId);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
return; // text unchanged, nothing to do
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// No existing handle, or edit failed — create new pinned message
|
|
237
|
+
try {
|
|
238
|
+
await this.unpinAllMessages(chatId);
|
|
239
|
+
}
|
|
240
|
+
catch { /* not admin */ }
|
|
241
|
+
const handles = await this.sendMessage(chatId, text);
|
|
242
|
+
const handle = handles[0];
|
|
243
|
+
this.statusHandles.set(chatId, handle);
|
|
244
|
+
try {
|
|
245
|
+
await this.pinMessage(chatId, handle);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
log.warn("[channel] failed to pin status message (bot may not be admin): %s", err instanceof Error ? err.message : String(err));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
185
251
|
async setTyping(chatId, isTyping) {
|
|
186
252
|
if (isTyping) {
|
|
187
253
|
if (this.typingIntervals.has(chatId))
|
|
@@ -204,10 +270,107 @@ export class TelegramChannel extends EventEmitter {
|
|
|
204
270
|
}
|
|
205
271
|
}
|
|
206
272
|
}
|
|
273
|
+
/** Extract auth-checked sender info from a grammY context. Returns null if unauthorized. */
|
|
274
|
+
extractSender(ctx) {
|
|
275
|
+
if (!ctx.from?.id || !ctx.chat?.id || !this.allowedUserIds.has(`tg:${ctx.from.id}`))
|
|
276
|
+
return null;
|
|
277
|
+
return {
|
|
278
|
+
chatId: `tg:${ctx.chat.id}`,
|
|
279
|
+
user: {
|
|
280
|
+
id: `tg:${ctx.from.id}`,
|
|
281
|
+
name: [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(" "),
|
|
282
|
+
...(ctx.from.username && { handle: ctx.from.username }),
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/** Shared handler for photo/document messages: download, emit attachment or fallback. */
|
|
287
|
+
async handleFileMessage(ctx, fileId, mimeType, filename, caption) {
|
|
288
|
+
const sender = this.extractSender(ctx);
|
|
289
|
+
if (!sender)
|
|
290
|
+
return;
|
|
291
|
+
try {
|
|
292
|
+
const buffer = await this.downloadFile(fileId);
|
|
293
|
+
this.emit("message", {
|
|
294
|
+
...sender,
|
|
295
|
+
text: caption ?? "",
|
|
296
|
+
attachments: [{ buffer, mimeType, filename }],
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
log.warn({ err }, "[channel] failed to download file %s", filename ?? fileId);
|
|
301
|
+
this.emit("message", {
|
|
302
|
+
...sender,
|
|
303
|
+
text: caption
|
|
304
|
+
? `${caption}\n[File download failed: ${filename ?? mimeType}]`
|
|
305
|
+
: `[File download failed: ${filename ?? mimeType}]`,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async sendFile(chatId, buffer, filename, opts) {
|
|
310
|
+
const id = this.numericId(chatId);
|
|
311
|
+
const mimeType = opts?.mimeType ?? mime.getType(filename) ?? "application/octet-stream";
|
|
312
|
+
const file = new InputFile(buffer, filename);
|
|
313
|
+
const caption = opts?.caption;
|
|
314
|
+
if (mimeType.startsWith("image/")) {
|
|
315
|
+
await this.bot.api.sendPhoto(id, file, { caption });
|
|
316
|
+
}
|
|
317
|
+
else if (mimeType.startsWith("video/")) {
|
|
318
|
+
await this.bot.api.sendVideo(id, file, { caption });
|
|
319
|
+
}
|
|
320
|
+
else if (mimeType.startsWith("audio/")) {
|
|
321
|
+
await this.bot.api.sendAudio(id, file, { caption });
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
await this.bot.api.sendDocument(id, file, { caption });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** Download a Telegram file by file_id, returning the raw buffer. */
|
|
328
|
+
async downloadFile(fileId) {
|
|
329
|
+
const file = await this.bot.api.getFile(fileId);
|
|
330
|
+
if (!file.file_path)
|
|
331
|
+
throw new Error("Telegram returned no file_path");
|
|
332
|
+
const url = `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`;
|
|
333
|
+
const resp = await fetch(url);
|
|
334
|
+
if (!resp.ok)
|
|
335
|
+
throw new Error(`File download failed: ${resp.status}`);
|
|
336
|
+
return Buffer.from(await resp.arrayBuffer());
|
|
337
|
+
}
|
|
207
338
|
numericId(chatId) {
|
|
208
339
|
return Number(chatId.replace(/^tg:/, ""));
|
|
209
340
|
}
|
|
210
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* Escape for Telegram MarkdownV2.
|
|
344
|
+
* Outside code blocks: all special chars. Inside: only backtick and backslash.
|
|
345
|
+
*/
|
|
346
|
+
function escapeMarkdownV2(text, codeBlock = false) {
|
|
347
|
+
const pattern = codeBlock ? /[`\\]/g : /[_*\[\]()~`>#+\-=|{}.!\\]/g;
|
|
348
|
+
return text.replace(pattern, "\\$&");
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Convert universal markdown (with code fences) to Telegram MarkdownV2.
|
|
352
|
+
* Handles code blocks specially: fence markers stay literal, inner content
|
|
353
|
+
* only escapes ` and \, outer text gets full MarkdownV2 escaping.
|
|
354
|
+
*/
|
|
355
|
+
function convertToMarkdownV2(markdown) {
|
|
356
|
+
const result = [];
|
|
357
|
+
let pos = 0;
|
|
358
|
+
const fenceRegex = /```(\w*)\n([\s\S]*?)```/g;
|
|
359
|
+
let match;
|
|
360
|
+
while ((match = fenceRegex.exec(markdown)) !== null) {
|
|
361
|
+
if (match.index > pos) {
|
|
362
|
+
result.push(escapeMarkdownV2(markdown.slice(pos, match.index)));
|
|
363
|
+
}
|
|
364
|
+
result.push("```" + match[1] + "\n");
|
|
365
|
+
result.push(escapeMarkdownV2(match[2], true));
|
|
366
|
+
result.push("```");
|
|
367
|
+
pos = match.index + match[0].length;
|
|
368
|
+
}
|
|
369
|
+
if (pos < markdown.length) {
|
|
370
|
+
result.push(escapeMarkdownV2(markdown.slice(pos)));
|
|
371
|
+
}
|
|
372
|
+
return result.join("");
|
|
373
|
+
}
|
|
211
374
|
const MAX_MSG_LEN = 4096;
|
|
212
375
|
/** Split text into ≤4096-char chunks, preferring newline boundaries. */
|
|
213
376
|
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, mcpServers, signal, } = opts;
|
|
8
19
|
const abortController = new AbortController();
|
|
9
20
|
// Wire external signal to our abort controller
|
|
10
21
|
if (signal) {
|
|
@@ -41,6 +52,10 @@ export class ClaudeCodeEngine {
|
|
|
41
52
|
interrupt: !resp.message,
|
|
42
53
|
};
|
|
43
54
|
};
|
|
55
|
+
// Plain string when no attachments, content blocks when attachments present
|
|
56
|
+
const prompt = attachments?.length
|
|
57
|
+
? buildAttachmentPrompt(textPrompt, attachments)
|
|
58
|
+
: textPrompt;
|
|
44
59
|
const q = query({
|
|
45
60
|
prompt,
|
|
46
61
|
options: {
|
|
@@ -51,6 +66,7 @@ export class ClaudeCodeEngine {
|
|
|
51
66
|
canUseTool,
|
|
52
67
|
abortController,
|
|
53
68
|
settingSources: ["user", "project", "local"],
|
|
69
|
+
...(mcpServers ? { mcpServers } : {}),
|
|
54
70
|
...(appendSystemPrompt
|
|
55
71
|
? {
|
|
56
72
|
systemPrompt: {
|
|
@@ -105,15 +121,12 @@ export class ClaudeCodeEngine {
|
|
|
105
121
|
const content = msg.message.content;
|
|
106
122
|
if (Array.isArray(content)) {
|
|
107
123
|
for (const block of content) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const toolName = toolUseIdToName.get(b.tool_use_id) ?? "unknown";
|
|
112
|
-
const output = extractToolResultText(b.content);
|
|
124
|
+
if (block.type === "tool_result") {
|
|
125
|
+
const toolName = toolUseIdToName.get(block.tool_use_id) ?? "unknown";
|
|
126
|
+
const output = extractToolResultText(block.content);
|
|
113
127
|
if (output) {
|
|
114
128
|
yield { type: "tool_result", toolName, output };
|
|
115
129
|
}
|
|
116
|
-
// Retain for turn-level stats (map is GC'd when generator ends)
|
|
117
130
|
}
|
|
118
131
|
}
|
|
119
132
|
}
|
|
@@ -178,6 +191,63 @@ export class ClaudeCodeEngine {
|
|
|
178
191
|
}
|
|
179
192
|
}
|
|
180
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Build an AsyncIterable<SDKUserMessage> that yields a single user message
|
|
196
|
+
* with content blocks for attachments + text.
|
|
197
|
+
*/
|
|
198
|
+
function buildAttachmentPrompt(text, attachments) {
|
|
199
|
+
const contentBlocks = attachments.map(encodeAttachment);
|
|
200
|
+
// Text prompt always comes last
|
|
201
|
+
if (text) {
|
|
202
|
+
contentBlocks.push({ type: "text", text });
|
|
203
|
+
}
|
|
204
|
+
const message = { role: "user", content: contentBlocks };
|
|
205
|
+
// SDKUserMessage requires these fields for type satisfaction, but they're
|
|
206
|
+
// not semantically meaningful here — we're injecting a top-level prompt,
|
|
207
|
+
// not replaying a conversation. Session management is handled by the
|
|
208
|
+
// `resume` option passed to query().
|
|
209
|
+
const msg = {
|
|
210
|
+
type: "user",
|
|
211
|
+
message,
|
|
212
|
+
parent_tool_use_id: null,
|
|
213
|
+
session_id: "",
|
|
214
|
+
};
|
|
215
|
+
return (async function* () {
|
|
216
|
+
yield msg;
|
|
217
|
+
})();
|
|
218
|
+
}
|
|
219
|
+
const SUPPORTED_IMAGE_TYPES = new Set([
|
|
220
|
+
"image/jpeg", "image/png", "image/gif", "image/webp",
|
|
221
|
+
]);
|
|
222
|
+
/** Encode an attachment as an SDK content block using its in-memory buffer. */
|
|
223
|
+
function encodeAttachment(att) {
|
|
224
|
+
const data = att.buffer.toString("base64");
|
|
225
|
+
if (SUPPORTED_IMAGE_TYPES.has(att.mimeType)) {
|
|
226
|
+
return {
|
|
227
|
+
type: "image",
|
|
228
|
+
source: {
|
|
229
|
+
type: "base64",
|
|
230
|
+
media_type: att.mimeType,
|
|
231
|
+
data,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (att.mimeType === "application/pdf") {
|
|
236
|
+
return {
|
|
237
|
+
type: "document",
|
|
238
|
+
source: { type: "base64", media_type: "application/pdf", data },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (att.mimeType.startsWith("text/")) {
|
|
242
|
+
const content = att.buffer.toString("utf-8");
|
|
243
|
+
const header = att.filename ? `--- ${att.filename} ---\n` : "";
|
|
244
|
+
return { type: "text", text: `${header}${content}` };
|
|
245
|
+
}
|
|
246
|
+
// Unsupported (includes non-standard image types like svg, bmp, tiff)
|
|
247
|
+
const name = att.filename ?? att.mimeType;
|
|
248
|
+
log.warn("[engine] unsupported attachment type: %s", att.mimeType);
|
|
249
|
+
return { type: "text", text: `[Unsupported file: ${name}]` };
|
|
250
|
+
}
|
|
181
251
|
/** Pull plain text out of a tool_result content block. */
|
|
182
252
|
function extractToolResultText(content) {
|
|
183
253
|
if (typeof content === "string")
|
package/dist/files.d.ts
ADDED
package/dist/files.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import mime from "mime";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
/** Save an attachment to disk for audit logging. Returns the saved file path. */
|
|
6
|
+
export async function saveFile(att, workspace, filesPath) {
|
|
7
|
+
const epoch = Math.floor(Date.now() / 1000);
|
|
8
|
+
const hex = crypto.randomBytes(2).toString("hex");
|
|
9
|
+
const name = att.filename
|
|
10
|
+
? sanitizeFilename(att.filename)
|
|
11
|
+
: `file${att.mimeType ? `.${mime.getExtension(att.mimeType) ?? "bin"}` : ""}`;
|
|
12
|
+
const filename = `${workspace}-${epoch}-${hex}-${name}`;
|
|
13
|
+
const filePath = path.join(filesPath, filename);
|
|
14
|
+
await fs.writeFile(filePath, att.buffer);
|
|
15
|
+
return filePath;
|
|
16
|
+
}
|
|
17
|
+
/** Strip path separators and null bytes, collapse whitespace. */
|
|
18
|
+
function sanitizeFilename(name) {
|
|
19
|
+
return name
|
|
20
|
+
.replace(/[/\\\0]/g, "_")
|
|
21
|
+
.replace(/\s+/g, "_")
|
|
22
|
+
.slice(0, 200);
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=files.js.map
|
package/dist/format.d.ts
CHANGED
|
@@ -18,6 +18,10 @@ export declare function formatToolStatusLine(toolName: string, input: Record<str
|
|
|
18
18
|
* Edit/Write include the diff/preview; other tools get a one-line description.
|
|
19
19
|
*/
|
|
20
20
|
export declare function formatPermissionPrompt(toolName: string, input: Record<string, unknown>, description: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Relative time string: "just now", "5m ago", "2h ago", "3d ago".
|
|
23
|
+
*/
|
|
24
|
+
export declare function timeAgo(epochMs: number): string;
|
|
21
25
|
/**
|
|
22
26
|
* Human-readable one-liner for a tool invocation (used in engine's canUseTool).
|
|
23
27
|
*/
|
package/dist/format.js
CHANGED
|
@@ -41,22 +41,19 @@ export function formatPermissionPrompt(toolName, input, description) {
|
|
|
41
41
|
const lines = patch.split("\n");
|
|
42
42
|
const start = lines.findIndex((l) => l.startsWith("---"));
|
|
43
43
|
const diffLines = start >= 0 ? lines.slice(start) : lines;
|
|
44
|
-
const body =
|
|
45
|
-
|
|
46
|
-
return `${header}\n\`\`\`diff\n${body}\n\`\`\``;
|
|
44
|
+
const body = truncateLines(diffLines, MAX_LINES);
|
|
45
|
+
return `🔐 Allow Edit? ${edit.file_path}\n\`\`\`diff\n${body}\n\`\`\``;
|
|
47
46
|
}
|
|
48
47
|
if (toolName === "Write") {
|
|
49
48
|
const write = input;
|
|
50
49
|
const lines = write.content.split("\n");
|
|
51
|
-
const body =
|
|
52
|
-
|
|
53
|
-
return `${header}\n\`\`\`\n${body}\n\`\`\``;
|
|
50
|
+
const body = truncateLines(lines, MAX_LINES);
|
|
51
|
+
return `🔐 Allow Write? ${write.file_path} (${lines.length} lines)\n\`\`\`\n${body}\n\`\`\``;
|
|
54
52
|
}
|
|
55
53
|
// Non-Edit/Write tools: emoji header + detail in code block
|
|
56
54
|
const detail = formatToolDetail(toolName, input);
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
return `${header}\n\`\`\`\n${body}\n\`\`\``;
|
|
55
|
+
const body = truncateLines(detail.split("\n"), MAX_LINES);
|
|
56
|
+
return `🔐 Allow ${toolName}?\n\`\`\`\n${body}\n\`\`\``;
|
|
60
57
|
}
|
|
61
58
|
function formatToolStatus(toolName, input) {
|
|
62
59
|
switch (toolName) {
|
|
@@ -104,6 +101,22 @@ function formatToolDetail(toolName, input) {
|
|
|
104
101
|
return JSON.stringify(input, null, 2);
|
|
105
102
|
}
|
|
106
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Relative time string: "just now", "5m ago", "2h ago", "3d ago".
|
|
106
|
+
*/
|
|
107
|
+
export function timeAgo(epochMs) {
|
|
108
|
+
const seconds = Math.floor((Date.now() - epochMs) / 1000);
|
|
109
|
+
if (seconds < 60)
|
|
110
|
+
return "just now";
|
|
111
|
+
const minutes = Math.floor(seconds / 60);
|
|
112
|
+
if (minutes < 60)
|
|
113
|
+
return `${minutes}m ago`;
|
|
114
|
+
const hours = Math.floor(minutes / 60);
|
|
115
|
+
if (hours < 24)
|
|
116
|
+
return `${hours}h ago`;
|
|
117
|
+
const days = Math.floor(hours / 24);
|
|
118
|
+
return `${days}d ago`;
|
|
119
|
+
}
|
|
107
120
|
function truncateLines(lines, max) {
|
|
108
121
|
if (lines.length <= max) {
|
|
109
122
|
return lines.join("\n");
|
|
@@ -112,15 +125,6 @@ function truncateLines(lines, max) {
|
|
|
112
125
|
const remaining = lines.length - max;
|
|
113
126
|
return `${shown}\n... (${remaining} more lines)`;
|
|
114
127
|
}
|
|
115
|
-
/**
|
|
116
|
-
* Escape for Telegram MarkdownV2.
|
|
117
|
-
* Outside code blocks: all special chars. Inside: only backtick and backslash
|
|
118
|
-
* (other chars are literal inside pre blocks, escaping them adds visible backslashes).
|
|
119
|
-
*/
|
|
120
|
-
function escapeMarkdownV2(text, codeBlock = false) {
|
|
121
|
-
const pattern = codeBlock ? /[`\\]/g : /[_*\[\]()~`>#+\-=|{}.!\\]/g;
|
|
122
|
-
return text.replace(pattern, "\\$&");
|
|
123
|
-
}
|
|
124
128
|
/**
|
|
125
129
|
* Human-readable one-liner for a tool invocation (used in engine's canUseTool).
|
|
126
130
|
*/
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import log, { initLogger } from "./logger.js";
|
|
|
4
4
|
import { WorkspaceStore } from "./workspace-store.js";
|
|
5
5
|
import { ClaudeCodeEngine } from "./engine/claude-code.js";
|
|
6
6
|
import { TelegramChannel } from "./channel/telegram.js";
|
|
7
|
+
import { SlackChannel } from "./channel/slack.js";
|
|
7
8
|
import { Orchestrator } from "./orchestrator.js";
|
|
8
9
|
// The Claude Agent SDK has a bug: handleControlRequest() writes to the
|
|
9
10
|
// subprocess stdin without a .catch(), so aborting during a permission
|
|
@@ -23,8 +24,14 @@ process.on("unhandledRejection", (reason) => {
|
|
|
23
24
|
async function main() {
|
|
24
25
|
const config = loadConfig();
|
|
25
26
|
initLogger(config.logPath);
|
|
26
|
-
const workspaceStore = new WorkspaceStore(config.
|
|
27
|
-
|
|
27
|
+
const workspaceStore = new WorkspaceStore(config.workspaceStorePath);
|
|
28
|
+
let channel;
|
|
29
|
+
if (config.channel.type === "slack") {
|
|
30
|
+
channel = new SlackChannel(config.channel.botToken, config.channel.appToken, config.allowedUserIds);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
channel = new TelegramChannel(config.channel.botToken, config.allowedUserIds);
|
|
34
|
+
}
|
|
28
35
|
const engine = new ClaudeCodeEngine();
|
|
29
36
|
const orchestrator = new Orchestrator({
|
|
30
37
|
channel,
|
|
@@ -32,6 +39,7 @@ async function main() {
|
|
|
32
39
|
workspaceStore,
|
|
33
40
|
permissionMode: config.permissionMode,
|
|
34
41
|
defaultPromptPath: config.defaultPromptPath,
|
|
42
|
+
filesPath: config.filesPath,
|
|
35
43
|
});
|
|
36
44
|
await orchestrator.start();
|
|
37
45
|
}
|