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.
@@ -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
- if (!ctx.from?.id || !this.allowedUserIds.has(`tg:${ctx.from.id}`))
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
- const user = {
32
- id: `tg:${ctx.from.id}`,
33
- name: [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(" "),
34
- ...(ctx.from.username && { handle: ctx.from.username }),
35
- };
36
- this.emit("message", { chatId, user, text: ctx.message.text });
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 = buttons.map((btn, i) => ({
95
- btn,
96
- cbData: `${callbackId}:${i}`,
97
- }));
98
- // Build inline keyboard
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
- for (const { btn, cbData } of cbEntries) {
101
- keyboard.text(btn.label, cbData);
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 or timeout (30 minutes)
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
- if (!pressed)
126
- return { value: "" };
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, "Type your note:", {
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 Config {
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
- workspacesPath: string;
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
- const botToken = requireEnv("TELEGRAM_BOT_TOKEN");
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
- botToken,
38
+ channel,
25
39
  allowedUserIds,
26
40
  permissionMode,
27
- dataDir,
28
41
  defaultPromptPath: path.join(dataDir, "workspace", "CLAUDE.md"),
29
- workspacesPath: path.join(dataDir, "workspaces.json"),
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
- const { content } = msg.message;
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
- toolUseIdToName.set(block.id, block.name);
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
- const b = block;
95
- if (b.type === "tool_result" &&
96
- typeof b.tool_use_id === "string") {
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")
@@ -0,0 +1,3 @@
1
+ import type { Attachment } from "./types.js";
2
+ /** Save an attachment to disk for audit logging. Returns the saved file path. */
3
+ export declare function saveFile(att: Attachment, workspace: string, filesPath: string): Promise<string>;
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