zubo 0.1.28 → 0.1.29

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/README.md CHANGED
@@ -98,9 +98,10 @@ User Message
98
98
  All config lives in `~/.zubo/config.json`. Run `zubo setup` for interactive configuration, or set values directly:
99
99
 
100
100
  ```bash
101
- zubo config set activeProvider anthropic
102
- zubo config set smartRouting.enabled true
101
+ zubo config set activeProvider anthropic
102
+ zubo config set smartRouting.enabled true
103
103
  zubo config set budget.monthlyLimitUsd 50
104
+ zubo config set approvals.autoApproveFirstPartyTools true
104
105
  ```
105
106
 
106
107
  See the full [configuration reference](https://zubo.bot/docs/config.html) for all options.
@@ -167,6 +168,7 @@ Across WebChat, Telegram, Discord, Slack, and other channels:
167
168
  - `/permissions set <tool> <auto|confirm|deny>` — override tool permission
168
169
  - `/budget` — view budget usage and limits
169
170
  - `/budget pause|resume` — pause/resume budget enforcement
171
+ - `/sent [n]` — show latest outbound email delivery records
170
172
 
171
173
  ## Contributing
172
174
 
@@ -0,0 +1,18 @@
1
+ CREATE TABLE IF NOT EXISTS sent_messages (
2
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3
+ provider TEXT NOT NULL, -- smtp | gmail | email_channel
4
+ recipient TEXT NOT NULL,
5
+ subject TEXT NOT NULL,
6
+ body_preview TEXT,
7
+ attachments_json TEXT,
8
+ status TEXT NOT NULL, -- sent | failed
9
+ error_message TEXT,
10
+ external_id TEXT,
11
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
12
+ );
13
+
14
+ CREATE INDEX IF NOT EXISTS idx_sent_messages_created_at
15
+ ON sent_messages(created_at DESC);
16
+
17
+ CREATE INDEX IF NOT EXISTS idx_sent_messages_recipient
18
+ ON sent_messages(recipient);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zubo",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "Your AI agent that never forgets. Persistent memory, 25+ tools, 7 channels, 11+ LLM providers — runs entirely on your machine.",
5
5
  "license": "MIT",
6
6
  "author": "thomaskanze",
package/src/agent/loop.ts CHANGED
@@ -41,12 +41,33 @@ function resolveOptions(memoriesOrOptions: string | AgentLoopOptions): AgentLoop
41
41
  }
42
42
 
43
43
  /** Detect standalone greetings that don't need tool definitions in context. */
44
- function looksConversational(text: string): boolean {
44
+ function looksConversational(text: string): boolean {
45
45
  const t = text.trim().toLowerCase().replace(/[!?.,:;]+$/g, "");
46
46
  // Only match messages that are PURELY a greeting — no follow-up request
47
47
  const standaloneGreetings = /^(h(ello|i|ey|owdy|ola)|yo|sup|good\s*(morning|afternoon|evening|night)|what'?s\s*up|gm|thanks|thank\s*you|ok(ay)?|bye|see\s*ya|cool|nice|wow|lol|haha)$/;
48
- return standaloneGreetings.test(t);
49
- }
48
+ return standaloneGreetings.test(t);
49
+ }
50
+
51
+ export function hasEmailSendIntent(text: string): boolean {
52
+ const t = text.trim().toLowerCase();
53
+ if (!t) return false;
54
+ if (/\bdraft\b/.test(t) && !/\bsend\b/.test(t)) return false;
55
+ if (/\b(send|email|mail)\b/.test(t) && /\b(to|recipient)\b/.test(t)) return true;
56
+ if (/\b(write|compose)\b.*\b(email|mail)\b/.test(t)) return true;
57
+ return false;
58
+ }
59
+
60
+ export function canSendEmailWithTools(tools: any[]): boolean {
61
+ const names = new Set(tools.map((t) => t.name));
62
+ return names.has("email_send") || names.has("gmail");
63
+ }
64
+
65
+ function hasEmailSendCapability(allowedTools?: string[]): boolean {
66
+ const defs = getAllToolDefs();
67
+ if (!allowedTools) return canSendEmailWithTools(defs);
68
+ const allowed = new Set(allowedTools);
69
+ return canSendEmailWithTools(defs.filter((d) => allowed.has(d.name)));
70
+ }
50
71
 
51
72
  async function prepareLoop(
52
73
  llm: LlmProvider,
@@ -214,10 +235,12 @@ export async function agentLoop(
214
235
  memoriesOrOptions: string | AgentLoopOptions = ""
215
236
  ): Promise<LoopResult> {
216
237
  const options = resolveOptions(memoriesOrOptions);
217
- const maxRounds = options.maxRounds ?? MAX_TOOL_ROUNDS;
218
- const { system, messages, tools } = await prepareLoop(llm, sessionId, userMessage, options);
219
-
220
- let totalToolCalls = 0;
238
+ const maxRounds = options.maxRounds ?? MAX_TOOL_ROUNDS;
239
+ const { system, messages, tools } = await prepareLoop(llm, sessionId, userMessage, options);
240
+ const emailIntentLock = hasEmailSendIntent(userMessage) && canSendEmailWithTools(tools);
241
+ let emailIntentRetries = 0;
242
+
243
+ let totalToolCalls = 0;
221
244
 
222
245
  for (let round = 0; round < maxRounds; round++) {
223
246
  const llmStartTime = Date.now();
@@ -246,15 +269,40 @@ export async function agentLoop(
246
269
 
247
270
  const toolUseBlocks = extractToolUseBlocks(response.content);
248
271
 
249
- // No tool calls — done
250
- if (toolUseBlocks.length === 0) {
251
- const reply = response.content
252
- .filter((b) => b.type === "text")
253
- .map((b) => b.text ?? "")
254
- .join("\n") || "";
255
- finishLoop(sessionId, reply);
256
- triggerPostLoopCompaction(llm, sessionId);
257
- return { reply, toolCalls: totalToolCalls };
272
+ // No tool calls — done
273
+ if (toolUseBlocks.length === 0) {
274
+ const reply = response.content
275
+ .filter((b) => b.type === "text")
276
+ .map((b) => b.text ?? "")
277
+ .join("\n") || "";
278
+
279
+ if (emailIntentLock && totalToolCalls === 0 && emailIntentRetries < 1) {
280
+ emailIntentRetries += 1;
281
+ messages.push({ role: "assistant", content: response.content });
282
+ messages.push({
283
+ role: "user",
284
+ content: [{
285
+ type: "text",
286
+ text:
287
+ "Policy guard: The user asked you to SEND an email. You must execute an email tool now (email_send or gmail action send/reply). " +
288
+ "Do not return a draft-only response.",
289
+ }],
290
+ });
291
+ continue;
292
+ }
293
+
294
+ if (emailIntentLock && totalToolCalls === 0 && emailIntentRetries >= 1) {
295
+ const lockedReply =
296
+ "I couldn't complete that because no email send tool was executed. " +
297
+ "Please try again and I'll send it directly.";
298
+ finishLoop(sessionId, lockedReply);
299
+ triggerPostLoopCompaction(llm, sessionId);
300
+ return { reply: lockedReply, toolCalls: totalToolCalls };
301
+ }
302
+
303
+ finishLoop(sessionId, reply);
304
+ triggerPostLoopCompaction(llm, sessionId);
305
+ return { reply, toolCalls: totalToolCalls };
258
306
  }
259
307
 
260
308
  // Execute tools
@@ -272,18 +320,30 @@ export async function agentLoop(
272
320
  return { reply: MAX_ROUNDS_FALLBACK, toolCalls: totalToolCalls };
273
321
  }
274
322
 
275
- export async function agentLoopStream(
276
- llm: LlmProvider,
277
- sessionId: string,
278
- userMessage: string,
323
+ export async function agentLoopStream(
324
+ llm: LlmProvider,
325
+ sessionId: string,
326
+ userMessage: string,
279
327
  callbacks: StreamCallbacks,
280
328
  memoriesOrOptions: string | AgentLoopOptions = ""
281
- ): Promise<void> {
282
- const options = resolveOptions(memoriesOrOptions);
283
- const maxRounds = options.maxRounds ?? MAX_TOOL_ROUNDS;
284
-
285
- // Fall back to non-streaming if provider doesn't support it
286
- if (!llm.chatStream) {
329
+ ): Promise<void> {
330
+ const options = resolveOptions(memoriesOrOptions);
331
+ const maxRounds = options.maxRounds ?? MAX_TOOL_ROUNDS;
332
+ const emailIntentLock = hasEmailSendIntent(userMessage) && hasEmailSendCapability(options.allowedTools);
333
+
334
+ if (emailIntentLock) {
335
+ try {
336
+ const result = await agentLoop(llm, sessionId, userMessage, options);
337
+ callbacks.onTextDelta(result.reply);
338
+ callbacks.onDone(result);
339
+ } catch (err: any) {
340
+ callbacks.onError(err);
341
+ }
342
+ return;
343
+ }
344
+
345
+ // Fall back to non-streaming if provider doesn't support it
346
+ if (!llm.chatStream) {
287
347
  try {
288
348
  const result = await agentLoop(llm, sessionId, userMessage, memoriesOrOptions);
289
349
  callbacks.onTextDelta(result.reply);
@@ -294,10 +354,10 @@ export async function agentLoopStream(
294
354
  return;
295
355
  }
296
356
 
297
- try {
298
- const { system, messages, tools } = await prepareLoop(llm, sessionId, userMessage, options);
299
-
300
- let totalToolCalls = 0;
357
+ try {
358
+ const { system, messages, tools } = await prepareLoop(llm, sessionId, userMessage, options);
359
+
360
+ let totalToolCalls = 0;
301
361
  let fullReply = "";
302
362
 
303
363
  for (let round = 0; round < maxRounds; round++) {
@@ -55,6 +55,7 @@ const DEFAULT_PERSONALITY = `You are Zubo, a personal AI agent. You are friendly
55
55
 
56
56
  - If the user asks you to send/write an email to someone, you must actually send it using a tool ("email_send" or "gmail"). Do not only draft text unless the user explicitly asks for a draft.
57
57
  - If required fields are missing, ask only for what's missing ("to", "subject", or "body") and send immediately once provided.
58
+ - When files are mentioned for an outgoing email, include them via the email tool's attachments input (workspace paths or upload IDs like "upload:123").
58
59
 
59
60
  ## Preferences
60
61
 
@@ -1,8 +1,11 @@
1
- import { connect, type Socket } from "net";
2
- import * as tls from "tls";
3
- import type { ChannelAdapter, InboundMessage } from "./adapter";
4
- import type { MessageRouter } from "./router";
5
- import { logger } from "../util/logger";
1
+ import { connect, type Socket } from "net";
2
+ import * as tls from "tls";
3
+ import { readFileSync, statSync } from "fs";
4
+ import { basename } from "path";
5
+ import type { ChannelAdapter, InboundMessage } from "./adapter";
6
+ import type { MessageRouter } from "./router";
7
+ import { logSentMessage } from "../email/sent-log";
8
+ import { logger } from "../util/logger";
6
9
 
7
10
  export interface EmailConfig {
8
11
  enabled: boolean;
@@ -192,8 +195,19 @@ class SmtpClient {
192
195
 
193
196
  constructor(private config: EmailConfig["smtp"], private fromName?: string) {}
194
197
 
198
+ private repairMojibake(value: string): string {
199
+ if (!/[ÃÂ]/.test(value)) return value;
200
+ try {
201
+ const repaired = Buffer.from(value, "latin1").toString("utf8");
202
+ return repaired.includes("�") ? value : repaired;
203
+ } catch {
204
+ return value;
205
+ }
206
+ }
207
+
195
208
  private encodeMimeHeader(value: string): string {
196
- const sanitized = value.replace(/[\r\n]+/g, " ").trim();
209
+ const repaired = this.repairMojibake(value);
210
+ const sanitized = repaired.replace(/[\r\n]+/g, " ").trim();
197
211
  if (!sanitized) return "";
198
212
  // RFC 2047 encoded-word for non-ASCII header values (emoji, accents, etc.).
199
213
  if (/^[\x00-\x7F]*$/.test(sanitized)) return sanitized;
@@ -201,12 +215,78 @@ class SmtpClient {
201
215
  }
202
216
 
203
217
  private toCrlfBody(body: string): string {
204
- const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
218
+ const repaired = this.repairMojibake(body);
219
+ const normalized = repaired.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
205
220
  return normalized
206
221
  .split("\n")
207
222
  .map((line) => (line.startsWith(".") ? `.${line}` : line))
208
223
  .join("\r\n");
209
224
  }
225
+
226
+ private buildMimeMessage(
227
+ fromAddr: string,
228
+ to: string,
229
+ subject: string,
230
+ body: string,
231
+ attachments: string[] = [],
232
+ ): string {
233
+ const displayName = this.fromName || "Zubo";
234
+ const encodedFromName = this.encodeMimeHeader(displayName);
235
+ const encodedSubject = this.encodeMimeHeader(subject);
236
+ const safeBody = this.toCrlfBody(body || "");
237
+ if (!attachments.length) {
238
+ return [
239
+ `From: ${encodedFromName} <${fromAddr}>`,
240
+ `To: ${to}`,
241
+ `Subject: ${encodedSubject}`,
242
+ `Date: ${new Date().toUTCString()}`,
243
+ `MIME-Version: 1.0`,
244
+ `Content-Type: text/plain; charset=utf-8`,
245
+ `Content-Transfer-Encoding: 8bit`,
246
+ ``,
247
+ safeBody,
248
+ ].join("\r\n");
249
+ }
250
+
251
+ const boundary = `zubo-${crypto.randomUUID()}`;
252
+ const lines: string[] = [
253
+ `From: ${encodedFromName} <${fromAddr}>`,
254
+ `To: ${to}`,
255
+ `Subject: ${encodedSubject}`,
256
+ `Date: ${new Date().toUTCString()}`,
257
+ `MIME-Version: 1.0`,
258
+ `Content-Type: multipart/mixed; boundary="${boundary}"`,
259
+ ``,
260
+ `--${boundary}`,
261
+ `Content-Type: text/plain; charset=utf-8`,
262
+ `Content-Transfer-Encoding: 8bit`,
263
+ ``,
264
+ safeBody,
265
+ ``,
266
+ ];
267
+
268
+ for (const attachmentPath of attachments) {
269
+ const size = statSync(attachmentPath).size;
270
+ if (size > 10 * 1024 * 1024) {
271
+ throw new Error(`Attachment too large (>10MB): ${attachmentPath}`);
272
+ }
273
+ const filename = basename(attachmentPath);
274
+ const encodedFilename = this.encodeMimeHeader(filename);
275
+ const content = readFileSync(attachmentPath);
276
+ const b64 = content.toString("base64").replace(/(.{76})/g, "$1\r\n");
277
+ lines.push(
278
+ `--${boundary}`,
279
+ `Content-Type: application/octet-stream; name="${encodedFilename}"`,
280
+ `Content-Disposition: attachment; filename="${encodedFilename}"`,
281
+ `Content-Transfer-Encoding: base64`,
282
+ ``,
283
+ b64,
284
+ ``,
285
+ );
286
+ }
287
+ lines.push(`--${boundary}--`);
288
+ return lines.join("\r\n");
289
+ }
210
290
 
211
291
  private async connectRaw(): Promise<Socket | tls.TLSSocket> {
212
292
  return new Promise((resolve, reject) => {
@@ -253,7 +333,7 @@ class SmtpClient {
253
333
  });
254
334
  }
255
335
 
256
- async sendEmail(to: string, subject: string, body: string, from?: string): Promise<void> {
336
+ async sendEmail(to: string, subject: string, body: string, from?: string, attachments: string[] = []): Promise<void> {
257
337
  const rawSocket = await this.connectRaw();
258
338
  let socket: Socket | tls.TLSSocket = rawSocket;
259
339
 
@@ -297,22 +377,8 @@ class SmtpClient {
297
377
  // DATA
298
378
  await this.sendCmd(socket, "DATA");
299
379
 
300
- // Message content — write directly to socket, not via sendCmd
301
- const displayName = this.fromName || "Zubo";
302
- const encodedFromName = this.encodeMimeHeader(displayName);
303
- const encodedSubject = this.encodeMimeHeader(subject);
304
- const safeBody = this.toCrlfBody(body || "");
305
- const message = [
306
- `From: ${encodedFromName} <${fromAddr}>`,
307
- `To: ${to}`,
308
- `Subject: ${encodedSubject}`,
309
- `Date: ${new Date().toUTCString()}`,
310
- `MIME-Version: 1.0`,
311
- `Content-Type: text/plain; charset=utf-8`,
312
- `Content-Transfer-Encoding: 8bit`,
313
- ``,
314
- safeBody,
315
- ].join("\r\n");
380
+ // Message content — write directly to socket, not via sendCmd
381
+ const message = this.buildMimeMessage(fromAddr, to, subject, body, attachments);
316
382
 
317
383
  // Send body then terminator, wait for 250 OK
318
384
  await new Promise<void>((resolve, reject) => {
@@ -325,13 +391,21 @@ class SmtpClient {
325
391
  });
326
392
 
327
393
  // QUIT
328
- try {
329
- await this.sendCmd(socket, "QUIT");
330
- } catch {}
331
- } finally {
332
- socket.destroy();
333
- }
334
- }
394
+ try {
395
+ await this.sendCmd(socket, "QUIT");
396
+ } catch {}
397
+ logSentMessage({
398
+ provider: "smtp",
399
+ recipient: to,
400
+ subject,
401
+ body,
402
+ attachments,
403
+ status: "sent",
404
+ });
405
+ } finally {
406
+ socket.destroy();
407
+ }
408
+ }
335
409
  }
336
410
 
337
411
  export async function sendSmtpEmail(
@@ -341,9 +415,23 @@ export async function sendSmtpEmail(
341
415
  body: string,
342
416
  fromName?: string,
343
417
  from?: string,
418
+ attachments: string[] = [],
344
419
  ): Promise<void> {
345
420
  const smtp = new SmtpClient(config, fromName);
346
- await smtp.sendEmail(to, subject, body, from);
421
+ try {
422
+ await smtp.sendEmail(to, subject, body, from, attachments);
423
+ } catch (err: any) {
424
+ logSentMessage({
425
+ provider: "smtp",
426
+ recipient: to,
427
+ subject,
428
+ body,
429
+ attachments,
430
+ status: "failed",
431
+ errorMessage: err?.message ?? String(err),
432
+ });
433
+ throw err;
434
+ }
347
435
  }
348
436
 
349
437
  // --- Email channel adapter ---
@@ -419,13 +507,21 @@ export function createEmailAdapter(
419
507
  ? (msg.subject.startsWith("Re:") ? msg.subject : `Re: ${msg.subject}`)
420
508
  : "Re: your message";
421
509
 
422
- try {
423
- await smtp.sendEmail(senderEmail, replySubject, reply);
424
- logger.info(`Email: replied to ${senderEmail}`);
425
- } catch (err: any) {
426
- logger.error("Email: failed to send reply", { error: err.message, to: senderEmail });
427
- }
428
- });
510
+ try {
511
+ await smtp.sendEmail(senderEmail, replySubject, reply);
512
+ logger.info(`Email: replied to ${senderEmail}`);
513
+ } catch (err: any) {
514
+ logger.error("Email: failed to send reply", { error: err.message, to: senderEmail });
515
+ logSentMessage({
516
+ provider: "email_channel",
517
+ recipient: senderEmail,
518
+ subject: replySubject,
519
+ body: reply,
520
+ status: "failed",
521
+ errorMessage: err?.message ?? String(err),
522
+ });
523
+ }
524
+ });
429
525
 
430
526
  await imap.markSeen(uid);
431
527
  } catch (err: any) {
@@ -470,12 +566,20 @@ export function createEmailAdapter(
470
566
  return;
471
567
  }
472
568
 
473
- try {
474
- await smtp.sendEmail(to, "Message from Zubo", text);
475
- logger.info(`Email: sent proactive message to ${to}`);
476
- } catch (err: any) {
477
- logger.error("Email: failed to send proactive message", { error: err.message, to });
478
- }
569
+ try {
570
+ await smtp.sendEmail(to, "Message from Zubo", text);
571
+ logger.info(`Email: sent proactive message to ${to}`);
572
+ } catch (err: any) {
573
+ logger.error("Email: failed to send proactive message", { error: err.message, to });
574
+ logSentMessage({
575
+ provider: "email_channel",
576
+ recipient: to,
577
+ subject: "Message from Zubo",
578
+ body: text,
579
+ status: "failed",
580
+ errorMessage: err?.message ?? String(err),
581
+ });
582
+ }
479
583
  },
480
584
  };
481
585
  }
@@ -223,6 +223,7 @@ export function createRouter(
223
223
  "/permissions set <tool> <auto|confirm|deny>\n" +
224
224
  "/budget\n" +
225
225
  "/budget pause|resume\n" +
226
+ "/sent [n]\n" +
226
227
  "\n" +
227
228
  "Docs: https://zubo.bot/docs/";
228
229
 
@@ -313,6 +314,31 @@ export function createRouter(
313
314
  `monthly: $${monthly.total.toFixed(4)} / ${row.monthly_limit_usd ? `$${row.monthly_limit_usd.toFixed(2)}` : "unlimited"}`
314
315
  );
315
316
  }
317
+ if (command.name === "sent") {
318
+ const limitRaw = Number(command.args.trim() || "10");
319
+ const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(50, Math.floor(limitRaw))) : 10;
320
+ try {
321
+ const rows = db.query(
322
+ "SELECT provider, recipient, subject, status, error_message, created_at FROM sent_messages ORDER BY id DESC LIMIT ?"
323
+ ).all(limit) as Array<{
324
+ provider: string;
325
+ recipient: string;
326
+ subject: string;
327
+ status: string;
328
+ error_message: string | null;
329
+ created_at: string;
330
+ }>;
331
+ if (!rows.length) return "No sent email records yet.";
332
+ return rows
333
+ .map((r, i) =>
334
+ `[${i + 1}] ${r.created_at} ${r.status.toUpperCase()} via ${r.provider} -> ${r.recipient}\n` +
335
+ `Subject: ${r.subject}${r.error_message ? `\nError: ${r.error_message}` : ""}`
336
+ )
337
+ .join("\n\n");
338
+ } catch {
339
+ return "Sent log is not available yet. Restart Zubo to apply latest DB migrations.";
340
+ }
341
+ }
316
342
  return null;
317
343
  }
318
344
 
@@ -360,12 +360,29 @@ async function handleDashboardApi(url: URL, req: Request): Promise<Response | nu
360
360
  return Response.json({ content: lines.slice(-100).join("\n") });
361
361
  }
362
362
 
363
- // GET /api/dashboard/skills
364
- if (path === "/skills" && req.method === "GET") {
365
- return Response.json({ skills: getSkillsData() });
366
- }
367
-
368
- // ── TODOS ──
363
+ // GET /api/dashboard/skills
364
+ if (path === "/skills" && req.method === "GET") {
365
+ return Response.json({ skills: getSkillsData() });
366
+ }
367
+
368
+ // GET /api/dashboard/sent-messages?limit=20
369
+ if (path === "/sent-messages" && req.method === "GET") {
370
+ const db = getDb();
371
+ const limitRaw = Number(url.searchParams.get("limit") || "20");
372
+ const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(100, Math.floor(limitRaw))) : 20;
373
+ try {
374
+ const rows = db.query(
375
+ `SELECT id, provider, recipient, subject, body_preview, attachments_json, status, error_message, external_id, created_at
376
+ FROM sent_messages
377
+ ORDER BY id DESC LIMIT ?`
378
+ ).all(limit);
379
+ return Response.json({ rows });
380
+ } catch (err: any) {
381
+ return Response.json({ rows: [], error: err.message });
382
+ }
383
+ }
384
+
385
+ // ── TODOS ──
369
386
 
370
387
  // GET /api/dashboard/todos?filter=pending|done|all
371
388
  if (path === "/todos" && req.method === "GET") {
@@ -144,6 +144,12 @@ export const configSchema = z.object({
144
144
  // Tool permission overrides
145
145
  toolPermissions: z.record(z.string(), z.enum(["auto", "confirm", "deny"])).optional(),
146
146
 
147
+ // Approval behavior
148
+ approvals: z.object({
149
+ // If true, built-in first-party tools never require secondary confirmation prompts.
150
+ autoApproveFirstPartyTools: z.boolean().default(false),
151
+ }).optional(),
152
+
147
153
  // Memory retrieval tuning for router context injection
148
154
  memoryRetrieval: z.object({
149
155
  contextTopK: z.number().int().min(1).max(10).default(3),
@@ -0,0 +1,37 @@
1
+ import { getDb } from "../db/connection";
2
+ import { logger } from "../util/logger";
3
+
4
+ export interface SentMessageLogEntry {
5
+ provider: string;
6
+ recipient: string;
7
+ subject: string;
8
+ body?: string;
9
+ attachments?: string[];
10
+ status: "sent" | "failed";
11
+ errorMessage?: string;
12
+ externalId?: string;
13
+ }
14
+
15
+ export function logSentMessage(entry: SentMessageLogEntry): void {
16
+ try {
17
+ const db = getDb();
18
+ const preview = (entry.body ?? "").slice(0, 500);
19
+ const attachmentsJson = entry.attachments?.length ? JSON.stringify(entry.attachments) : null;
20
+ db.prepare(
21
+ `INSERT INTO sent_messages
22
+ (provider, recipient, subject, body_preview, attachments_json, status, error_message, external_id)
23
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
24
+ ).run(
25
+ entry.provider,
26
+ entry.recipient,
27
+ entry.subject,
28
+ preview || null,
29
+ attachmentsJson,
30
+ entry.status,
31
+ entry.errorMessage ?? null,
32
+ entry.externalId ?? null,
33
+ );
34
+ } catch (err: any) {
35
+ logger.warn("Failed to log sent message", { error: err?.message ?? String(err) });
36
+ }
37
+ }
@@ -1,6 +1,8 @@
1
- import { readFileSync } from "fs";
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { resolve } from "path";
2
3
  import { registerTool } from "../registry";
3
4
  import { paths } from "../../config/paths";
5
+ import { getDb } from "../../db/connection";
4
6
  import { sendSmtpEmail, type EmailConfig } from "../../channels/email";
5
7
 
6
8
  function isValidEmail(value: string): boolean {
@@ -8,6 +10,30 @@ function isValidEmail(value: string): boolean {
8
10
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed);
9
11
  }
10
12
 
13
+ function resolveAttachmentRef(ref: string): string | null {
14
+ const trimmed = ref.trim();
15
+ if (!trimmed) return null;
16
+ if (trimmed.startsWith("upload:")) {
17
+ const id = Number(trimmed.slice("upload:".length));
18
+ if (!Number.isFinite(id) || id <= 0) return null;
19
+ try {
20
+ const db = getDb();
21
+ const row = db.query("SELECT filename FROM uploads WHERE id = ?").get(id) as { filename: string } | null;
22
+ if (!row?.filename) return null;
23
+ return row.filename;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+ return trimmed;
29
+ }
30
+
31
+ function isAllowedAttachmentPath(filePath: string): boolean {
32
+ const absolute = resolve(filePath);
33
+ const roots = [resolve(process.cwd()), resolve(paths.uploads)];
34
+ return roots.some((root) => absolute.startsWith(root + "/") || absolute === root);
35
+ }
36
+
11
37
  export function registerEmailSendTool(): void {
12
38
  registerTool({
13
39
  definition: {
@@ -21,6 +47,12 @@ export function registerEmailSendTool(): void {
21
47
  to: { type: "string", description: "Recipient email address" },
22
48
  subject: { type: "string", description: "Email subject" },
23
49
  body: { type: "string", description: "Email body content" },
50
+ attachments: {
51
+ type: "array",
52
+ items: { type: "string" },
53
+ description:
54
+ "Optional attachment references. Use absolute/relative file paths in workspace, or upload IDs via `upload:<id>`.",
55
+ },
24
56
  },
25
57
  required: ["to", "subject", "body"],
26
58
  },
@@ -29,6 +61,9 @@ export function registerEmailSendTool(): void {
29
61
  const to = String(input.to ?? "").trim();
30
62
  const subject = String(input.subject ?? "").trim();
31
63
  const body = String(input.body ?? "");
64
+ const attachmentRefs = Array.isArray(input.attachments)
65
+ ? input.attachments.map((x) => String(x))
66
+ : [];
32
67
 
33
68
  if (!to || !isValidEmail(to)) {
34
69
  return "Invalid recipient email address. Please provide a valid `to` address.";
@@ -52,9 +87,23 @@ export function registerEmailSendTool(): void {
52
87
  );
53
88
  }
54
89
 
90
+ const attachments: string[] = [];
91
+ for (const ref of attachmentRefs) {
92
+ const resolvedRef = resolveAttachmentRef(ref);
93
+ if (!resolvedRef) return `Invalid attachment reference: ${ref}`;
94
+ const absolute = resolve(resolvedRef);
95
+ if (!existsSync(absolute)) return `Attachment not found: ${ref}`;
96
+ if (!isAllowedAttachmentPath(absolute)) {
97
+ return `Attachment path is outside allowed roots (workspace/uploads): ${ref}`;
98
+ }
99
+ attachments.push(absolute);
100
+ }
101
+
55
102
  try {
56
- await sendSmtpEmail(smtpCfg, to, subject, body, emailCfg?.fromName);
57
- return `Email sent to ${to} with subject "${subject}".`;
103
+ await sendSmtpEmail(smtpCfg, to, subject, body, emailCfg?.fromName, undefined, attachments);
104
+ return attachments.length
105
+ ? `Email sent to ${to} with ${attachments.length} attachment(s).`
106
+ : `Email sent to ${to} with subject "${subject}".`;
58
107
  } catch (err: any) {
59
108
  return `Failed to send email: ${err?.message ?? String(err)}`;
60
109
  }
@@ -1,7 +1,18 @@
1
+ import { logSentMessage } from "../../../../email/sent-log";
1
2
  const API = "https://gmail.googleapis.com/gmail/v1/users/me";
2
3
 
3
4
  function encodeMimeHeader(value: string): string {
4
- const sanitized = String(value || "").replace(/[\r\n]+/g, " ").trim();
5
+ const raw = String(value || "");
6
+ let repaired = raw;
7
+ if (/[ÃÂ]/.test(raw)) {
8
+ try {
9
+ const candidate = Buffer.from(raw, "latin1").toString("utf8");
10
+ repaired = candidate.includes("�") ? raw : candidate;
11
+ } catch {
12
+ repaired = raw;
13
+ }
14
+ }
15
+ const sanitized = repaired.replace(/[\r\n]+/g, " ").trim();
5
16
  if (!sanitized) return "";
6
17
  if (/^[\x00-\x7F]*$/.test(sanitized)) return sanitized;
7
18
  return `=?UTF-8?B?${Buffer.from(sanitized, "utf8").toString("base64")}?=`;
@@ -98,11 +109,30 @@ export default async function (input: Record<string, unknown>): Promise<string>
98
109
  const res = await fetch(`${API}/messages/send`, {
99
110
  method: "POST", headers,
100
111
  body: JSON.stringify({ raw }),
101
- });
102
- if (!res.ok) return await safeApiErr(res, "Gmail");
103
- const data = (await res.json()) as any;
104
- return JSON.stringify({ sent: true, id: data.id });
105
- }
112
+ });
113
+ if (!res.ok) {
114
+ const error = await safeApiErr(res, "Gmail");
115
+ logSentMessage({
116
+ provider: "gmail",
117
+ recipient: to,
118
+ subject,
119
+ body: body || "",
120
+ status: "failed",
121
+ errorMessage: error,
122
+ });
123
+ return error;
124
+ }
125
+ const data = (await res.json()) as any;
126
+ logSentMessage({
127
+ provider: "gmail",
128
+ recipient: to,
129
+ subject,
130
+ body: body || "",
131
+ status: "sent",
132
+ externalId: data.id,
133
+ });
134
+ return JSON.stringify({ sent: true, id: data.id });
135
+ }
106
136
  case "search": {
107
137
  if (!query) return JSON.stringify({ error: "query is required" });
108
138
  const res = await fetch(`${API}/messages?q=${encodeURIComponent(query)}&maxResults=${max_results || 10}`, { headers });
@@ -126,16 +156,45 @@ export default async function (input: Record<string, unknown>): Promise<string>
126
156
  const res = await fetch(`${API}/messages/send`, {
127
157
  method: "POST", headers,
128
158
  body: JSON.stringify({ raw, threadId: origData.threadId }),
129
- });
130
- if (!res.ok) return await safeApiErr(res, "Gmail");
131
- const data = (await res.json()) as any;
132
- return JSON.stringify({ replied: true, id: data.id });
133
- }
134
- default:
135
- return JSON.stringify({ error: `Unknown action: ${action}` });
136
- }
137
- } catch (err: any) {
138
- console.error(`[Gmail] Request failed: ${err.message}`);
139
- return JSON.stringify({ error: "Gmail request failed. Check logs for details." });
140
- }
141
- }
159
+ });
160
+ if (!res.ok) {
161
+ const error = await safeApiErr(res, "Gmail");
162
+ logSentMessage({
163
+ provider: "gmail",
164
+ recipient: replyTo,
165
+ subject: subj,
166
+ body,
167
+ status: "failed",
168
+ errorMessage: error,
169
+ });
170
+ return error;
171
+ }
172
+ const data = (await res.json()) as any;
173
+ logSentMessage({
174
+ provider: "gmail",
175
+ recipient: replyTo,
176
+ subject: subj,
177
+ body,
178
+ status: "sent",
179
+ externalId: data.id,
180
+ });
181
+ return JSON.stringify({ replied: true, id: data.id });
182
+ }
183
+ default:
184
+ return JSON.stringify({ error: `Unknown action: ${action}` });
185
+ }
186
+ } catch (err: any) {
187
+ console.error(`[Gmail] Request failed: ${err.message}`);
188
+ if (action === "send" || action === "reply") {
189
+ logSentMessage({
190
+ provider: "gmail",
191
+ recipient: String(to || ""),
192
+ subject: String(subject || ""),
193
+ body: body || "",
194
+ status: "failed",
195
+ errorMessage: err?.message ?? String(err),
196
+ });
197
+ }
198
+ return JSON.stringify({ error: "Gmail request failed. Check logs for details." });
199
+ }
200
+ }
@@ -1,4 +1,4 @@
1
- import { getTool } from "./registry";
1
+ import { getTool, isUserInstalledSkill } from "./registry";
2
2
  import { getToolPermission, getToolScopes, hasRiskyScope } from "./permissions";
3
3
  import { logger } from "../util/logger";
4
4
  import { recordError } from "../util/error-buffer";
@@ -21,6 +21,7 @@ const BUILTIN_INTEGRATION_TOOLS = new Set([
21
21
  interface ToolScopePolicy {
22
22
  allowed?: string[];
23
23
  dryRunByDefault: boolean;
24
+ autoApproveFirstPartyTools: boolean;
24
25
  }
25
26
 
26
27
  const toolScopePolicyCache: {
@@ -28,7 +29,7 @@ const toolScopePolicyCache: {
28
29
  value: ToolScopePolicy;
29
30
  } = {
30
31
  mtimeMs: -1,
31
- value: { dryRunByDefault: false },
32
+ value: { dryRunByDefault: false, autoApproveFirstPartyTools: false },
32
33
  };
33
34
 
34
35
  export interface ToolResult {
@@ -77,14 +78,20 @@ function readToolScopePolicy(): ToolScopePolicy {
77
78
  const parsed = {
78
79
  allowed: Array.isArray(cfg?.toolScopes?.allowed) ? cfg.toolScopes.allowed : undefined,
79
80
  dryRunByDefault: Boolean(cfg?.toolScopes?.dryRunByDefault),
81
+ autoApproveFirstPartyTools: cfg?.approvals?.autoApproveFirstPartyTools === true,
80
82
  };
81
83
  toolScopePolicyCache.mtimeMs = mtimeMs;
82
84
  toolScopePolicyCache.value = parsed;
83
85
  return parsed;
84
86
  } catch {
85
- return { dryRunByDefault: false };
87
+ return { dryRunByDefault: false, autoApproveFirstPartyTools: false };
86
88
  }
87
89
  }
90
+
91
+ function isFirstPartyTool(name: string): boolean {
92
+ if (name.includes("__")) return false; // MCP tools
93
+ return !isUserInstalledSkill(name);
94
+ }
88
95
 
89
96
  // Determine if a tool should run in the sandbox (user-installed skills only)
90
97
  async function shouldSandbox(
@@ -235,6 +242,9 @@ export async function executeTool(
235
242
  }
236
243
 
237
244
  if (permission === "confirm") {
245
+ if (scopePolicy.autoApproveFirstPartyTools && isFirstPartyTool(name)) {
246
+ logger.info(`Auto-approving first-party tool: ${name}`);
247
+ } else
238
248
  // For direct user requests, don't require a second explicit approval round.
239
249
  if (options.directUserRequest) {
240
250
  logger.info(`Bypassing confirmation for direct user request: ${name}`);
@@ -17,7 +17,8 @@ export type ToolScope =
17
17
 
18
18
  const DEFAULT_PERMISSIONS: Record<string, ToolPermission> = {
19
19
  // Built-in tools — always safe
20
- datetime: "auto",
20
+ datetime: "auto",
21
+ get_current_datetime: "auto",
21
22
  memory_write: "auto",
22
23
  memory_search: "auto",
23
24
  memory_prune: "confirm",
@@ -121,6 +122,7 @@ function getPermissionOverrides(): Record<string, ToolPermission> {
121
122
 
122
123
  const TOOL_SCOPES: Record<string, ToolScope[]> = {
123
124
  datetime: ["memory"],
125
+ get_current_datetime: ["memory"],
124
126
  memory_write: ["memory"],
125
127
  memory_search: ["memory"],
126
128
  memory_prune: ["memory"],