@expertcustom/mcp-chatbot-core 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
+ import { logger } from './src/lib/logger.js'
5
+ import { closeRedis } from './src/lib/redis.js'
6
+ import { sendMessageToWhatsApp } from './src/tools/sendMessageToWhatsApp.js'
7
+ import { createScheduledTask } from './src/tools/createScheduledTask.js'
8
+
9
+ const server = new McpServer({
10
+ name: 'mcp-chatbot-core',
11
+ version: '1.1.0',
12
+ })
13
+
14
+ const tools = [
15
+ sendMessageToWhatsApp,
16
+ createScheduledTask,
17
+ ]
18
+
19
+ for (const tool of tools) {
20
+ server.tool(tool.name, tool.description, tool.inputSchema, tool.handler)
21
+ }
22
+
23
+ const transport = new StdioServerTransport()
24
+
25
+ let shuttingDown = false
26
+ async function shutdown(signal) {
27
+ if (shuttingDown) return
28
+ shuttingDown = true
29
+ logger.info('shutdown_started', { signal })
30
+ try {
31
+ await server.close().catch((err) => logger.warn('server_close_error', { err: err.message }))
32
+ await closeRedis()
33
+ } finally {
34
+ logger.info('shutdown_complete', { signal })
35
+ process.exit(0)
36
+ }
37
+ }
38
+
39
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
40
+ process.on('SIGINT', () => shutdown('SIGINT'))
41
+
42
+ process.on('unhandledRejection', (reason) => {
43
+ logger.error('unhandled_rejection', { reason: String(reason) })
44
+ })
45
+ process.on('uncaughtException', (err) => {
46
+ logger.error('uncaught_exception', { err: err.message, stack: err.stack })
47
+ process.exit(1)
48
+ })
49
+
50
+ await server.connect(transport)
51
+ logger.info('mcp_ready', { tools: tools.map((t) => t.name) })
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@expertcustom/mcp-chatbot-core",
3
+ "version": "1.2.0",
4
+ "description": "MCP server com as tools core do Aurora: memoria temporaria, WhatsApp via WAHA, detector de reset de contexto e criacao de agendamentos",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "bin": {
9
+ "mcp-chatbot-core": "index.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "src"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "build": "chmod +x index.js",
23
+ "prepublishOnly": "npm run build",
24
+ "start": "node index.js",
25
+ "dev": "node --watch index.js"
26
+ },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.12.1",
29
+ "axios": "^1.7.0",
30
+ "redis": "^4.7.0",
31
+ "zod": "^3.24.0"
32
+ }
33
+ }
package/src/config.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Config do MCP. Le env vars uma vez no boot, valida tipos basicos e
3
+ * congela o objeto pra impedir mutacao acidental em tempo de runtime.
4
+ */
5
+
6
+ function requireString(name, fallback) {
7
+ const v = process.env[name]
8
+ if (typeof v === 'string' && v.length > 0) return v
9
+ if (fallback !== undefined) return fallback
10
+ throw new Error(`[CONFIG] env ${name} obrigatoria nao definida`)
11
+ }
12
+
13
+ function readInt(name, fallback) {
14
+ const v = process.env[name]
15
+ if (v === undefined || v === '') return fallback
16
+ const n = Number(v)
17
+ if (!Number.isFinite(n)) {
18
+ throw new Error(`[CONFIG] env ${name} deve ser numero, recebido: ${v}`)
19
+ }
20
+ return n
21
+ }
22
+
23
+ export const config = Object.freeze({
24
+ redis: {
25
+ url: requireString('REDIS_URL', 'redis://localhost:6379'),
26
+ // Timeout curto pra falhar rapido se Redis estiver indisponivel — o
27
+ // MCP nao deve segurar a IA por 30s esperando.
28
+ connectTimeoutMs: readInt('REDIS_CONNECT_TIMEOUT_MS', 3000),
29
+ commandTimeoutMs: readInt('REDIS_COMMAND_TIMEOUT_MS', 2000),
30
+ },
31
+ aurora: {
32
+ baseUrl: requireString('AURORA_BASE_URL', 'http://localhost:3334'),
33
+ timeoutMs: readInt('AURORA_TIMEOUT_MS', 10000),
34
+ },
35
+ waha: {
36
+ baseUrl: requireString('WAHA_BASE_URL', 'http://localhost:3000'),
37
+ session: requireString('WAHA_SESSION', 'default'),
38
+ apiKey: process.env.WAHA_API_KEY || '',
39
+ timeoutMs: readInt('WAHA_TIMEOUT_MS', 10000),
40
+ },
41
+ // Limites de payload pra evitar abuso/JSON-bomb via tool args.
42
+ limits: {
43
+ keyMaxLength: readInt('TEMP_KEY_MAX_LENGTH', 128),
44
+ dataMaxBytes: readInt('TEMP_DATA_MAX_BYTES', 64 * 1024), // 64 KB
45
+ ttlMaxSeconds: readInt('TEMP_TTL_MAX_SECONDS', 24 * 60 * 60), // 24h
46
+ ttlDefaultSeconds: readInt('TEMP_TTL_DEFAULT_SECONDS', 3600), // 1h
47
+ messageMaxLength: readInt('WAHA_MESSAGE_MAX_LENGTH', 4096),
48
+ },
49
+ })
@@ -0,0 +1,17 @@
1
+ import axios from 'axios'
2
+ import http from 'node:http'
3
+ import https from 'node:https'
4
+ import { config } from '../config.js'
5
+
6
+ const httpAgent = new http.Agent({ keepAlive: true })
7
+ const httpsAgent = new https.Agent({ keepAlive: true })
8
+
9
+ export const auroraClient = axios.create({
10
+ baseURL: config.aurora.baseUrl,
11
+ timeout: config.aurora.timeoutMs,
12
+ httpAgent,
13
+ httpsAgent,
14
+ headers: {
15
+ 'Content-Type': 'application/json',
16
+ },
17
+ })
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Logger JSONL pra stderr.
3
+ *
4
+ * Stdout e o canal do MCP STDIO transport — qualquer caractere fora do
5
+ * protocolo quebra a comunicacao com o cliente. Por isso TUDO vai pra
6
+ * stderr (que o Aurora captura nos logs separadamente).
7
+ *
8
+ * Formato JSONL e o que o Aurora consegue parsear no log aggregator;
9
+ * texto livre vira ruido.
10
+ */
11
+
12
+ const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 }
13
+ const minLevel = LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] ?? LEVELS.info
14
+
15
+ function emit(level, msg, ctx) {
16
+ if (LEVELS[level] < minLevel) return
17
+ const entry = {
18
+ ts: new Date().toISOString(),
19
+ level,
20
+ service: 'mcp-chatbot-core',
21
+ msg,
22
+ ...(ctx ?? {}),
23
+ }
24
+ // Stringify defensivo — context com referencias circulares nao quebra o log.
25
+ let line
26
+ try {
27
+ line = JSON.stringify(entry)
28
+ } catch {
29
+ line = JSON.stringify({ ...entry, _ctxError: 'serialization_failed' })
30
+ }
31
+ process.stderr.write(line + '\n')
32
+ }
33
+
34
+ export const logger = {
35
+ debug: (msg, ctx) => emit('debug', msg, ctx),
36
+ info: (msg, ctx) => emit('info', msg, ctx),
37
+ warn: (msg, ctx) => emit('warn', msg, ctx),
38
+ error: (msg, ctx) => emit('error', msg, ctx),
39
+ }
@@ -0,0 +1,105 @@
1
+ import { createClient } from 'redis'
2
+ import { config } from '../config.js'
3
+ import { logger } from './logger.js'
4
+
5
+ /**
6
+ * Singleton de Redis com:
7
+ * - lazy connect (so conecta no primeiro uso)
8
+ * - lock pra evitar race de duas tools conectando em paralelo
9
+ * - reconnect automatico do client SDK (backoff exponencial)
10
+ * - timeout curto na primeira conexao pra falhar rapido (3s) — o MCP
11
+ * nao pode segurar a IA por 30s esperando Redis morto
12
+ * - timeout por comando (2s) pra evitar travas em runtime
13
+ * - shutdown limpo (quit) chamado pelo entrypoint em SIGTERM/SIGINT
14
+ *
15
+ * Quando Redis cai e volta, o client SDK reconecta sozinho. A diferenca
16
+ * versao antiga: agora um command durante a queda falha em 2s em vez de
17
+ * pendurar pra sempre.
18
+ */
19
+
20
+ let client = null
21
+ let connectingPromise = null
22
+
23
+ function withTimeout(promise, ms, label) {
24
+ return new Promise((resolve, reject) => {
25
+ const timer = setTimeout(() => {
26
+ reject(new Error(`Redis ${label} timeout (${ms}ms)`))
27
+ }, ms)
28
+ promise.then(
29
+ (v) => {
30
+ clearTimeout(timer)
31
+ resolve(v)
32
+ },
33
+ (err) => {
34
+ clearTimeout(timer)
35
+ reject(err)
36
+ },
37
+ )
38
+ })
39
+ }
40
+
41
+ async function connect() {
42
+ const c = createClient({
43
+ url: config.redis.url,
44
+ socket: {
45
+ connectTimeout: config.redis.connectTimeoutMs,
46
+ // reconnectStrategy: backoff capado em 5s — evita storm e nao desiste.
47
+ reconnectStrategy: (attempts) => Math.min(50 * Math.pow(2, attempts), 5000),
48
+ },
49
+ })
50
+
51
+ c.on('error', (err) => {
52
+ // O cliente emite 'error' por reconnect failure tambem — logamos mas
53
+ // nao crasheamos. Comandos posteriores vao falhar com timeout proprio.
54
+ logger.warn('redis_client_error', { err: err.message })
55
+ })
56
+ c.on('reconnecting', () => logger.info('redis_reconnecting'))
57
+ c.on('ready', () => logger.info('redis_ready'))
58
+
59
+ await withTimeout(c.connect(), config.redis.connectTimeoutMs, 'connect')
60
+ return c
61
+ }
62
+
63
+ /**
64
+ * Devolve o client. Conecta na primeira chamada; subsequentes reusam.
65
+ * Se a conexao falhar, descarta o client e tenta de novo na proxima
66
+ * chamada (evita ficar preso a um client morto pra sempre).
67
+ */
68
+ export async function getRedis() {
69
+ if (client?.isOpen) return client
70
+ if (connectingPromise) return connectingPromise
71
+
72
+ connectingPromise = (async () => {
73
+ try {
74
+ client = await connect()
75
+ return client
76
+ } catch (err) {
77
+ client = null
78
+ throw err
79
+ } finally {
80
+ connectingPromise = null
81
+ }
82
+ })()
83
+
84
+ return connectingPromise
85
+ }
86
+
87
+ /**
88
+ * Wrapper de comando com timeout. Use em vez de chamar r.get/set direto
89
+ * pra garantir que um Redis lento nao trava o MCP por 30s.
90
+ */
91
+ export function withCommandTimeout(promise, label) {
92
+ return withTimeout(promise, config.redis.commandTimeoutMs, label)
93
+ }
94
+
95
+ export async function closeRedis() {
96
+ if (!client) return
97
+ try {
98
+ await client.quit()
99
+ logger.info('redis_closed')
100
+ } catch (err) {
101
+ logger.warn('redis_close_error', { err: err.message })
102
+ } finally {
103
+ client = null
104
+ }
105
+ }
@@ -0,0 +1,66 @@
1
+ import { logger } from './logger.js'
2
+
3
+ /**
4
+ * Helpers de resposta MCP. Todas as tools devolvem texto (`content[0].type ===
5
+ * 'text'`) com JSON dentro — a IA parseia o JSON e decide o que fazer.
6
+ */
7
+
8
+ function txt(text) {
9
+ return { content: [{ type: 'text', text }] }
10
+ }
11
+
12
+ function jsonTxt(obj) {
13
+ return txt(JSON.stringify(obj, null, 2))
14
+ }
15
+
16
+ /**
17
+ * Sucesso padronizado. `data` vai direto pra raiz pra IA achar sem aninhar.
18
+ */
19
+ export function ok(data = {}) {
20
+ return jsonTxt({ success: true, ...data })
21
+ }
22
+
23
+ /**
24
+ * Erro padronizado.
25
+ *
26
+ * - `code`: string estavel pra IA poder se preparar pra falha (ex:
27
+ * REDIS_DOWN, INVALID_KEY, WAHA_UNREACHABLE). NUNCA use a mensagem
28
+ * interna do exception aqui — coloca um valor que faca parte de um
29
+ * enum.
30
+ * - `message`: texto human-readable pra IA explicar pro usuario.
31
+ * - `retryable`: se a IA pode tentar de novo (default true pra IO).
32
+ * - `details`: extras pra debug — vai no log mas nao na resposta.
33
+ */
34
+ export function fail(code, message, opts = {}) {
35
+ const { retryable = true, details, toolName } = opts
36
+ logger.warn('tool_failed', { code, toolName, details })
37
+ return jsonTxt({
38
+ success: false,
39
+ error: { code, message, retryable },
40
+ })
41
+ }
42
+
43
+ /**
44
+ * Wrapper que captura excecoes inesperadas e devolve fail() generico
45
+ * sem vazar stack pro modelo.
46
+ */
47
+ export async function runTool(toolName, fn) {
48
+ const startedAt = Date.now()
49
+ try {
50
+ const result = await fn()
51
+ logger.info('tool_ok', { toolName, durationMs: Date.now() - startedAt })
52
+ return result
53
+ } catch (err) {
54
+ logger.error('tool_uncaught', {
55
+ toolName,
56
+ durationMs: Date.now() - startedAt,
57
+ err: err.message,
58
+ stack: err.stack,
59
+ })
60
+ return fail('INTERNAL_ERROR', `Erro interno em ${toolName}. Tente de novo em alguns segundos.`, {
61
+ toolName,
62
+ retryable: true,
63
+ details: { message: err.message },
64
+ })
65
+ }
66
+ }
@@ -0,0 +1,34 @@
1
+ import axios from 'axios'
2
+ import http from 'node:http'
3
+ import https from 'node:https'
4
+ import { config } from '../config.js'
5
+
6
+ /**
7
+ * Client HTTP dedicado pro WAHA com:
8
+ * - keep-alive habilitado (reutiliza conexoes TCP entre tool calls)
9
+ * - timeout configuravel via env
10
+ * - X-API-Key injetada automaticamente se a env estiver setada
11
+ */
12
+
13
+ const httpAgent = new http.Agent({ keepAlive: true })
14
+ const httpsAgent = new https.Agent({ keepAlive: true })
15
+
16
+ export const wahaClient = axios.create({
17
+ baseURL: config.waha.baseUrl,
18
+ timeout: config.waha.timeoutMs,
19
+ httpAgent,
20
+ httpsAgent,
21
+ headers: {
22
+ 'Content-Type': 'application/json',
23
+ ...(config.waha.apiKey ? { 'X-API-Key': config.waha.apiKey } : {}),
24
+ },
25
+ })
26
+
27
+ /**
28
+ * Normaliza um numero/identificador pra chatId do WAHA.
29
+ * - Se ja tem `@`, devolve como veio (5562...@c.us ou ...@g.us pra grupo)
30
+ * - Caso contrario, assume chat 1:1 e adiciona @c.us
31
+ */
32
+ export function toChatId(target) {
33
+ return target.includes('@') ? target : `${target}@c.us`
34
+ }
@@ -0,0 +1,213 @@
1
+ import { z } from 'zod'
2
+ import { auroraClient } from '../lib/aurora.js'
3
+ import { logger } from '../lib/logger.js'
4
+ import { ok, fail, runTool } from '../lib/response.js'
5
+
6
+ const description =
7
+ 'Cria um agendamento que a IA executa automaticamente — uma vez só (once) ou em horários regulares (recorrente). ' +
8
+ '\n\n' +
9
+ 'USE quando o usuario pedir pra agendar algo, como:\n' +
10
+ ' - "Me lembre amanhã às 9h de ligar pro cliente" → once (uma vez)\n' +
11
+ ' - "Rode esse follow-up na sexta às 14h" → once (uma vez)\n' +
12
+ ' - "Me envie um email todo dia às 9h com o resumo" → daily (recorrente)\n' +
13
+ ' - "Envie uma mensagem no WhatsApp toda segunda às 14h" → weekly\n' +
14
+ ' - "Execute essa verificação todo primeiro dia do mês" → monthly\n' +
15
+ '\n' +
16
+ 'A IA descreve o que fazer em `instruction`, escolhe quando com `preset`, e ' +
17
+ 'para onde entregar com `deliveryType` + `target`.\n' +
18
+ '\n' +
19
+ 'TIPOS DE QUANDO (preset):\n' +
20
+ ' - once (UMA VEZ): { kind: "once", date: "YYYY-MM-DD" (futura), hour: H (0-23), minute: M (0-59) } — executa 1x e para\n' +
21
+ ' - hourly: { kind: "hourly", everyHours: N (1-23), minute: M (0-59) }\n' +
22
+ ' - daily: { kind: "daily", hour: H (0-23), minute: M (0-59) }\n' +
23
+ ' - weekly: { kind: "weekly", weekday: D (0=domingo..6=sábado), hour: H, minute: M }\n' +
24
+ ' - monthly: { kind: "monthly", day: D (1-28), hour: H, minute: M }\n' +
25
+ '\n' +
26
+ 'REGRA: para pedidos pontuais ("amanhã", "dia X", "na sexta") use SEMPRE once. ' +
27
+ 'A data do once precisa ser FUTURA.\n' +
28
+ '\n' +
29
+ 'TIPOS DE ENTREGA:\n' +
30
+ ' - "internal": Executa a instrução (efeito = tools + histórico), não entrega externamente\n' +
31
+ ' - "email": Envia o resultado por email (requer `target`)\n' +
32
+ ' - "whatsapp": Envia o resultado via WhatsApp (requer `target` = número/chatId)\n' +
33
+ '\n' +
34
+ 'EXEMPLO DE USO:\n' +
35
+ 'user: "Quero um resumo das tarefas todo dia às 9h no meu email"\n' +
36
+ 'ia: [chama createScheduledTask com]\n' +
37
+ ' title: "Resumo diário de tarefas"\n' +
38
+ ' instruction: "Liste todas as tarefas pendentes do usuario de forma resumida"\n' +
39
+ ' deliveryType: "email"\n' +
40
+ ' target: "user@example.com"\n' +
41
+ ' preset: { kind: "daily", hour: 9, minute: 0 }\n' +
42
+ 'ia: "Pronto! Vou enviar um resumo para seu email todo dia às 9h da manhã."'
43
+
44
+ const inputSchema = {
45
+ // Preenchido AUTOMATICAMENTE pelo Aurora (ToolExecutor injeta a IA chamadora).
46
+ // A IA não fornece — impede criar agendamento atribuído a outra IA.
47
+ aiId: z
48
+ .string()
49
+ .min(1)
50
+ .optional()
51
+ .describe(
52
+ 'ID da IA dona do agendamento. Preenchido AUTOMATICAMENTE pelo sistema — ' +
53
+ 'a IA NÃO precisa fornecer.',
54
+ ),
55
+ // Preenchido AUTOMATICAMENTE pelo Aurora (ToolExecutor injeta o usuário atual).
56
+ // A IA não fornece — é o identificador de quem está conversando agora; usado
57
+ // pra resolver target "self" (lembrar o próprio usuário) sem a IA saber o número.
58
+ userId: z
59
+ .string()
60
+ .min(1)
61
+ .optional()
62
+ .describe(
63
+ 'Identificador do usuário da conversa atual. Preenchido AUTOMATICAMENTE pelo ' +
64
+ 'sistema — a IA NÃO precisa fornecer.',
65
+ ),
66
+ title: z
67
+ .string()
68
+ .min(3)
69
+ .max(200)
70
+ .describe(
71
+ 'Título descritivo da tarefa (ex: "Resumo diário", "Verificação de estoque"). ' +
72
+ 'Será exibido no painel de agendamentos.',
73
+ ),
74
+ instruction: z
75
+ .string()
76
+ .min(10)
77
+ .max(5000)
78
+ .describe(
79
+ 'Instruções claras do que a IA deve fazer. Será executada como turno normal da IA. ' +
80
+ 'Ex: "Gere um relatório das vendas de hoje e envie"',
81
+ ),
82
+ deliveryType: z
83
+ .enum(['internal', 'email', 'whatsapp'])
84
+ .describe(
85
+ 'Onde entregar o resultado:\n' +
86
+ '- "whatsapp": envia no WhatsApp. Para LEMBRAR O PRÓPRIO USUÁRIO da conversa ' +
87
+ '(ex: "me lembre...", "me avise..."), use target "self" — o sistema entrega ' +
88
+ 'pra ele automaticamente, você NÃO precisa saber o número.\n' +
89
+ '- "email": envia por email (requer target com o email).\n' +
90
+ '- "internal": só executa (tools+histórico), NÃO notifica ninguém. ' +
91
+ 'NÃO use internal para lembretes/avisos ao usuário — ele não receberia nada.',
92
+ ),
93
+ target: z
94
+ .string()
95
+ .optional()
96
+ .nullable()
97
+ .describe(
98
+ 'Destino da entrega:\n' +
99
+ '- "self" → o PRÓPRIO usuário desta conversa (use para "me lembre/me avise" no WhatsApp).\n' +
100
+ '- email válido para deliveryType "email" (ex: "user@company.com").\n' +
101
+ '- número/chatId para mandar pra OUTRA pessoa no WhatsApp (ex: "5562999540017").\n' +
102
+ 'Para "internal" não se aplica (deixe vazio).',
103
+ ),
104
+ preset: z
105
+ .object({
106
+ kind: z.enum(['hourly', 'daily', 'weekly', 'monthly', 'once']),
107
+ everyHours: z.number().int().min(1).max(23).optional(),
108
+ hour: z.number().int().min(0).max(23).optional(),
109
+ minute: z.number().int().min(0).max(59),
110
+ weekday: z.number().int().min(0).max(6).optional(),
111
+ day: z.number().int().min(1).max(28).optional(),
112
+ date: z.string().optional(),
113
+ })
114
+ .strict()
115
+ .describe(
116
+ 'Preset de quando executar. Estrutura varia por kind:\n' +
117
+ '- once (UMA VEZ SÓ): { kind, date "YYYY-MM-DD" (futura), hour (0-23), minute } — executa 1x e para\n' +
118
+ '- hourly: { kind, everyHours (1-23), minute }\n' +
119
+ '- daily: { kind, hour (0-23), minute }\n' +
120
+ '- weekly: { kind, weekday (0-6), hour, minute }\n' +
121
+ '- monthly: { kind, day (1-28), hour, minute }\n' +
122
+ 'Use "once" para ações pontuais (lembrete/follow-up em data específica); ' +
123
+ 'os demais para tarefas que se repetem.',
124
+ ),
125
+ }
126
+
127
+ async function handler({ aiId, userId, title, instruction, deliveryType, target, preset }) {
128
+ return runTool('createScheduledTask', async () => {
129
+ try {
130
+ logger.info('createScheduledTask_attempting', {
131
+ title,
132
+ deliveryType,
133
+ preset_kind: preset?.kind,
134
+ self_target: target === 'self',
135
+ })
136
+
137
+ const response = await auroraClient.post('/mcp/schedules/create', {
138
+ aiId, // injetado pelo Aurora (ToolExecutor) — IA chamadora
139
+ userId, // injetado pelo Aurora — usado pra resolver target "self"
140
+ title,
141
+ instruction,
142
+ deliveryType,
143
+ target: target || null,
144
+ preset,
145
+ })
146
+
147
+ if (!response.data?.success) {
148
+ return fail(
149
+ 'SCHEDULE_CREATE_FAILED',
150
+ response.data?.message || 'Falha ao criar agendamento',
151
+ { toolName: 'createScheduledTask', retryable: false },
152
+ )
153
+ }
154
+
155
+ const schedule = response.data.schedule
156
+ const nextRunTime = new Date(schedule.nextRunAt).toLocaleString('pt-BR', {
157
+ timeZone: 'America/Sao_Paulo',
158
+ })
159
+
160
+ logger.info('createScheduledTask_success', {
161
+ scheduleId: schedule.id,
162
+ title: schedule.title,
163
+ nextRunAt: schedule.nextRunAt,
164
+ })
165
+
166
+ return ok({
167
+ scheduleId: schedule.id,
168
+ title: schedule.title,
169
+ nextRunAt: schedule.nextRunAt,
170
+ nextRunAtFormatted: nextRunTime,
171
+ schedule: schedule.schedule,
172
+ scheduleConfig: schedule.scheduleConfig,
173
+ message: `Agendamento criado! Próxima execução: ${nextRunTime}`,
174
+ })
175
+ } catch (error) {
176
+ const message = error?.response?.data?.message || error?.message || String(error)
177
+ logger.error('createScheduledTask_error', {
178
+ error: message,
179
+ status: error?.response?.status,
180
+ })
181
+
182
+ if (error?.response?.status === 400) {
183
+ return fail(
184
+ 'INVALID_PARAMETERS',
185
+ message,
186
+ { toolName: 'createScheduledTask', retryable: false },
187
+ )
188
+ }
189
+ if (error?.response?.status === 404) {
190
+ return fail(
191
+ 'AI_NOT_FOUND',
192
+ message,
193
+ { toolName: 'createScheduledTask', retryable: false },
194
+ )
195
+ }
196
+ if (error?.response?.status === 500) {
197
+ return fail(
198
+ 'SERVER_ERROR',
199
+ 'Erro ao criar agendamento. Tente novamente em alguns segundos.',
200
+ { toolName: 'createScheduledTask', retryable: true, details: { err: message } },
201
+ )
202
+ }
203
+
204
+ return fail(
205
+ 'UNKNOWN_ERROR',
206
+ message || 'Erro desconhecido ao criar agendamento',
207
+ { toolName: 'createScheduledTask', retryable: true },
208
+ )
209
+ }
210
+ })
211
+ }
212
+
213
+ export const createScheduledTask = { name: 'createScheduledTask', description, inputSchema, handler }
@@ -0,0 +1,87 @@
1
+ import { z } from 'zod'
2
+ import { getRedis, withCommandTimeout } from '../lib/redis.js'
3
+ import { ok, fail, runTool } from '../lib/response.js'
4
+ import { config } from '../config.js'
5
+
6
+ const description =
7
+ 'Le o bloco de notas temporario que voce salvou previamente com saveTemporaryData. ' +
8
+ 'Use no inicio de cada passo de uma tarefa longa pra checar o que ja foi feito antes ' +
9
+ 'de decidir o proximo passo, retomar um carrinho/rascunho, ou consultar dados parciais ' +
10
+ 'coletados antes. ' +
11
+ '\n\n' +
12
+ 'Retorna `found=false` (mas success=true) se a chave nao existe ou expirou — nesse ' +
13
+ 'caso, assuma que precisa comecar a tarefa do zero.'
14
+
15
+ const KEY_REGEX = /^[a-z0-9][a-z0-9_:-]*$/i
16
+
17
+ const inputSchema = {
18
+ userId: z
19
+ .string()
20
+ .min(1)
21
+ .max(256)
22
+ .describe(
23
+ 'Identificador do usuario/sessao. Preenchido AUTOMATICAMENTE pelo ' +
24
+ 'sistema que chama esse MCP — a IA nao precisa fornecer. Garante ' +
25
+ 'que voce so le seu proprio scratch (mesmo namespace do saveTemporaryData).',
26
+ ),
27
+ key: z
28
+ .string()
29
+ .min(1)
30
+ .max(config.limits.keyMaxLength)
31
+ .regex(KEY_REGEX, 'key invalida')
32
+ .describe('Mesma chave usada no saveTemporaryData (sem userId — o sistema isola automaticamente)'),
33
+ }
34
+
35
+ async function handler({ userId, key }) {
36
+ return runTool('getTemporaryData', async () => {
37
+ let redis
38
+ try {
39
+ redis = await getRedis()
40
+ } catch (err) {
41
+ return fail('REDIS_UNAVAILABLE',
42
+ 'Armazenamento temporario indisponivel agora.',
43
+ { toolName: 'getTemporaryData', retryable: true, details: { err: err.message } },
44
+ )
45
+ }
46
+
47
+ const redisKey = `temp:${userId}:${key}`
48
+ let raw
49
+ try {
50
+ raw = await withCommandTimeout(redis.get(redisKey), 'get')
51
+ } catch (err) {
52
+ return fail('REDIS_TIMEOUT',
53
+ 'Timeout ao buscar dados — tente novamente.',
54
+ { toolName: 'getTemporaryData', retryable: true, details: { err: err.message } },
55
+ )
56
+ }
57
+
58
+ if (!raw) {
59
+ return ok({
60
+ found: false,
61
+ message: `Nada encontrado para "${key}". Pode ter expirado ou nunca foi salvo.`,
62
+ })
63
+ }
64
+
65
+ let parsed
66
+ try {
67
+ parsed = JSON.parse(raw)
68
+ } catch (err) {
69
+ // Dado corrompido — apaga pra nao envenenar futuras leituras.
70
+ await withCommandTimeout(redis.del(redisKey), 'del').catch(() => {})
71
+ return fail('CORRUPTED_DATA',
72
+ `Dados em "${key}" estavam corrompidos e foram descartados. Salve de novo se precisar.`,
73
+ { toolName: 'getTemporaryData', retryable: false, details: { err: err.message } },
74
+ )
75
+ }
76
+
77
+ return ok({
78
+ found: true,
79
+ key,
80
+ data: parsed.data,
81
+ savedAt: parsed.savedAt,
82
+ expiresAt: parsed.expiresAt,
83
+ })
84
+ })
85
+ }
86
+
87
+ export const getTemporaryData = { name: 'getTemporaryData', description, inputSchema, handler }
@@ -0,0 +1,145 @@
1
+ import { z } from 'zod'
2
+ import { getRedis, withCommandTimeout } from '../lib/redis.js'
3
+ import { ok, fail, runTool } from '../lib/response.js'
4
+ import { config } from '../config.js'
5
+
6
+ const description =
7
+ 'Bloco de notas TEMPORARIO em key/value (Redis-backed, TTL configuravel) pra voce ' +
8
+ 'rastrear estado QUE EVOLUI ao longo da conversa ou entre tool calls. ' +
9
+ '\n\n' +
10
+ 'USE sempre que coletar/agregar informacao em PEDACOS que precisarao ser usadas ou ' +
11
+ 'confirmadas em turnos subsequentes — independente do dominio. Domain-agnostic. ' +
12
+ '\n\n' +
13
+ 'CENARIOS GENERICOS de uso (mapeie pra qualquer dominio): ' +
14
+ '\n - Estado em CONSTRUCAO multi-turno: o usuario fornece informacao em pedacos ' +
15
+ ' (pedido/carrinho/agendamento/formulario/cadastro/orcamento/levantamento). ' +
16
+ '\n - TODO LIST das suas tarefas longas: checklist de etapas com flag done/pending. ' +
17
+ '\n - RESULTADOS PARCIAIS de tool calls que serao agregados na resposta final. ' +
18
+ '\n - RASCUNHOS que serao revisados ou confirmados antes de uma acao definitiva. ' +
19
+ '\n\n' +
20
+ 'PADRAO MULTI-TURNO (independe de dominio): ' +
21
+ '\n 1. Usuario fornece um pedaco de info -> SAVE com estado completo ate aqui. ' +
22
+ '\n 2. Usuario altera/adiciona/remove -> SAVE de novo (sobrescreve, atualiza TTL). ' +
23
+ '\n 3. Antes de confirmar/resumir/processar -> chame getTemporaryData pra ler exato. ' +
24
+ '\n 4. Acao final concluida -> deixe expirar OU sobrescreva com status final. ' +
25
+ '\n\n' +
26
+ 'CONVENCAO DE CHAVE recomendada: combine um proposito + identificador unico do ' +
27
+ 'contexto. Ex: "<proposito>_<userId>" ou "<proposito>_<sessionId>". Garante ' +
28
+ 'isolamento entre usuarios e fluxos. ' +
29
+ '\n\n' +
30
+ 'NAO use para: ' +
31
+ '\n - Fatos duraveis sobre o usuario (preferencias, perfil) -> memoria de longo prazo. ' +
32
+ '\n - Log/auditoria de acoes. ' +
33
+ '\n - Dados que precisam sobreviver mais de 24h. ' +
34
+ '\n\n' +
35
+ 'REGRA-CHAVE: nao confie apenas na memoria do contexto da conversa. Em conversas ' +
36
+ 'longas o LLM pode esquecer/inventar detalhes. Se voce salvou algo, LEIA antes de ' +
37
+ 'confirmar. ' +
38
+ '\n\n' +
39
+ 'TTL default: 1h. Aumente se a tarefa for longa (ex: ttl=14400 pra 4h, 86400 pra 24h).'
40
+
41
+ // Restringe a key pra prefixo seguro (evita colisao com outras namespaces
42
+ // do Redis: thread, ratelimit, etc).
43
+ const KEY_REGEX = /^[a-z0-9][a-z0-9_:-]*$/i
44
+
45
+ const inputSchema = {
46
+ // Provido pelo orquestrador (sistema que chama esse MCP) — a IA nao
47
+ // precisa controlar esse campo. Garante isolamento entre usuarios
48
+ // (multi-tenant) pra que cliente A nao leia/sobrescreva o scratch do
49
+ // cliente B mesmo se a IA usar a mesma key.
50
+ userId: z
51
+ .string()
52
+ .min(1)
53
+ .max(256)
54
+ .describe(
55
+ 'Identificador do usuario/sessao. Preenchido AUTOMATICAMENTE pelo ' +
56
+ 'sistema que chama esse MCP — a IA nao precisa fornecer. Usado pra ' +
57
+ 'isolar o storage per-usuario no namespace do Redis.',
58
+ ),
59
+ key: z
60
+ .string()
61
+ .min(1)
62
+ .max(config.limits.keyMaxLength)
63
+ .regex(KEY_REGEX, 'key deve ser alfanumerica (_ - : permitidos)')
64
+ .describe(
65
+ 'Nome curto e descritivo do proposito. Ex: "pedido", "carrinho", ' +
66
+ '"agendamento", "cadastro". NAO precisa incluir userId — o sistema ' +
67
+ 'isola automaticamente por usuario.',
68
+ ),
69
+ // OpenAI exige que arrays no schema declarem 'items'. Lista explicita de
70
+ // tipos primitivos no items resolve o erro 400 "array schema missing items".
71
+ // Claude e mais permissivo (aceita array sem items), OpenAI nao.
72
+ data: z
73
+ .union([
74
+ z.string(),
75
+ z.number(),
76
+ z.boolean(),
77
+ z.null(),
78
+ z.array(z.union([z.string(), z.number(), z.boolean(), z.null(), z.record(z.unknown())])),
79
+ z.record(z.unknown()),
80
+ ])
81
+ .describe('Conteudo a salvar — qualquer JSON serializavel'),
82
+ ttl: z
83
+ .number()
84
+ .int()
85
+ .min(1)
86
+ .max(config.limits.ttlMaxSeconds)
87
+ .optional()
88
+ .default(config.limits.ttlDefaultSeconds)
89
+ .describe(
90
+ `Tempo de vida em segundos. Default ${config.limits.ttlDefaultSeconds} (1h). ` +
91
+ `Max ${config.limits.ttlMaxSeconds} (24h). ` +
92
+ 'AJUSTE pra cima se voce sabe que a tarefa e longa (ex: ttl=14400 pra 4h, ' +
93
+ 'ttl=43200 pra 12h, ttl=86400 pra 24h). ' +
94
+ 'Re-salvar a mesma chave RESETA o TTL pra o novo valor — use isso pra estender ' +
95
+ 'durante uma tarefa em andamento.',
96
+ ),
97
+ }
98
+
99
+ async function handler({ userId, key, data, ttl }) {
100
+ return runTool('saveTemporaryData', async () => {
101
+ // Limite de payload pra evitar JSON-bomba/explosao de memoria Redis.
102
+ const payload = JSON.stringify({
103
+ data,
104
+ savedAt: new Date().toISOString(),
105
+ expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
106
+ })
107
+ if (Buffer.byteLength(payload, 'utf8') > config.limits.dataMaxBytes) {
108
+ return fail('PAYLOAD_TOO_LARGE',
109
+ `Dados excedem o limite de ${config.limits.dataMaxBytes} bytes. Divida em chaves menores.`,
110
+ { toolName: 'saveTemporaryData', retryable: false },
111
+ )
112
+ }
113
+
114
+ let redis
115
+ try {
116
+ redis = await getRedis()
117
+ } catch (err) {
118
+ return fail('REDIS_UNAVAILABLE',
119
+ 'Armazenamento temporario indisponivel agora. Tente novamente em alguns segundos ou explique pro usuario que voce nao consegue salvar a memoria temporaria.',
120
+ { toolName: 'saveTemporaryData', retryable: true, details: { err: err.message } },
121
+ )
122
+ }
123
+
124
+ // Namespace per-user: cliente A nunca le/escreve no scratch do cliente B
125
+ // mesmo se a IA usar a mesma key humana ("pedido", "carrinho", etc).
126
+ const redisKey = `temp:${userId}:${key}`
127
+ try {
128
+ await withCommandTimeout(redis.setEx(redisKey, ttl, payload), 'setEx')
129
+ } catch (err) {
130
+ return fail('REDIS_TIMEOUT',
131
+ 'Timeout ao salvar — o Redis demorou demais. Tente novamente.',
132
+ { toolName: 'saveTemporaryData', retryable: true, details: { err: err.message } },
133
+ )
134
+ }
135
+
136
+ return ok({
137
+ key,
138
+ ttlSeconds: ttl,
139
+ expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
140
+ message: `Dados salvos em "${key}" por ${Math.round(ttl / 60)} minutos`,
141
+ })
142
+ })
143
+ }
144
+
145
+ export const saveTemporaryData = { name: 'saveTemporaryData', description, inputSchema, handler }
@@ -0,0 +1,148 @@
1
+ import { z } from 'zod'
2
+ import { wahaClient, toChatId } from '../lib/waha.js'
3
+ import { getRedis, withCommandTimeout } from '../lib/redis.js'
4
+ import { logger } from '../lib/logger.js'
5
+ import { ok, fail, runTool } from '../lib/response.js'
6
+ import { config } from '../config.js'
7
+
8
+ const description =
9
+ 'Envia uma mensagem de texto para um numero de WhatsApp via WAHA. ' +
10
+ 'Use quando o usuario pedir explicitamente para mandar mensagem pra outra pessoa, ' +
11
+ 'ou quando o fluxo da IA exigir notificar terceiros. ' +
12
+ 'NAO use para responder ao proprio usuario na conversa atual — isso e feito ' +
13
+ 'automaticamente pelo retorno do chat.'
14
+
15
+ const inputSchema = {
16
+ phoneNumber: z
17
+ .string()
18
+ .min(1)
19
+ .optional()
20
+ .describe('Numero ou chatId do destinatario. Ex: "5562999540017" ou "5562999540017@c.us"'),
21
+ to: z
22
+ .string()
23
+ .min(1)
24
+ .optional()
25
+ .describe('Alias de phoneNumber (compatibilidade). Prefira phoneNumber.'),
26
+ message: z
27
+ .string()
28
+ .min(1)
29
+ .max(config.limits.messageMaxLength)
30
+ .describe('Texto da mensagem (max 4096 chars)'),
31
+ mentions: z
32
+ .array(z.string())
33
+ .max(20)
34
+ .optional()
35
+ .describe('Lista de numeros pra mencionar (@) na mensagem'),
36
+ context: z
37
+ .string()
38
+ .max(2000)
39
+ .optional()
40
+ .describe(
41
+ 'Contexto opcional a anexar na thread do destinatario (visivel pra IA na proxima mensagem dele).',
42
+ ),
43
+ }
44
+
45
+ /**
46
+ * Best-effort: anexa marcadores na thread do destinatario pra IA ter
47
+ * visibilidade do que foi enviado a ele.
48
+ *
49
+ * SMELL: o MCP nao deveria conhecer o formato interno do thread storage
50
+ * do Aurora (`openai:thread:{chatId}`). Idealmente isso seria emitido
51
+ * como evento e o Aurora consome do lado dele. Mantido aqui por
52
+ * compatibilidade com o comportamento atual; mover quando refatorar o
53
+ * pipeline de outbound messages.
54
+ */
55
+ async function appendToRecipientThread(chatId, message, context) {
56
+ try {
57
+ const redis = await getRedis()
58
+ const threadKey = `openai:thread:${chatId}`
59
+ const raw = await withCommandTimeout(redis.get(threadKey), 'get')
60
+ if (!raw) return // sem thread ativa — nada a anexar
61
+
62
+ let thread
63
+ try {
64
+ thread = JSON.parse(raw)
65
+ } catch {
66
+ return // formato inesperado — ignora silenciosamente
67
+ }
68
+ if (!Array.isArray(thread?.messages)) return
69
+
70
+ if (context) {
71
+ thread.messages.push({ role: 'assistant', content: `[CONTEXTO PRINCIPAL]: ${context}` })
72
+ }
73
+ thread.messages.push({
74
+ role: 'assistant',
75
+ content: `[PRIMEIRA MENSAGEM DA AURORA]: ${message}`,
76
+ })
77
+ await withCommandTimeout(redis.set(threadKey, JSON.stringify(thread)), 'set')
78
+ await withCommandTimeout(redis.expire(threadKey, 24 * 60 * 60), 'expire')
79
+ } catch (err) {
80
+ // Nao queremos que falha de thread-append derrube o envio bem-sucedido.
81
+ logger.warn('thread_append_failed', { err: err.message, chatId })
82
+ }
83
+ }
84
+
85
+ async function handler({ phoneNumber, to, message, mentions, context }) {
86
+ return runTool('sendMessageToWhatsApp', async () => {
87
+ const target = phoneNumber || to
88
+ if (!target) {
89
+ return fail('MISSING_TARGET',
90
+ 'phoneNumber ou to e obrigatorio.',
91
+ { toolName: 'sendMessageToWhatsApp', retryable: false },
92
+ )
93
+ }
94
+
95
+ const chatId = toChatId(target)
96
+ const payload = {
97
+ chatId,
98
+ text: message,
99
+ session: config.waha.session,
100
+ }
101
+ if (mentions && mentions.length > 0) {
102
+ // WAHA espera lista de numeros sem @c.us
103
+ payload.mentions = mentions.map((m) => m.replace(/@c\.us$/i, '').replace(/[^\d]/g, ''))
104
+ }
105
+
106
+ try {
107
+ await wahaClient.post('/api/sendText', payload)
108
+ } catch (err) {
109
+ const status = err.response?.status
110
+ const code =
111
+ status === 401 ? 'WAHA_UNAUTHORIZED' :
112
+ status === 404 ? 'WAHA_CHAT_NOT_FOUND' :
113
+ status >= 500 ? 'WAHA_UPSTREAM_ERROR' :
114
+ err.code === 'ECONNABORTED' ? 'WAHA_TIMEOUT' :
115
+ err.code === 'ECONNREFUSED' ? 'WAHA_UNREACHABLE' :
116
+ 'WAHA_SEND_FAILED'
117
+ const human = {
118
+ WAHA_UNAUTHORIZED: 'Credenciais do WhatsApp invalidas — fale com o admin.',
119
+ WAHA_CHAT_NOT_FOUND: 'Chat nao encontrado no WhatsApp.',
120
+ WAHA_UPSTREAM_ERROR: 'WhatsApp instavel agora. Tente em alguns segundos.',
121
+ WAHA_TIMEOUT: 'WhatsApp demorou a responder. Tente de novo.',
122
+ WAHA_UNREACHABLE: 'WhatsApp offline no momento.',
123
+ WAHA_SEND_FAILED: 'Nao consegui enviar a mensagem.',
124
+ }[code]
125
+ return fail(code, human, {
126
+ toolName: 'sendMessageToWhatsApp',
127
+ retryable: code !== 'WAHA_UNAUTHORIZED' && code !== 'WAHA_CHAT_NOT_FOUND',
128
+ details: { status, err: err.message, chatId },
129
+ })
130
+ }
131
+
132
+ // Best-effort: anexa na thread do destinatario (ver smell na helper).
133
+ // Roda APOS o envio bem-sucedido — se falhar, nao reverte a mensagem.
134
+ await appendToRecipientThread(chatId, message, context)
135
+
136
+ return ok({
137
+ message: 'Mensagem enviada com sucesso',
138
+ chatId,
139
+ })
140
+ })
141
+ }
142
+
143
+ export const sendMessageToWhatsApp = {
144
+ name: 'sendMessageToWhatsApp',
145
+ description,
146
+ inputSchema,
147
+ handler,
148
+ }
@@ -0,0 +1,117 @@
1
+ import { z } from 'zod'
2
+ import { getRedis, withCommandTimeout } from '../lib/redis.js'
3
+ import { ok, fail, runTool } from '../lib/response.js'
4
+
5
+ /**
6
+ * Detector heuristico de pedido de reset de contexto.
7
+ *
8
+ * SMELL: hoje esse tool toca direto na key `openai:thread:{userId}` que
9
+ * pertence ao thread storage interno do Aurora. Se o Aurora renomear
10
+ * essa key, a feature quebra silenciosamente. Idealmente a IA chamaria
11
+ * um endpoint dedicado do Aurora ("conversation reset") em vez de um MCP
12
+ * mexer no storage interno. Mantido aqui por compatibilidade — mover na
13
+ * proxima onda de refator.
14
+ */
15
+
16
+ const description =
17
+ 'Detecta se o usuario quer ZERAR o contexto da conversa (pedidos como ' +
18
+ '"esquece tudo", "limpa conversa", "vamos comecar do zero", "mudando de assunto"). ' +
19
+ 'Chame ANTES de responder quando suspeitar dessa intencao. Se confirmar, limpa o ' +
20
+ 'historico e a proxima mensagem comeca do zero. ' +
21
+ 'NAO chame em todas as mensagens — so quando o texto sugerir reset explicito ou ' +
22
+ 'mudanca clara de topico.'
23
+
24
+ const explicitClearIndicators = [
25
+ 'esqueca isso', 'esquece isso', 'esqueca tudo', 'esquece tudo',
26
+ 'limpa conversa', 'limpe a conversa', 'limpar conversa', 'limpar historico',
27
+ 'zerar conversa', 'resetar conversa', 'comecar de novo', 'comecar do zero',
28
+ 'recomecar conversa', 'nova conversa',
29
+ ]
30
+
31
+ const topicChangeIndicators = [
32
+ 'mudando de assunto', 'mudar de assunto', 'novo assunto',
33
+ 'outro assunto', 'deixa pra la', 'nao importa', 'vamos falar de outra coisa',
34
+ ]
35
+
36
+ // Mensagens que sugerem um workflow EM ANDAMENTO — nesses casos NUNCA
37
+ // limpamos o contexto, mesmo que apareca alguma palavra ambigua.
38
+ const activeWorkflowIndicators = [
39
+ 'oferta', 'ofertas', 'criar', 'criando', 'registro', 'registrar',
40
+ 'confirma', 'confirmar', 'prosseguir', 'continuar', 'proximo', 'proxima',
41
+ 'aguarde', 'processando', 'analisando',
42
+ ]
43
+
44
+ function matchAny(text, list) {
45
+ for (const ind of list) if (text.includes(ind)) return ind
46
+ return null
47
+ }
48
+
49
+ const inputSchema = {
50
+ userId: z
51
+ .string()
52
+ .min(1)
53
+ .max(256)
54
+ .describe('Identificador do usuario na conversa (chatId WhatsApp ou userId interno)'),
55
+ currentMessage: z
56
+ .string()
57
+ .min(1)
58
+ .max(10000)
59
+ .describe('Mensagem atual do usuario — usada pra detectar intencao de reset'),
60
+ }
61
+
62
+ async function handler({ userId, currentMessage }) {
63
+ return runTool('smartContextManager', async () => {
64
+ const text = currentMessage.toLowerCase()
65
+
66
+ const activeMatch = matchAny(text, activeWorkflowIndicators)
67
+ if (activeMatch) {
68
+ return ok({
69
+ action: 'keep_context',
70
+ reason: `workflow ativo: "${activeMatch}"`,
71
+ })
72
+ }
73
+
74
+ const explicitMatch = matchAny(text, explicitClearIndicators)
75
+ const topicMatch = explicitMatch ? null : matchAny(text, topicChangeIndicators)
76
+
77
+ if (!explicitMatch && !topicMatch) {
78
+ return ok({
79
+ action: 'keep_context',
80
+ reason: 'sem indicador de mudanca',
81
+ })
82
+ }
83
+
84
+ let redis
85
+ try {
86
+ redis = await getRedis()
87
+ } catch (err) {
88
+ return fail('REDIS_UNAVAILABLE',
89
+ 'Nao consegui acessar o storage de conversa pra limpar. Tente de novo em alguns segundos.',
90
+ { toolName: 'smartContextManager', retryable: true, details: { err: err.message } },
91
+ )
92
+ }
93
+
94
+ const threadKey = `openai:thread:${userId}`
95
+ try {
96
+ await withCommandTimeout(redis.del(threadKey), 'del')
97
+ } catch (err) {
98
+ return fail('REDIS_TIMEOUT',
99
+ 'Timeout ao limpar contexto.',
100
+ { toolName: 'smartContextManager', retryable: true, details: { err: err.message } },
101
+ )
102
+ }
103
+
104
+ return ok({
105
+ action: 'context_cleared',
106
+ reason: explicitMatch ? `solicitacao explicita: "${explicitMatch}"` : `mudanca de topico: "${topicMatch}"`,
107
+ message: 'Contexto limpo — proxima resposta comeca do zero.',
108
+ })
109
+ })
110
+ }
111
+
112
+ export const smartContextManager = {
113
+ name: 'smartContextManager',
114
+ description,
115
+ inputSchema,
116
+ handler,
117
+ }