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 +4 -2
- package/migrations/025_sent_messages.sql +18 -0
- package/package.json +1 -1
- package/src/agent/loop.ts +90 -30
- package/src/agent/prompts.ts +1 -0
- package/src/channels/email.ts +149 -45
- package/src/channels/router.ts +26 -0
- package/src/channels/webchat.ts +23 -6
- package/src/config/schema.ts +6 -0
- package/src/email/sent-log.ts +37 -0
- package/src/tools/builtin/email-send.ts +52 -3
- package/src/tools/builtin-integrations/google/gmail/handler.ts +78 -19
- package/src/tools/executor.ts +13 -3
- package/src/tools/permissions.ts +3 -1
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
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
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
286
|
-
if (
|
|
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++) {
|
package/src/agent/prompts.ts
CHANGED
|
@@ -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
|
|
package/src/channels/email.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { connect, type Socket } from "net";
|
|
2
|
-
import * as tls from "tls";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/channels/router.ts
CHANGED
|
@@ -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
|
|
package/src/channels/webchat.ts
CHANGED
|
@@ -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
|
-
//
|
|
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") {
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
|
103
|
-
|
|
104
|
-
|
|
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)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
}
|
package/src/tools/executor.ts
CHANGED
|
@@ -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}`);
|
package/src/tools/permissions.ts
CHANGED
|
@@ -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"],
|