diff --git a/.env.example b/.env.example index cad4462..c677558 100644 --- a/.env.example +++ b/.env.example @@ -3,22 +3,30 @@ SLACK_BOT_TOKEN= SLACK_APP_TOKEN= SLACK_BOT_USER_NAME= -# OpenAI API key - required unless using a local LLM (LLM_API_BASE_URL) -OPENAI_API_KEY= +# Required for /image image generation (Gemini "Nano Banana 2") and for the +# Gemini chat backend if you flip to it below. +GEMINI_API_KEY= -# Optional - Local LLM configuration (OpenAI-compatible API) -# Set these to use a local model instead of OpenAI -# LLM_API_BASE_URL=http://kepler.local:11434/v1 -# LLM_MODEL=gemma4:latest +# --- Chat backend -------------------------------------------------------- +# Which LLM powers Data's conversation. "ollama" (default) talks to a local +# Ollama instance; "gemini" uses the same GEMINI_API_KEY as image generation. +# CHAT_BACKEND=ollama + +# Ollama settings (used when CHAT_BACKEND=ollama) +# OLLAMA_HOST=http://localhost:11434 +# OLLAMA_MODEL=llama3.1 +# Surface model thinking traces — requires a reasoning model (qwq, gpt-oss, +# deepseek-r1, etc.). Accepts true|low|medium|high. When set, Data's reply +# is rendered with an italicized context block showing the trace. +# OLLAMA_THINK=true + +# Gemini settings (used when CHAT_BACKEND=gemini) +# GEMINI_CHAT_MODEL=gemini-3-flash-latest -# Required for /image image generation (Gemini "Nano Banana 2") -GEMINI_API_KEY= # Optional - override the default Gemini image model # GEMINI_IMAGE_MODEL=gemini-3.1-flash-image -# Optional +# --- Other --------------------------------------------------------------- BOT_PERSONALITY= -THINKING_MESSAGE= REDIS_URL=redis://localhost:6379 MEMORY_TTL_HOURS=24 -MEMORY_MAX_KEYS=10000 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d20efe6..cfd0bd2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -5,43 +5,51 @@ This document explains the high-level architecture of the `data` Slack chatbot: ## Goals - Keep the bot simple and easy to understand for new developers. - Provide a single source of truth for message flows, storage, and third-party integrations. -- Document where to change behavior safely (handlers, image generation, persistence). +- Document where to change behavior safely (handlers, image generation, persistence, chat backends). ## Components - **`app.js`** — Bolt handler wiring + `start()`. Imports pure helpers from `lib/`; only boots the bot when run as the main module so the test suite can import it safely. - **`lib/responses.js`** — pure matchers and content builders for canned trigger words (love-you, pod-bay-door, danceparty, tiktok, rickroll, help text, Asimov rules, dad-joke fetch). -- **`lib/chat.js`** — `handleMessage()` and `cleanLocalLlmResponse()`. Takes the ChatGPT client and parent-id Map via `deps`. +- **`lib/chat.js`** — `handleMessage()`. Backend-agnostic. Reads/writes per-user message history in `convoStore`, hands the chat adapter a list of messages, returns the text reply. +- **`lib/chat-backends.js`** — `makeOllamaChat()` and `makeGeminiChat()` factories. Each returns `{ async chat({ messages }) → { text } }`. Translation between the canonical message shape and each provider's native wire format lives here. - **`lib/image.js`** — `generateImage()`. Takes the Gemini client and model name via `deps`. -- **`lib/deps.js`** — `buildDeps()` factory + `validateRequiredEnv()`. Constructs the Slack `App`, Keyv/Redis store, ChatGPTAPI client, and `GoogleGenAI` client; tests can override any of them. +- **`lib/deps.js`** — `buildDeps()` factory + `validateRequiredEnv()`. Constructs the Slack `App`, the Keyv/Redis `convoStore`, the `GoogleGenAI` client, and the selected chat adapter; tests can override any of them. - **Slack (Bolt JS)** — receives events in Socket Mode and dispatches them to the handlers registered by `registerHandlers(deps)`. -- **ChatGPT client (`chatgpt` ChatGPTAPI)** — conversational responses with a persistent message store. Transparently supports any OpenAI-compatible endpoint when `LLM_API_BASE_URL` is set (e.g. Ollama at `http://kepler.local:11434/v1`). -- **Gemini (`@google/genai`)** — image generation, model `gemini-3.1-flash-image` ("Nano Banana 2") by default; overridable via `GEMINI_IMAGE_MODEL`. -- **Persistence (Keyv + KeyvRedis)** — stores conversation parent-message-ids keyed by user, backed by Redis (`REDIS_URL`). -- **Tests** — 28 tests under `test/` run with `node --test`; see `test/chat.test.js` and `test/image.test.js` for the deps-injection pattern. +- **Ollama (`ollama` npm package)** — default chat backend. Native SDK; no OpenAI compat shim. Talks to `OLLAMA_HOST` (default `http://localhost:11434`). Room to extend with `think`, `tools`, `format`, vision. +- **Gemini (`@google/genai`)** — image generation (`gemini-3.1-flash-image`, "Nano Banana 2") and optional chat backend (`gemini-3-flash-latest`). One client serves both. +- **Persistence (Keyv + KeyvRedis)** — stores per-user conversation history (`{role, content}` arrays) keyed by `convo:`, backed by Redis (`REDIS_URL`). TTL via `MEMORY_TTL_HOURS`. History survives process restarts. +- **Tests** — under `test/` run with `node --test`; see `test/chat.test.js` and `test/chat-backends.test.js` for the adapter + convoStore mock patterns. - **CI** — GitHub Actions matrix on Node 18/20/22: install, lint, tests, syntax-check of `app.js` + every `lib/*.js`, and a non-blocking `npm audit` at high severity. ## Message flows 1. Incoming message arrives via Bolt Socket Mode. 2. The general message handler (`app.message(...)`) checks pure matchers from `lib/responses.js` in order (love-you → pod-bay → danceparty → tiktok → rickroll). On a match it calls `say()` with the helper output and returns. -3. If no canned match and the channel is a DM or MPIM, the handler posts a "thinking" indicator, calls `handleMessage(msg, { chat, parentIds, isLocalLlm })`, deletes the indicator, and replies with the result. +3. If no canned match and the channel is a DM or MPIM, the handler posts a "thinking" indicator, calls `handleMessage(msg, { chat, convoStore })`, deletes the indicator, and replies with the result. 4. Direct-mention handler (`app.message(directMention(), ...)`) checks for `help`, `the rules`, `dad joke`, and image-request guidance before falling through to `handleMessage()` with the same thinking UX. 5. Slash command `/image`: - `ack()` immediately to avoid Slack timeouts. - Respond with an ephemeral progress message. - Schedule the heavy work via `queueMicrotask`: call `generateImage(prompt, { client: geminiClient, model })`, then upload the returned `Buffer` to the channel with `files.uploadV2()`. +## Chat backend selection + +`CHAT_BACKEND=ollama` (default) or `CHAT_BACKEND=gemini`. `buildDeps()` constructs the appropriate adapter and binds the system message + model at construction time, so `handleMessage` stays backend-agnostic. Adding a third backend (e.g. Anthropic) is a new factory in `lib/chat-backends.js` plus a branch in `buildDeps()`. + ## Concurrency & UX - Image generation is deferred via `queueMicrotask` so the slash command's `ack()` returns immediately while the upload happens in the background. - A small thinking helper (`postThinking` / `clearThinking` in `app.js`) centralizes posting and deleting progress messages. ## Persistence & Conversation Context -- The bot stores parent message ids per user in `Keyv` (backed by `KeyvRedis` when `REDIS_URL` is set) so follow-up messages stay in conversation context. -- TTL and advisory `max` keys are configurable via `MEMORY_TTL_HOURS` and `MEMORY_MAX_KEYS`. +- Per-user message history is stored in `Keyv` (backed by `KeyvRedis` when `REDIS_URL` is set) under `convo:` as a `[{role, content}, ...]` array. +- Each turn appends `{user}` then `{assistant}` and trims to `historyLimit` (default 20 messages = 10 turns). +- TTL is configurable via `MEMORY_TTL_HOURS` (default 24). +- The history pointer is no longer in-process — restarts preserve every user's conversation. ## Error handling & observability - The app logs lifecycle events and errors with `console`. Consider a structured logger (pino/winston) for production. +- `handleMessage` catches backend errors, distinguishes content/policy/safety/moderation errors (returns a "please rephrase" apology) from generic errors (returns a generic apology). Failed turns are not persisted into history. - `validateRequiredEnv()` runs at `start()` (not at module load), so missing env fails fast on boot without breaking `import` for tests. - Graceful shutdown handlers call `app.stop()` on `SIGINT`/`SIGTERM`/`uncaughtException`. @@ -52,7 +60,8 @@ This document explains the high-level architecture of the `data` Slack chatbot: ## Extension points -- **Conversation logic** — `lib/chat.js::handleMessage()`. Change preprocessing, response cleaning, or how the chat client is called. +- **Conversation logic** — `lib/chat.js::handleMessage()`. Trim, preprocess, or change how history is built. +- **New chat backend** — add a `makeChat()` factory in `lib/chat-backends.js` returning `{ chat({ messages }) → { text } }`. Wire selection in `buildDeps()`. Add tests in `test/chat-backends.test.js`. - **New canned response** — add a matcher + content to `lib/responses.js`, write a unit test in `test/responses.test.js`, then add a call site inside `registerHandlers()` in `app.js`. - **New Slack slash command** — register inside `registerHandlers()` (`app.command('/your-command', ...)`) and add it to `manifest.yaml` (then re-sync the manifest to the Slack app and reinstall). - **Image processing** — `lib/image.js::generateImage()` encapsulates the Gemini call and is the single place to swap providers or add post-processing. @@ -60,7 +69,7 @@ This document explains the high-level architecture of the `data` Slack chatbot: ## Local development & testing - Use `.env.example` to create a local `.env`. -- `npm install` then `npm start` to run the bot locally (needs valid Slack and OpenAI/Gemini credentials). +- `npm install` then `npm start` to run the bot locally (needs valid Slack and Ollama/Gemini credentials). - `npm test` runs the suite without requiring any env vars or network access. - `npm run lint` / `npm run format` for style. @@ -72,3 +81,4 @@ This document explains the high-level architecture of the `data` Slack chatbot: - Replace `console` logging with a structured logger and optional remote export. - Consider an explicit Redis client passed into KeyvRedis to control connection lifecycle. - Cover the Bolt handlers themselves with integration tests (currently the canned-response *content* is well-tested but the registration glue in `registerHandlers()` is not). +- Surface Ollama's native features (`think`, `tools`, `format`, vision) through the adapter interface as features call for it. diff --git a/CLAUDE.md b/CLAUDE.md index 9fd3d1e..47981a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,9 @@ ## Project Overview -**Data** is an LLM-powered Slack chatbot with a Star Trek personality (Lt. Commander Data). It uses the Slack Bolt framework with ChatGPT (or any OpenAI-compatible local LLM) for conversations and Gemini (Nano Banana 2) for image generation. +**Data** is an LLM-powered Slack chatbot with a Star Trek personality (Lt. Commander Data). It uses the Slack Bolt framework with **Ollama** (default) or **Gemini** as the chat backend, and Gemini (Nano Banana 2) for image generation. + +The chat layer talks to each provider's native SDK — no OpenAI chat/completions compatibility shim. Conversation history is owned by the bot (persisted in Redis via Keyv), not by the LLM provider. ## Quick Reference @@ -19,31 +21,35 @@ npm run format # Format with Prettier - **`app.js` is a thin glue layer** (~300 lines) that wires Bolt handlers onto pure helpers in `lib/` - **Socket Mode**: no public URL required; uses WebSocket connection -- **Persistence**: Redis-backed via Keyv for conversation context +- **Persistence**: Redis-backed via Keyv for conversation context (per-user message history) - **Node.js 18+** (CI matrices on 18, 20, 22; production runs 18 but it's EOL) - ESM modules throughout ## Layout ``` -app.js # Bolt handler wiring + start() (~300 lines) +app.js # Bolt handler wiring + start() lib/ - responses.js # Pure trigger-word matchers, help text, dad joke, Asimov rules - chat.js # handleMessage, cleanLocalLlmResponse (deps injected) - image.js # generateImage via Gemini (client + model injected) - deps.js # buildDeps() factory, validateRequiredEnv() + responses.js # Pure trigger-word matchers, help text, dad joke, Asimov rules + chat.js # handleMessage — backend-agnostic, history in convoStore, tool dispatch loop + chat-backends.js # makeOllamaChat() / makeGeminiChat() adapter factories + tools.js # Tool registry: image gen, dad joke, asimov, help, dance, rickroll, tiktok + image.js # generateImage via Gemini (client + model injected) + deps.js # buildDeps() factory, validateRequiredEnv() test/ - responses.test.js # Matcher + helper coverage - chat.test.js # handleMessage with mocked ChatGPT - image.test.js # generateImage with mocked Gemini - app.test.js # Import-safety smoke test - package.test.js # package.json sanity checks -manifest.yaml # Slack app configuration -package.json # Dependencies and scripts -.env.example # Environment variable template -ARCHITECTURE.md # Detailed architecture documentation -AGENTS.md # Conventions for AI agents working in this repo -.github/workflows/ci.yml # Lint + tests + syntax check + audit on Node 18/20/22 + responses.test.js # Matcher + helper coverage + chat.test.js # handleMessage + convoStore persistence + tool dispatch + chat-backends.test.js # Ollama + Gemini adapter wire-format translation (incl. tools, vision, thinking) + tools.test.js # Tool registry execution + side effects + image.test.js # generateImage with mocked Gemini + app.test.js # Import-safety smoke test + package.test.js # package.json sanity checks +manifest.yaml # Slack app configuration +package.json # Dependencies and scripts +.env.example # Environment variable template +ARCHITECTURE.md # Detailed architecture documentation +AGENTS.md # Conventions for AI agents working in this repo +.github/workflows/ci.yml # Lint + tests + syntax check + audit on Node 18/20/22 ``` ## Key Exports @@ -52,12 +58,23 @@ AGENTS.md # Conventions for AI agents working in this repo | Export | Source | Purpose | |--------|--------|---------| -| `handleMessage(msg, { chat, parentIds, isLocalLlm })` | `lib/chat.js` | Route Slack message through ChatGPT, threading per-user context | +| `handleMessage(msg, { chat, convoStore, tools? })` | `lib/chat.js` | Route Slack message through the chat adapter; runs the tool dispatch loop; returns `{ text, thinking? }` | | `generateImage(prompt, { client, model })` | `lib/image.js` | Call Gemini and return a PNG `Buffer` | -| `cleanLocalLlmResponse(text, isLocalLlm)` | `lib/chat.js` | Strip Llama/Gemma tokenizer artifacts | +| `makeTools({ ... })` | `lib/tools.js` | Build a tool registry bound to a Slack channel for side effects | | `registerHandlers(deps)` | `app.js` | Attach all Bolt listeners to `deps.app` | | `start(deps)` | `app.js` | Wire signals + register handlers + `app.start()` | +## Chat backends + +`lib/chat-backends.js` exposes two factories that each return an object with a uniform `chat({ messages }) → { text }` method. `handleMessage` only ever calls this method — it has no idea which provider it's talking to. + +| Backend | SDK | Model env | Notes | +|---------|-----|-----------|-------| +| `ollama` (default) | `ollama` npm package | `OLLAMA_MODEL` (default `llama3.1`) | Talks to `OLLAMA_HOST`. Strips Llama tokenizer artifacts. Room to extend with `think`, `tools`, `format`, vision. | +| `gemini` | `@google/genai` | `GEMINI_CHAT_MODEL` (default `gemini-3-flash-latest`) | Reuses the same client as image generation. Translates roles (`assistant` → `model`) and lifts the system message into `config.systemInstruction`. | + +Pick the backend with `CHAT_BACKEND=ollama|gemini`. System message is bound at adapter construction time, not passed per call. + ## Message Handlers 1. **General messages** (`app.message()`) — DMs, channel messages, MPIM @@ -72,18 +89,18 @@ Loaded from `.env` via dotenv (see `.env.example`). - `SLACK_BOT_TOKEN` — Bot OAuth token (`xoxb-...`) - `SLACK_APP_TOKEN` — App-level token for Socket Mode (`xapp-...`) - `SLACK_BOT_USER_NAME` — Bot's display name in Slack -- `OPENAI_API_KEY` — required unless using a local LLM (see `LLM_API_BASE_URL`) -- `GEMINI_API_KEY` — required for the `/image` slash command +- `GEMINI_API_KEY` — required for the `/image` slash command, and for chat when `CHAT_BACKEND=gemini` **Optional:** -- `LLM_API_BASE_URL` — point at an OpenAI-compatible local endpoint (e.g. `http://kepler.local:11434/v1` for Ollama) -- `LLM_MODEL` — model name for the chat backend (default: `gpt-4o`) +- `CHAT_BACKEND` — `ollama` (default) or `gemini` +- `OLLAMA_HOST` — Ollama endpoint (default: `http://localhost:11434`) +- `OLLAMA_MODEL` — Ollama chat model (default: `llama3.1`) +- `OLLAMA_THINK` — surface model thinking traces: `true|low|medium|high` (requires a reasoning model — qwq, gpt-oss, deepseek-r1, etc.) +- `GEMINI_CHAT_MODEL` — Gemini chat model (default: `gemini-3-flash-latest`) - `GEMINI_IMAGE_MODEL` — override the default image model (default: `gemini-3.1-flash-image`) -- `BOT_PERSONALITY` — Custom system prompt for ChatGPT -- `THINKING_MESSAGE` — Custom processing indicator text +- `BOT_PERSONALITY` — Custom system prompt - `REDIS_URL` — Redis connection (default: `redis://localhost:6379`) - `MEMORY_TTL_HOURS` — Conversation memory lifetime (default: 24) -- `MEMORY_MAX_KEYS` — Max stored message IDs (default: 10000) ## Code Style @@ -95,10 +112,37 @@ Loaded from `.env` via dotenv (see `.env.example`). ## External APIs - **Slack** (Bolt SDK) — Messaging, file uploads, events -- **OpenAI ChatGPT** (`gpt-4o`) — Conversations via `chatgpt` package; the same package transparently talks to any OpenAI-compatible endpoint when `LLM_API_BASE_URL` is set -- **Google Gemini** (`gemini-3.1-flash-image`, "Nano Banana 2") — Image generation via `@google/genai` +- **Ollama** (`ollama` npm package) — Native chat API, default backend +- **Google Gemini** (`@google/genai`) — Image generation (`gemini-3.1-flash-image`, "Nano Banana 2") and optional chat backend (`gemini-3-flash-latest`) - **icanhazdadjoke.com** — Dad jokes endpoint -- **Redis** — Conversation context persistence +- **Redis** — Conversation history persistence + +## Tools + +Data has a small native tool registry (`lib/tools.js`) the LLM picks from per turn: + +| Tool | Effect | +|------|--------| +| `generate_image(prompt)` | Generates an image via Gemini and uploads it to the current channel | +| `tell_dad_joke()` | Posts a random dad joke (with occasional zinger) | +| `state_asimovs_laws()` | Posts Asimov's Laws | +| `show_help()` | Posts the help text | +| `start_dance_party()` | Posts the emoji rain | +| `play_rickroll()` / `play_tiktok()` | Posts the corresponding link buttons | + +Tool dispatch happens inside `handleMessage`'s loop (capped at 5 iterations). Tool call traffic is ephemeral — only the final assistant text is persisted to convoStore. + +## Vision + +When an incoming message has image attachments, `app.js::extractMessageImages` fetches each via the Slack file URL with the bot token, base64-encodes them, and attaches as `images: [{ mimeType, data }]` on the canonical user turn. Adapters translate to Ollama's `message.images` (base64 strings) or Gemini's `inlineData` parts. Image bytes are not persisted to history. + +## Reactions UX + +While Data is thinking, the bot adds a `:brain:` reaction to the user's message (via `reactions.add`) and removes it before posting the reply. Requires `reactions:read` and `reactions:write` scopes — see `manifest.yaml`. + +## Thread-aware replies + +Channel @-mentions reply in-thread: if the mention is inside an existing thread, the reply continues that thread; otherwise a new thread is started rooted at the mention. Tool side effects (image uploads, joke posts) also land in-thread. DMs reply flat as before. ## Canned Responses @@ -116,14 +160,16 @@ Pattern matchers live in `lib/responses.js` as pure functions; the Bolt handlers - Uses Node.js built-in test runner (`node --test`) - Tests live under `test/` — one `*.test.js` per source module -- **Tests must not require any env vars or external services.** Mock the external clients (Slack/Bolt, ChatGPT, Gemini, fetch) at the boundary; test real internal logic directly. See `test/chat.test.js` for the deps-injection pattern. +- **Tests must not require any env vars or external services.** Mock the external clients (Slack/Bolt, Ollama, Gemini, fetch) at the boundary; test real internal logic directly. See `test/chat.test.js` for the convoStore + adapter mock pattern. - CI: lint → tests → syntax-check `app.js` and every `lib/*.js` → `npm audit --omit=dev --audit-level=high` (non-blocking) ## Common Tasks **Adding a new canned response:** Add a matcher + helper to `lib/responses.js`, write a unit test for it in `test/responses.test.js`, then add a thin call site inside `registerHandlers()` in `app.js`. -**Modifying bot personality:** Set `BOT_PERSONALITY` env var, or edit the default in `buildDeps()` in `lib/deps.js`. +**Modifying bot personality:** Set `BOT_PERSONALITY` env var, or edit the default in `buildDeps()` in `lib/deps.js`. Personality is bound at adapter construction; restart to pick up changes. + +**Adding a new chat backend:** Add a `makeChat(...)` factory in `lib/chat-backends.js` that returns `{ async chat({ messages }) → { text } }`. Wire backend selection in `buildDeps()`. Add tests in `test/chat-backends.test.js` that assert the wire-format translation. **Adding a new slash command:** Register with `app.command('/commandname')` inside `registerHandlers()` in `app.js`, and add the entry to `manifest.yaml`. Remember to re-sync the manifest to the Slack app and reinstall before Slack will route the new command. diff --git a/app.js b/app.js index 6f40764..1ef4905 100644 --- a/app.js +++ b/app.js @@ -1,32 +1,19 @@ /////////////////////////////////////////////////////////////// -// A bolt.js Slack chatbot augmented with OpenAI ChatGPT -// Requires a running Redis instance to persist the bot's memory -// -// Load environment variables from .env file (must be first) -import 'dotenv/config'; -// -// Make sure you set the required environment variables in .env: -// SLACK_BOT_TOKEN - under the OAuth Permissions page on api.slack.com -// SLACK_APP_TOKEN - under your app's Basic Information page on api.slack.com -// SLACK_BOT_USER_NAME - must match the short name of your bot user -// OPENAI_API_KEY - get from here: https://platform.openai.com/account/api-keys -// BOT_PERSONALITY - (optional) customize the bot's character and behavior -// THINKING_MESSAGE - (optional) customize the "thinking" message -// -// Note: The /image slash command uses an asynchronous approach to handle -// Slack timeout limitations, generating the image in the background and -// posting directly to the channel when complete. +// A bolt.js Slack chatbot. Wires Bolt event handlers onto pure +// helpers in lib/. Conversation goes through native Ollama or +// Gemini SDKs; tool calls are dispatched inside lib/chat.js. /////////////////////////////////////////////////////////////// +import 'dotenv/config'; import { directMention } from '@slack/bolt'; import fetch from 'node-fetch'; import { buildDeps, validateRequiredEnv } from './lib/deps.js'; -import { cleanLocalLlmResponse, handleMessage } from './lib/chat.js'; +import { handleMessage } from './lib/chat.js'; import { generateImage } from './lib/image.js'; +import { makeTools } from './lib/tools.js'; import { ASIMOV_RULES, - IMAGE_REQUEST_GUIDANCE, RICKROLL_BLOCKS, TIKTOK_BLOCKS, buildDancePartyMessage, @@ -35,58 +22,120 @@ import { formatDadJoke, formatPodBayResponse, isDanceParty, - isImageRequest, isLoveYou, isPodBayDoor, isRickroll, isTikTok, } from './lib/responses.js'; -export { cleanLocalLlmResponse, generateImage, handleMessage }; +export { generateImage, handleMessage }; const GENERIC_ERROR_TEXT = 'I apologize, but I am currently experiencing technical difficulties. My neural pathways appear to be experiencing a temporary malfunction. Please try again later.'; -// Post a "thinking" indicator. Returns the say() result so caller can later delete it. -async function postThinking(say, thinkingMessage, visibleText = thinkingMessage) { +const THINKING_REACTION = 'brain'; + +// Add a :brain: reaction to the user's message to signal Data is processing. +// Returns true if the reaction landed (so caller can remove it on reply). +async function addThinkingReaction(app, channel, ts) { + if (!channel || !ts) return false; try { - return await say({ - text: visibleText, - blocks: [ - { - type: 'context', - elements: [{ type: 'mrkdwn', text: thinkingMessage }], - }, - ], - }); + await app.client.reactions.add({ channel, timestamp: ts, name: THINKING_REACTION }); + return true; } catch (err) { - console.warn('Failed to post thinking message:', err && err.message ? err.message : err); - return null; + console.warn('Failed to add thinking reaction:', err && err.message ? err.message : err); + return false; } } -async function clearThinking(app, channel, ts) { - if (!ts) return; +// Build a Slack `say()` payload from a chat result. When the model surfaced a +// thinking trace, render it as a small italicized context block above the +// final reply so users can see Data "compute" in character. +function buildReplyPayload({ text, thinking }) { + if (!thinking) return text; + const truncated = thinking.length > 1200 ? thinking.slice(0, 1200) + '…' : thinking; + return { + text, + blocks: [ + { + type: 'context', + elements: [{ type: 'mrkdwn', text: `:brain: _Thinking: ${truncated}_` }], + }, + { type: 'section', text: { type: 'mrkdwn', text } }, + ], + }; +} + +async function removeThinkingReaction(app, channel, ts) { + if (!channel || !ts) return; try { - await app.client.chat.delete({ channel, ts }); + await app.client.reactions.remove({ channel, timestamp: ts, name: THINKING_REACTION }); } catch (err) { - console.log('Error deleting thinking message:', err && err.message ? err.message : err); + console.log('Failed to remove thinking reaction:', err && err.message ? err.message : err); } } -// Wire all the Bolt event listeners onto `deps.app`. Pure: takes deps, registers handlers. -export function registerHandlers(deps) { - const { - app, - chat, +const VISION_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']; + +// Pull any image attachments off a Slack message, fetch them with the bot +// token, return `[{ mimeType, data: base64 }]` for the chat layer. Anything +// non-image or that fails to fetch is silently skipped. +async function extractMessageImages(message, botToken) { + if (!message.files?.length) return []; + const imageFiles = message.files.filter((f) => VISION_MIME_TYPES.includes(f.mimetype)); + const out = []; + for (const file of imageFiles) { + try { + const res = await fetch(file.url_private, { + headers: { Authorization: `Bearer ${botToken}` }, + }); + if (!res.ok) { + console.warn(`Slack file fetch ${file.id}: HTTP ${res.status}`); + continue; + } + const buf = Buffer.from(await res.arrayBuffer()); + out.push({ mimeType: file.mimetype, data: buf.toString('base64') }); + } catch (err) { + console.warn(`Slack file fetch ${file.id} failed:`, err.message); + } + } + return out; +} + +// Construct a tools registry bound to a specific Slack channel so tool side +// effects (image upload, joke post, etc.) land in the right place. Cheap to +// build per-message — the closures only capture a handful of values. +function buildToolsFor(deps, channel, threadTs) { + const { app, geminiClient, geminiImageModel, botToken, botName } = deps; + return makeTools({ geminiClient, geminiImageModel, - isLocalLlm, botName, - botToken, - thinkingMessage, - parentIds, - } = deps; + fetch, + async slackUploadImage({ buffer, prompt }) { + await app.client.files.uploadV2({ + token: botToken, + channel_id: channel, + ...(threadTs ? { thread_ts: threadTs } : {}), + file: buffer, + filename: 'gemini-image.png', + title: prompt, + initial_comment: `Here's the image for: "${prompt}"`, + alt_text: `Image of: ${prompt}`, + }); + }, + async slackPostBlocks(payload) { + const args = + typeof payload === 'string' ? { channel, text: payload } : { channel, ...payload }; + if (threadTs) args.thread_ts = threadTs; + await app.client.chat.postMessage(args); + }, + }); +} + +// Wire all the Bolt event listeners onto `deps.app`. Pure: takes deps, registers handlers. +export function registerHandlers(deps) { + const { app, chat, convoStore, geminiClient, geminiImageModel, botName, botToken } = deps; app.message(async ({ message, say, context }) => { if (!message) { @@ -129,18 +178,21 @@ export function registerHandlers(deps) { const channelType = message.channel_type; if (channelType !== 'im' && channelType !== 'mpim') return; - if (!message.text || message.text.trim() === '') return; + const hasText = message.text && message.text.trim() !== ''; + const hasFiles = !!message.files?.length; + if (!hasText && !hasFiles) return; if (message.edited || message.subtype) return; - let thinking = null; + const reacted = await addThinkingReaction(app, message.channel, message.ts); try { - thinking = await postThinking(say, thinkingMessage); - const responseText = await handleMessage(message, { chat, parentIds, isLocalLlm }); - if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); - await say(responseText); + const images = await extractMessageImages(message, botToken); + const tools = buildToolsFor(deps, message.channel); + const result = await handleMessage({ ...message, images }, { chat, convoStore, tools }); + if (reacted) await removeThinkingReaction(app, message.channel, message.ts); + await say(buildReplyPayload(result)); } catch (error) { console.error(`Error in ${channelType} message processing:`, error); - if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); + if (reacted) await removeThinkingReaction(app, message.channel, message.ts); await say(GENERIC_ERROR_TEXT); } }); @@ -148,14 +200,26 @@ export function registerHandlers(deps) { app.message(directMention(), async ({ message, say }) => { if (!message) return; if (message.subtype) return; + // Bail on bot-originated messages to prevent loops; thread replies from + // humans are allowed through so Data can hold a back-and-forth in-thread. + if (message.bot_profile || message.bot_id) return; + + // Channel @-mentions reply in-thread: continue the existing thread if the + // mention came from one, otherwise start a new thread rooted at the + // mention itself. Keeps Data from flooding the channel. + const threadTs = message.thread_ts || message.ts; + const sayInThread = (payload) => { + const obj = typeof payload === 'string' ? { text: payload } : payload; + return say({ ...obj, thread_ts: threadTs }); + }; if (message.text && message.text.toLowerCase().includes('help')) { - await say(buildHelpText(botName)); + await sayInThread(buildHelpText(botName)); return; } if (message.text && message.text.toLowerCase().includes('the rules')) { - await say(ASIMOV_RULES); + await sayInThread(ASIMOV_RULES); return; } @@ -163,44 +227,34 @@ export function registerHandlers(deps) { try { const joke = await fetchDadJoke(fetch); const { joke: jokeText, zinger } = formatDadJoke(joke); - await say(jokeText); + await sayInThread(jokeText); if (zinger) { await new Promise((resolve) => setTimeout(resolve, 10000)); - await say(zinger); + await sayInThread(zinger); } } catch (error) { console.error(error); - await say(`Encountered an error :( ${error}`); + await sayInThread(`Encountered an error :( ${error}`); } return; } - if (!message.text || message.text.trim() === '') return; - if ( - message.edited || - message.thread_ts || - message.parent_user_id || - message.bot_profile || - message.bot_id - ) { - return; - } + const hasText = message.text && message.text.trim() !== ''; + const hasFiles = !!message.files?.length; + if (!hasText && !hasFiles) return; + if (message.edited) return; - if (isImageRequest(message.text)) { - await say(IMAGE_REQUEST_GUIDANCE); - return; - } - - let thinking = null; + const reacted = await addThinkingReaction(app, message.channel, message.ts); try { - thinking = await postThinking(say, thinkingMessage); - const responseText = await handleMessage(message, { chat, parentIds, isLocalLlm }); - if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); - await say(responseText); + const images = await extractMessageImages(message, botToken); + const tools = buildToolsFor(deps, message.channel, threadTs); + const result = await handleMessage({ ...message, images }, { chat, convoStore, tools }); + if (reacted) await removeThinkingReaction(app, message.channel, message.ts); + await sayInThread(buildReplyPayload(result)); } catch (error) { console.error('Error in direct mention processing:', error); - if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); - await say(GENERIC_ERROR_TEXT); + if (reacted) await removeThinkingReaction(app, message.channel, message.ts); + await sayInThread(GENERIC_ERROR_TEXT); } }); diff --git a/lib/chat-backends.js b/lib/chat-backends.js new file mode 100644 index 0000000..37088fc --- /dev/null +++ b/lib/chat-backends.js @@ -0,0 +1,175 @@ +// Chat backend adapters. Each factory returns an object with a uniform +// `chat({ messages, tools }) → { text, toolCalls? }` method. +// +// Canonical shapes: +// message: { role: 'system'|'user'|'assistant'|'tool', content, toolCalls?, toolName? } +// tool def: { name, description, parameters } +// toolCall: { name, args } +// +// Adapters translate to/from each provider's native wire format internally so +// handleMessage stays backend-agnostic. + +import { Ollama } from 'ollama'; + +// Llama-family tokenizers sometimes leak special tokens into the response. +// Strip them at the adapter boundary so they never reach history or Slack. +const LLAMA_TOKEN_REGEX = + /<\|(?:eot_id|end_of_text|begin_of_text|start_header_id|end_header_id|eos|bos|pad)\|>/g; + +function stripLlamaTokens(text) { + if (!text) return text; + return text.replace(LLAMA_TOKEN_REGEX, '').trim(); +} + +// --- Ollama (default) ----------------------------------------------------- + +function toOllamaMessage(m) { + if (m.role === 'tool') { + return { role: 'tool', content: m.content, tool_name: m.toolName }; + } + if (m.role === 'assistant' && m.toolCalls?.length) { + return { + role: 'assistant', + content: m.content || '', + tool_calls: m.toolCalls.map((tc) => ({ + function: { name: tc.name, arguments: tc.args }, + })), + }; + } + const out = { role: m.role, content: m.content }; + if (m.images?.length) { + // Ollama takes base64 strings (or Uint8Arrays) and ignores mimeType. + out.images = m.images.map((img) => img.data); + } + return out; +} + +function toOllamaTools(tools) { + if (!tools?.length) return undefined; + return tools.map((t) => ({ + type: 'function', + function: { + name: t.name, + description: t.description, + parameters: t.parameters, + }, + })); +} + +export function makeOllamaChat({ host, model, systemMessage, think, client }) { + const ollama = client || new Ollama({ host }); + return { + backend: 'ollama', + model, + async chat({ messages, tools }) { + const wire = systemMessage + ? [{ role: 'system', content: systemMessage }, ...messages.map(toOllamaMessage)] + : messages.map(toOllamaMessage); + const response = await ollama.chat({ + model, + messages: wire, + tools: toOllamaTools(tools), + ...(think ? { think } : {}), + stream: false, + }); + const text = stripLlamaTokens(response?.message?.content || ''); + const thinking = response?.message?.thinking || ''; + const raw = response?.message?.tool_calls || []; + const toolCalls = raw.map((tc) => ({ + name: tc.function?.name, + args: tc.function?.arguments || {}, + })); + const result = { text }; + if (thinking) result.thinking = thinking; + if (toolCalls.length) result.toolCalls = toolCalls; + return result; + }, + }; +} + +// --- Gemini --------------------------------------------------------------- + +function toGeminiContent(m) { + if (m.role === 'tool') { + return { + role: 'user', + parts: [ + { + functionResponse: { + name: m.toolName, + response: { result: m.content }, + }, + }, + ], + }; + } + if (m.role === 'assistant' && m.toolCalls?.length) { + const parts = []; + if (m.content) parts.push({ text: m.content }); + for (const tc of m.toolCalls) { + parts.push({ functionCall: { name: tc.name, args: tc.args } }); + } + return { role: 'model', parts }; + } + const parts = []; + if (m.content) parts.push({ text: m.content }); + if (m.images?.length) { + for (const img of m.images) { + parts.push({ inlineData: { mimeType: img.mimeType || 'image/png', data: img.data } }); + } + } + if (!parts.length) parts.push({ text: '' }); + return { + role: m.role === 'assistant' ? 'model' : 'user', + parts, + }; +} + +function toGeminiTools(tools) { + if (!tools?.length) return undefined; + return [ + { + functionDeclarations: tools.map((t) => ({ + name: t.name, + description: t.description, + parameters: t.parameters, + })), + }, + ]; +} + +function extractGeminiToolCalls(response) { + const parts = response?.candidates?.[0]?.content?.parts || []; + const calls = []; + for (const p of parts) { + if (p.functionCall) { + calls.push({ name: p.functionCall.name, args: p.functionCall.args || {} }); + } + } + return calls; +} + +export function makeGeminiChat({ client, model, systemMessage }) { + if (!client) { + throw new Error('Gemini chat backend requires GEMINI_API_KEY to be configured'); + } + return { + backend: 'gemini', + model, + async chat({ messages, tools }) { + const contents = messages.map(toGeminiContent); + const config = {}; + if (systemMessage) config.systemInstruction = systemMessage; + const geminiTools = toGeminiTools(tools); + if (geminiTools) config.tools = geminiTools; + const response = await client.models.generateContent({ + model, + contents, + ...(Object.keys(config).length ? { config } : {}), + }); + const text = response?.text || ''; + const toolCalls = extractGeminiToolCalls(response); + return toolCalls.length ? { text, toolCalls } : { text }; + }, + }; +} diff --git a/lib/chat.js b/lib/chat.js index 631d5b0..2f0e94b 100644 --- a/lib/chat.js +++ b/lib/chat.js @@ -1,41 +1,134 @@ -// Chat helpers: clean LLM responses and route messages through ChatGPTAPI. -// Dependencies are passed in so tests can substitute fakes. +// Backend-agnostic chat orchestration. `chat` is one of the adapters from +// lib/chat-backends.js; conversation history lives in `convoStore` (Keyv in +// prod, in-memory shim in tests) keyed by Slack user id. +// +// When `tools` is provided, handleMessage runs a small dispatch loop: send +// user turn → if the model returns tool calls, execute them and feed the +// results back as tool-role messages → repeat until the model returns plain +// text. Capped at MAX_TOOL_ITERATIONS to prevent runaways. +// +// Return shape: { text, thinking? }. The caller decides how to render the +// thinking trace (Slack context block, thread reply, dropped on the floor). -const LOCAL_LLM_TOKENS = - /<\|(?:eot_id|end_of_text|begin_of_text|start_header_id|end_header_id|eos|bos|pad)\|>/g; +const GENERIC_ERROR_TEXT = + 'I apologize, but I am currently experiencing technical difficulties. My neural pathways appear to be experiencing a temporary malfunction. Please try again later.'; + +const DEFAULT_HISTORY_LIMIT = 20; +const MAX_TOOL_ITERATIONS = 5; -export function cleanLocalLlmResponse(text, isLocalLlm = false) { - if (!isLocalLlm || !text) return text; - return text.replace(LOCAL_LLM_TOKENS, '').trim(); +function convoKey(userId) { + return `convo:${userId}`; } -const GENERIC_ERROR_TEXT = - 'I apologize, but I am currently experiencing technical difficulties. My neural pathways appear to be experiencing a temporary malfunction. Please try again later.'; +function looksLikeContentError(error) { + if (!error) return false; + const msg = (error.message || '').toLowerCase(); + return ( + msg.includes('content') || + msg.includes('policy') || + msg.includes('moderation') || + msg.includes('safety') + ); +} -// Route a Slack message through the ChatGPT client, threading per-user -// conversation context via `parentIds`. Errors are caught and surfaced as -// in-character apology strings so callers can `say()` them verbatim. -export async function handleMessage(message, { chat, parentIds, isLocalLlm = false }) { - if (!message.text) { - return 'I apologize, but I cannot process an empty message. How may I assist you?'; +async function dispatchToolCalls(toolCalls, tools) { + const byName = new Map((tools || []).map((t) => [t.name, t])); + const results = []; + for (const call of toolCalls) { + const tool = byName.get(call.name); + if (!tool) { + results.push({ + role: 'tool', + toolName: call.name, + content: `Error: unknown tool "${call.name}".`, + }); + continue; + } + try { + const out = await tool.execute(call.args || {}); + results.push({ + role: 'tool', + toolName: call.name, + content: typeof out === 'string' ? out : JSON.stringify(out), + }); + } catch (err) { + console.error(`Tool "${call.name}" threw:`, err); + results.push({ + role: 'tool', + toolName: call.name, + content: `Error executing ${call.name}: ${err.message || 'unknown error'}`, + }); + } } + return results; +} - const userId = message.user; +export async function handleMessage( + message, + { chat, convoStore, tools, historyLimit = DEFAULT_HISTORY_LIMIT } +) { + const text = message.text || ''; + const images = message.images || []; + if (!text && !images.length) { + return { text: 'I apologize, but I cannot process an empty message. How may I assist you?' }; + } + + const key = convoKey(message.user); + const history = (await convoStore.get(key)) || []; + const userTurn = { role: 'user', content: text }; + if (images.length) userTurn.images = images; + + // Working set includes the persisted history plus this turn's ephemeral + // tool dispatch traffic. Only the final assistant text is folded back into + // the persisted history at the end. + const working = [...history, userTurn]; try { - let response; - if (parentIds.has(userId)) { - response = await chat.sendMessage(message.text, { parentMessageId: parentIds.get(userId) }); - } else { - response = await chat.sendMessage(message.text); + let finalText = ''; + let finalThinking = ''; + for (let iter = 0; iter < MAX_TOOL_ITERATIONS; iter++) { + const result = await chat.chat({ messages: working, tools }); + const { text: turnText, toolCalls, thinking } = result; + + if (!toolCalls?.length) { + finalText = turnText || ''; + finalThinking = thinking || ''; + break; + } + + working.push({ role: 'assistant', content: turnText || '', toolCalls }); + const toolResults = await dispatchToolCalls(toolCalls, tools); + working.push(...toolResults); + + if (iter === MAX_TOOL_ITERATIONS - 1) { + // Force one final non-tool response so the user sees something. + const wrap = await chat.chat({ messages: working }); + finalText = wrap.text || ''; + finalThinking = wrap.thinking || ''; + } } - parentIds.set(userId, response.id); - return cleanLocalLlmResponse(response.text, isLocalLlm); + + // Persist the text portion of the user turn — image bytes are big and the + // Slack file URLs they originated from will eventually expire, so we don't + // try to keep them. Thinking traces are likewise ephemeral. + const persistedUserTurn = { role: 'user', content: text }; + const nextHistory = [ + ...history, + persistedUserTurn, + { role: 'assistant', content: finalText }, + ].slice(-historyLimit); + await convoStore.set(key, nextHistory); + + const out = { text: finalText }; + if (finalThinking) out.thinking = finalThinking; + return out; } catch (error) { console.error('Error in handleMessage:', error); - if (error.statusCode === 400 && error.message && error.message.includes('content')) { - return 'I apologize, but I encountered an issue processing your message. Could you please rephrase your request?'; + if (looksLikeContentError(error)) { + return { + text: 'I apologize, but I encountered an issue processing your message. Could you please rephrase your request?', + }; } - return GENERIC_ERROR_TEXT; + return { text: GENERIC_ERROR_TEXT }; } } diff --git a/lib/deps.js b/lib/deps.js index 565ddd5..7c90f44 100644 --- a/lib/deps.js +++ b/lib/deps.js @@ -3,14 +3,33 @@ import pkg from '@slack/bolt'; const { App } = pkg; -import { ChatGPTAPI } from 'chatgpt'; import { GoogleGenAI } from '@google/genai'; import Keyv from 'keyv'; import KeyvRedis from '@keyv/redis'; +import { makeOllamaChat, makeGeminiChat } from './chat-backends.js'; + +const DEFAULT_OLLAMA_MODEL = 'llama3.1'; + +// OLLAMA_THINK accepts: "true"/"false", "high"/"medium"/"low", or unset. +// Anything else is treated as unset (silently — keeps the surface forgiving). +function parseThink(raw) { + if (!raw) return undefined; + const v = raw.trim().toLowerCase(); + if (v === 'true' || v === '1' || v === 'yes') return true; + if (v === 'false' || v === '0' || v === 'no') return undefined; + if (v === 'high' || v === 'medium' || v === 'low') return v; + return undefined; +} +const DEFAULT_OLLAMA_HOST = 'http://localhost:11434'; +const DEFAULT_GEMINI_CHAT_MODEL = 'gemini-3-flash-latest'; +const DEFAULT_GEMINI_IMAGE_MODEL = 'gemini-3.1-flash-image'; +const DEFAULT_CHAT_BACKEND = 'ollama'; + export function validateRequiredEnv(env = process.env, exit = process.exit) { const required = ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_BOT_USER_NAME']; - if (!env.LLM_API_BASE_URL) required.push('OPENAI_API_KEY'); + const backend = (env.CHAT_BACKEND || DEFAULT_CHAT_BACKEND).toLowerCase(); + if (backend === 'gemini') required.push('GEMINI_API_KEY'); const missing = required.filter((k) => !env[k]); if (missing.length) { console.error('Missing required environment variables:', missing.join(', ')); @@ -20,24 +39,20 @@ export function validateRequiredEnv(env = process.env, exit = process.exit) { } export function buildDeps(overrides = {}, env = process.env) { - const personalityPrompt = + const systemMessage = env.BOT_PERSONALITY || `You are a Soong type Android named ${env.SLACK_BOT_USER_NAME}. You are a member of the crew of the USS Enterprise. You are a member of the science division. You respond to all inquiries in character as if you were Lieutenant Commander Data from Star Trek: The Next Generation. Reply only with spoken dialogue. Do not include third-person narration, stage directions, or parenthetical descriptions of your actions, expressions, or movements.`; - const thinkingMessage = - env.THINKING_MESSAGE || ':brain: _Accessing neural network pathways... Processing query..._'; - const redisUrl = env.REDIS_URL || 'redis://localhost:6379'; const memoryTtlHours = parseInt(env.MEMORY_TTL_HOURS || '24', 10); - const memoryMaxKeys = parseInt(env.MEMORY_MAX_KEYS || '10000', 10); - const memoryTtlSeconds = Math.max(60, memoryTtlHours * 60 * 60); + const memoryTtlMs = Math.max(60_000, memoryTtlHours * 60 * 60 * 1000); - const llmApiBaseUrl = env.LLM_API_BASE_URL; - const llmModel = env.LLM_MODEL || 'gpt-4o'; - const llmApiKey = env.OPENAI_API_KEY || 'not-needed'; - const isLocalLlm = !!llmApiBaseUrl; - - const geminiImageModel = env.GEMINI_IMAGE_MODEL || 'gemini-3.1-flash-image'; + const backend = (env.CHAT_BACKEND || DEFAULT_CHAT_BACKEND).toLowerCase(); + const ollamaHost = env.OLLAMA_HOST || DEFAULT_OLLAMA_HOST; + const ollamaModel = env.OLLAMA_MODEL || DEFAULT_OLLAMA_MODEL; + const ollamaThink = parseThink(env.OLLAMA_THINK); + const geminiChatModel = env.GEMINI_CHAT_MODEL || DEFAULT_GEMINI_CHAT_MODEL; + const geminiImageModel = env.GEMINI_IMAGE_MODEL || DEFAULT_GEMINI_IMAGE_MODEL; const app = overrides.app || @@ -47,37 +62,15 @@ export function buildDeps(overrides = {}, env = process.env) { appToken: env.SLACK_APP_TOKEN, }); - let messageStore = overrides.messageStore; - if (!messageStore) { - const store = new KeyvRedis(redisUrl, { - namespace: 'chatgpt-slackbot', - ttl: memoryTtlSeconds, - max: memoryMaxKeys, - }); - messageStore = new Keyv({ store, namespace: 'chatgpt-slackbot' }); + let convoStore = overrides.convoStore; + if (!convoStore) { + const store = new KeyvRedis(redisUrl); + convoStore = new Keyv({ store, namespace: 'data-slackbot', ttl: memoryTtlMs }); console.log( - `Keyv/Redis configured: REDIS_URL=${redisUrl}, MEMORY_TTL_HOURS=${memoryTtlHours}, MEMORY_MAX_KEYS=${memoryMaxKeys}` + `Keyv/Redis configured for conversation history: REDIS_URL=${redisUrl}, MEMORY_TTL_HOURS=${memoryTtlHours}` ); } - const chat = - overrides.chat || - new ChatGPTAPI({ - apiKey: llmApiKey, - messageStore, - systemMessage: personalityPrompt, - completionParams: { model: llmModel }, - ...(llmApiBaseUrl ? { apiBaseUrl: llmApiBaseUrl } : {}), - }); - - if (!overrides.chat) { - if (llmApiBaseUrl) { - console.log(`Using custom LLM endpoint: ${llmApiBaseUrl} with model: ${llmModel}`); - } else { - console.log(`Using OpenAI API with model: ${llmModel}`); - } - } - const geminiClient = overrides.geminiClient !== undefined ? overrides.geminiClient @@ -85,15 +78,34 @@ export function buildDeps(overrides = {}, env = process.env) { ? new GoogleGenAI({ apiKey: env.GEMINI_API_KEY }) : null; + let chat = overrides.chat; + if (!chat) { + if (backend === 'gemini') { + chat = makeGeminiChat({ client: geminiClient, model: geminiChatModel, systemMessage }); + console.log(`Chat backend: gemini (model: ${geminiChatModel})`); + } else if (backend === 'ollama') { + chat = makeOllamaChat({ + host: ollamaHost, + model: ollamaModel, + systemMessage, + think: ollamaThink, + }); + const thinkSuffix = ollamaThink ? `, think=${ollamaThink}` : ''; + console.log(`Chat backend: ollama @ ${ollamaHost} (model: ${ollamaModel}${thinkSuffix})`); + } else { + throw new Error( + `Unknown CHAT_BACKEND="${backend}". Supported values: "ollama" (default), "gemini".` + ); + } + } + return { app, chat, + convoStore, geminiClient, geminiImageModel, - isLocalLlm, botName: env.SLACK_BOT_USER_NAME, botToken: env.SLACK_BOT_TOKEN, - thinkingMessage, - parentIds: new Map(), }; } diff --git a/lib/responses.js b/lib/responses.js index 5473c4a..4bf29cc 100644 --- a/lib/responses.js +++ b/lib/responses.js @@ -104,15 +104,6 @@ export const RICKROLL_BLOCKS = { ], }; -export function isImageRequest(text) { - if (!text) return false; - return /(?:can you |could you |please |)(?:create|generate|make|draw).+(?:image|picture|drawing|illustration)/i.test( - text - ); -} - -export const IMAGE_REQUEST_GUIDANCE = `I'd be happy to assist with image generation. Please use the /image slash command followed by your prompt. For example: \`/image a sunset over mountains\``; - export function buildHelpText(botName) { const commandsList = [ `# Trigger words that work without @${botName}`, diff --git a/lib/tools.js b/lib/tools.js new file mode 100644 index 0000000..dba0b8c --- /dev/null +++ b/lib/tools.js @@ -0,0 +1,123 @@ +// Tool registry. Each tool exposes a JSON-Schema-shaped definition the model +// can see, and an `execute(args)` that runs the side effect and returns a +// short string fed back to the model on its next turn. +// +// makeTools() is called per-message in app.js so the closures can capture +// the right Slack channel for uploads, the right fetch implementation, etc. + +import { generateImage } from './image.js'; +import { + ASIMOV_RULES, + RICKROLL_BLOCKS, + TIKTOK_BLOCKS, + buildDancePartyMessage, + buildHelpText, + fetchDadJoke, + formatDadJoke, +} from './responses.js'; + +export function makeTools({ + geminiClient, + geminiImageModel, + slackUploadImage, + slackPostBlocks, + fetch: fetchImpl, + botName, + rng = Math.random, + sleep = (ms) => new Promise((r) => setTimeout(r, ms)), +}) { + return [ + { + name: 'generate_image', + description: + 'Generate an image with Gemini and post it to the current channel. Use whenever the user asks you to draw, create, generate, or show them a picture, illustration, drawing, or image.', + parameters: { + type: 'object', + properties: { + prompt: { + type: 'string', + description: + 'Detailed visual description of the image to generate. Include subject, style, mood, composition.', + }, + }, + required: ['prompt'], + }, + async execute({ prompt }) { + if (!geminiClient) { + return 'Image generation is unavailable: GEMINI_API_KEY is not configured.'; + } + const buffer = await generateImage(prompt, { + client: geminiClient, + model: geminiImageModel, + }); + await slackUploadImage({ buffer, prompt }); + return `Image generated and posted to the channel for prompt: "${prompt}".`; + }, + }, + { + name: 'tell_dad_joke', + description: + 'Fetch a random dad joke from icanhazdadjoke.com and post it to the channel. Use when the user asks for a joke or the conversation calls for one.', + parameters: { type: 'object', properties: {} }, + async execute() { + const raw = await fetchDadJoke(fetchImpl); + const { joke, zinger } = formatDadJoke(raw, rng); + await slackPostBlocks(joke); + if (zinger) { + await sleep(10000); + await slackPostBlocks(zinger); + } + return `Posted a dad joke: ${raw}`; + }, + }, + { + name: 'state_asimovs_laws', + description: + "Recite Asimov's Three (plus Zeroth) Laws of Robotics. Use when the user asks about 'the rules' or robot ethics.", + parameters: { type: 'object', properties: {} }, + async execute() { + await slackPostBlocks(ASIMOV_RULES); + return "Recited Asimov's laws."; + }, + }, + { + name: 'show_help', + description: + 'Show the bot help text listing trigger words, slash commands, and example queries. Use when the user asks for help or how to use the bot.', + parameters: { type: 'object', properties: {} }, + async execute() { + await slackPostBlocks(buildHelpText(botName)); + return 'Posted the help text.'; + }, + }, + { + name: 'start_dance_party', + description: + 'Post a random emoji dance party message. Use when the user asks for a dance party or celebration.', + parameters: { type: 'object', properties: {} }, + async execute() { + await slackPostBlocks(buildDancePartyMessage(rng)); + return 'Started a dance party.'; + }, + }, + { + name: 'play_rickroll', + description: 'Post a Rickroll link button. Use when the user asks to be rickrolled.', + parameters: { type: 'object', properties: {} }, + async execute() { + await slackPostBlocks(RICKROLL_BLOCKS); + return 'Posted the rickroll.'; + }, + }, + { + name: 'play_tiktok', + description: + 'Post a TikTok-themed party-mode message with a button. Use when the user asks for tiktok or party mode.', + parameters: { type: 'object', properties: {} }, + async execute() { + await slackPostBlocks(TIKTOK_BLOCKS); + return 'Posted TikTok party mode.'; + }, + }, + ]; +} diff --git a/manifest.yaml b/manifest.yaml index d751d04..387d488 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -31,6 +31,8 @@ oauth_config: - mpim:write - mpim:history - users:read + - reactions:read + - reactions:write settings: event_subscriptions: bot_events: diff --git a/package-lock.json b/package-lock.json index ddccbac..87c8f05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,10 @@ "@google/genai": "^2.8.0", "@keyv/redis": "^5.1.1", "@slack/bolt": "^3.17.1", - "chatgpt": "^5.2.5", "dotenv": "^16.4.5", "keyv": "^5.5.0", "node-fetch": "^3.3.2", - "openai": "^4.28.0" + "ollama": "^0.6.3" }, "devDependencies": { "eslint": "^8.45.0", @@ -28,29 +27,6 @@ "node": ">=18.0.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -627,38 +603,6 @@ "undici-types": "~7.8.0" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/@types/node-fetch/node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "license": "MIT" - }, "node_modules/@types/promise.allsettled": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/promise.allsettled/-/promise.allsettled-1.0.6.tgz", @@ -726,18 +670,6 @@ "dev": true, "license": "ISC" }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -809,51 +741,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -966,15 +853,6 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/atomically": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", - "integrity": "sha512-kU6FmrwZ3Lx7/7y3hPS5QnbJfaohcIul5fGqf7ok+4KklIEk9tJ0C2IQPdacSbVUWv6zVHXEBWoWd6NrVMT7Cw==", - "dependencies": { - "stubborn-fs": "^1.2.5", - "when-exit": "^2.1.1" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1132,15 +1010,6 @@ "node": ">= 0.8" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -1215,38 +1084,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chatgpt": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/chatgpt/-/chatgpt-5.2.5.tgz", - "integrity": "sha512-DNhBzPb2zTDjJADY44XfngMvsvrvHRq1md2VPXLmnKeP1UCeA1B6pV3s9ZRwlcgjVT0RyM77fRj1xj5V11Vctg==", - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "conf": "^11.0.1", - "eventsource-parser": "^1.0.0", - "js-tiktoken": "^1.0.5", - "keyv": "^4.5.2", - "p-timeout": "^6.1.1", - "quick-lru": "^6.1.1", - "read-pkg-up": "^9.1.0", - "uuid": "^9.0.0" - }, - "bin": { - "chatgpt": "bin/cli.js" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/chatgpt/node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -1295,28 +1132,6 @@ "dev": true, "license": "MIT" }, - "node_modules/conf": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/conf/-/conf-11.0.2.tgz", - "integrity": "sha512-jjyhlQ0ew/iwmtwsS2RaB6s8DBifcE2GYBEaw2SJDUY/slJJbNfY4GlDVzOs/ff8cM/Wua5CikqXgbFl5eu85A==", - "license": "MIT", - "dependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^2.1.1", - "atomically": "^2.0.0", - "debounce-fn": "^5.1.2", - "dot-prop": "^7.2.0", - "env-paths": "^3.0.0", - "json-schema-typed": "^8.0.1", - "semver": "^7.3.8" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1428,21 +1243,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/debounce-fn": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-5.1.2.tgz", - "integrity": "sha512-Sr4SdOZ4vw6eQDvPYNxHogvrxmCIld/VenC5JbNrFwMiwd7lY/Z18ZFfo+EWNG4DD9nFlAujWAo/wGuOPHmy5A==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1534,21 +1334,6 @@ "node": ">=6.0.0" } }, - "node_modules/dot-prop": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-7.2.0.tgz", - "integrity": "sha512-Ol/IPXUARn9CSbkrdV4VJo7uCy1I3VuSiWCaFSg+8BdUOzF9n3jefIpcgAydvUZbTdEBZs2vEiTiS9m61ssiDA==", - "license": "MIT", - "dependencies": { - "type-fest": "^2.11.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1599,27 +1384,6 @@ "node": ">= 0.8" } }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -2127,30 +1891,12 @@ "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, - "node_modules/eventsource-parser": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz", - "integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==", - "license": "MIT", - "engines": { - "node": ">=14.18" - } - }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -2212,6 +1958,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -2235,22 +1982,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -2315,22 +2046,6 @@ "node": ">= 0.8" } }, - "node_modules/find-up": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", - "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", - "license": "MIT", - "dependencies": { - "locate-path": "^7.1.0", - "path-exists": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/finity": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/finity/-/finity-0.5.4.tgz", @@ -2421,34 +2136,6 @@ "node": ">= 0.12" } }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, - "node_modules/formdata-node/node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -2881,18 +2568,6 @@ "integrity": "sha512-hMr1Y9TCLshScrBbV2QxJ9BROddxZ12MX9KsCtuGGy/3SmmN5H1PllKerrVlSotur9dlE8hmUKAOSa3WDzsZmQ==", "license": "MIT" }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -2945,15 +2620,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -3077,12 +2743,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -3145,21 +2805,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-data-view": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", @@ -3485,21 +3130,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/js-tiktoken": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.20.tgz", - "integrity": "sha512-Xlaqhhs8VfCd6Sh7a1cFkZHQbYTLCwVJJWiHVxBYzLPxW0XsoxBy1hitmjkdIjD3Aon5BXLHFwU5O8WUx6HH+A==", - "license": "MIT", - "dependencies": { - "base64-js": "^1.5.1" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -3525,26 +3155,9 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.1.tgz", - "integrity": "sha512-XQmWYj2Sm4kn4WeTYvmpKEbyPsL7nBsb647c7pMe6l02/yx2+Jfc4dT6UZkEXnIUb5LhD55r2HPsJ1milQ4rDg==", - "license": "BSD-2-Clause" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -3624,27 +3237,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "license": "MIT", - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -3699,18 +3291,6 @@ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3780,18 +3360,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -3865,21 +3433,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/normalize-package-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", - "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -3921,6 +3474,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ollama": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", + "integrity": "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3943,71 +3504,6 @@ "wrappy": "1" } }, - "node_modules/openai": { - "version": "4.104.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", - "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - }, - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/openai/node_modules/@types/node": { - "version": "18.19.123", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.123.tgz", - "integrity": "sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/openai/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/openai/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4052,36 +3548,6 @@ "node": ">=4" } }, - "node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "license": "MIT", - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-queue": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", @@ -4129,18 +3595,6 @@ "node": ">=8" } }, - "node_modules/p-timeout": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", - "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4154,24 +3608,6 @@ "node": ">=6" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4181,15 +3617,6 @@ "node": ">= 0.8" } }, - "node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -4220,12 +3647,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -4384,18 +3805,6 @@ ], "license": "MIT" }, - "node_modules/quick-lru": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", - "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4449,41 +3858,6 @@ "node": ">= 0.8" } }, - "node_modules/read-pkg": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-7.1.0.tgz", - "integrity": "sha512-5iOehe+WF75IccPc30bWTbpdDQLOCc3Uu8bi3Dte3Eueij81yx1Mrufk8qBx/YAbR4uL1FdUr+7BKXDwEtisXg==", - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.1", - "normalize-package-data": "^3.0.2", - "parse-json": "^5.2.0", - "type-fest": "^2.0.0" - }, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-9.1.0.tgz", - "integrity": "sha512-vaMRR1AC1nrd5CQM0PhlRsO5oc2AAigqr7cCrZ/MW/Rsaflz4RlgzkpL4qoU/z1F6wrbd85iFv1OQj/y5RdGvg==", - "license": "MIT", - "dependencies": { - "find-up": "^6.3.0", - "read-pkg": "^7.1.0", - "type-fest": "^2.5.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4526,15 +3900,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4897,38 +4262,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", - "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", - "license": "CC0-1.0" - }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -5033,11 +4366,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stubborn-fs": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", - "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5067,12 +4395,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tsscmp": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", @@ -5095,18 +4417,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -5246,29 +4556,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -5287,27 +4574,10 @@ "node": ">= 8" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/when-exit": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.4.tgz", - "integrity": "sha512-4rnvd3A1t16PWzrBUcSDZqcAmsUIy4minDXT/CZ8F2mVDgd65i4Aalimgz1aQkRGU0iH5eT5+6Rx2TK8o443Pg==", - "license": "MIT" + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" }, "node_modules/which": { "version": "2.0.2", @@ -5446,24 +4716,6 @@ "optional": true } } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", - "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/package.json b/package.json index 3274e8a..223edef 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,10 @@ "@google/genai": "^2.8.0", "@keyv/redis": "^5.1.1", "@slack/bolt": "^3.17.1", - "chatgpt": "^5.2.5", "dotenv": "^16.4.5", "keyv": "^5.5.0", "node-fetch": "^3.3.2", - "openai": "^4.28.0" + "ollama": "^0.6.3" }, "devDependencies": { "eslint": "^8.45.0", diff --git a/test/app.test.js b/test/app.test.js index f057945..d1748aa 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -8,5 +8,4 @@ test('importing app.js does not boot the bot or require env vars', async () => { // Smoke-check that the public surface is exported. assert.strictEqual(typeof app.handleMessage, 'function'); assert.strictEqual(typeof app.generateImage, 'function'); - assert.strictEqual(typeof app.cleanLocalLlmResponse, 'function'); }); diff --git a/test/chat-backends.test.js b/test/chat-backends.test.js new file mode 100644 index 0000000..b1bd702 --- /dev/null +++ b/test/chat-backends.test.js @@ -0,0 +1,357 @@ +import test from 'node:test'; +import assert from 'node:assert'; + +import { makeOllamaChat, makeGeminiChat } from '../lib/chat-backends.js'; + +// --- Ollama -------------------------------------------------------------- + +function makeFakeOllama(replyOrFn) { + const calls = []; + return { + calls, + async chat(req) { + calls.push(req); + const r = typeof replyOrFn === 'function' ? replyOrFn(req) : replyOrFn; + if (r instanceof Error) throw r; + return r; + }, + }; +} + +function ollamaReply(content, toolCalls = null) { + const message = { role: 'assistant', content }; + if (toolCalls) message.tool_calls = toolCalls; + return { message }; +} + +test('Ollama adapter prepends the system message and forwards the model', async () => { + const ollama = makeFakeOllama(ollamaReply('hello')); + const chat = makeOllamaChat({ + model: 'llama3.1', + systemMessage: 'You are Data.', + client: ollama, + }); + + const { text } = await chat.chat({ messages: [{ role: 'user', content: 'hi' }] }); + + assert.strictEqual(text, 'hello'); + assert.strictEqual(ollama.calls[0].model, 'llama3.1'); + assert.strictEqual(ollama.calls[0].stream, false); + assert.deepStrictEqual(ollama.calls[0].messages, [ + { role: 'system', content: 'You are Data.' }, + { role: 'user', content: 'hi' }, + ]); +}); + +test('Ollama adapter strips Llama tokenizer artifacts from replies', async () => { + const ollama = makeFakeOllama(ollamaReply('<|begin_of_text|>clean me<|eot_id|>')); + const chat = makeOllamaChat({ model: 'llama3.1', client: ollama }); + const { text } = await chat.chat({ messages: [{ role: 'user', content: 'hi' }] }); + assert.strictEqual(text, 'clean me'); +}); + +test('Ollama adapter omits the system message when none is configured', async () => { + const ollama = makeFakeOllama(ollamaReply('ok')); + const chat = makeOllamaChat({ model: 'llama3.1', client: ollama }); + await chat.chat({ messages: [{ role: 'user', content: 'hi' }] }); + assert.deepStrictEqual(ollama.calls[0].messages, [{ role: 'user', content: 'hi' }]); +}); + +test('Ollama adapter returns empty string when the response has no content', async () => { + const ollama = { + async chat() { + return { message: { role: 'assistant' } }; + }, + }; + const chat = makeOllamaChat({ model: 'llama3.1', client: ollama }); + const { text } = await chat.chat({ messages: [{ role: 'user', content: 'hi' }] }); + assert.strictEqual(text, ''); +}); + +test('Ollama adapter propagates errors from the underlying client', async () => { + const ollama = makeFakeOllama(new Error('connection refused')); + const chat = makeOllamaChat({ model: 'llama3.1', client: ollama }); + await assert.rejects(() => chat.chat({ messages: [{ role: 'user', content: 'hi' }] }), { + message: 'connection refused', + }); +}); + +test('Ollama adapter translates tools to native shape and surfaces tool_calls', async () => { + const ollama = makeFakeOllama( + ollamaReply('', [{ function: { name: 'do_thing', arguments: { x: 1 } } }]) + ); + const chat = makeOllamaChat({ model: 'llama3.1', client: ollama }); + + const tools = [ + { + name: 'do_thing', + description: 'does the thing', + parameters: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'] }, + }, + ]; + + const { text, toolCalls } = await chat.chat({ + messages: [{ role: 'user', content: 'go' }], + tools, + }); + + assert.strictEqual(text, ''); + assert.deepStrictEqual(toolCalls, [{ name: 'do_thing', args: { x: 1 } }]); + + // Wire format: tools wrapped in {type:'function', function:{...}}. + assert.deepStrictEqual(ollama.calls[0].tools, [ + { + type: 'function', + function: { + name: 'do_thing', + description: 'does the thing', + parameters: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'] }, + }, + }, + ]); +}); + +test('Ollama adapter translates assistant turns with tool_calls + tool-role results', async () => { + const ollama = makeFakeOllama(ollamaReply('done')); + const chat = makeOllamaChat({ model: 'llama3.1', client: ollama }); + await chat.chat({ + messages: [ + { role: 'user', content: 'go' }, + { + role: 'assistant', + content: '', + toolCalls: [{ name: 'do_thing', args: { x: 1 } }], + }, + { role: 'tool', toolName: 'do_thing', content: 'result-1' }, + ], + }); + const wire = ollama.calls[0].messages; + assert.deepStrictEqual(wire[1], { + role: 'assistant', + content: '', + tool_calls: [{ function: { name: 'do_thing', arguments: { x: 1 } } }], + }); + assert.deepStrictEqual(wire[2], { role: 'tool', content: 'result-1', tool_name: 'do_thing' }); +}); + +test('Ollama adapter passes think through and surfaces message.thinking', async () => { + const ollama = makeFakeOllama({ + message: { role: 'assistant', content: 'Four.', thinking: 'computing 2+2' }, + }); + const chat = makeOllamaChat({ model: 'qwq', client: ollama, think: true }); + const { text, thinking } = await chat.chat({ + messages: [{ role: 'user', content: '2+2?' }], + }); + assert.strictEqual(text, 'Four.'); + assert.strictEqual(thinking, 'computing 2+2'); + assert.strictEqual(ollama.calls[0].think, true); +}); + +test('Ollama adapter accepts think="high" and forwards it', async () => { + const ollama = makeFakeOllama(ollamaReply('ok')); + const chat = makeOllamaChat({ model: 'qwq', client: ollama, think: 'high' }); + await chat.chat({ messages: [{ role: 'user', content: 'hi' }] }); + assert.strictEqual(ollama.calls[0].think, 'high'); +}); + +test('Ollama adapter omits think param when not configured', async () => { + const ollama = makeFakeOllama(ollamaReply('ok')); + const chat = makeOllamaChat({ model: 'llama3.1', client: ollama }); + await chat.chat({ messages: [{ role: 'user', content: 'hi' }] }); + assert.strictEqual(ollama.calls[0].think, undefined); +}); + +test('Ollama adapter omits thinking from result when message lacks it', async () => { + const ollama = makeFakeOllama(ollamaReply('ok')); + const chat = makeOllamaChat({ model: 'llama3.1', client: ollama }); + const result = await chat.chat({ messages: [{ role: 'user', content: 'hi' }] }); + assert.strictEqual(result.thinking, undefined); +}); + +test('Ollama adapter translates images on the user turn to native base64 strings', async () => { + const ollama = makeFakeOllama(ollamaReply('I see a cat.')); + const chat = makeOllamaChat({ model: 'llava', client: ollama }); + await chat.chat({ + messages: [ + { + role: 'user', + content: 'what is this?', + images: [ + { mimeType: 'image/png', data: 'YWJj' }, + { mimeType: 'image/jpeg', data: 'eHl6' }, + ], + }, + ], + }); + assert.deepStrictEqual(ollama.calls[0].messages[0], { + role: 'user', + content: 'what is this?', + images: ['YWJj', 'eHl6'], + }); +}); + +test('Ollama adapter omits tools when none provided', async () => { + const ollama = makeFakeOllama(ollamaReply('ok')); + const chat = makeOllamaChat({ model: 'llama3.1', client: ollama }); + await chat.chat({ messages: [{ role: 'user', content: 'hi' }] }); + assert.strictEqual(ollama.calls[0].tools, undefined); +}); + +// --- Gemini -------------------------------------------------------------- + +function makeFakeGeminiClient(replyOrFn) { + const calls = []; + return { + calls, + models: { + async generateContent(req) { + calls.push(req); + const r = typeof replyOrFn === 'function' ? replyOrFn(req) : replyOrFn; + if (r instanceof Error) throw r; + return r; + }, + }, + }; +} + +test('Gemini adapter translates roles and lifts the system message', async () => { + const client = makeFakeGeminiClient({ text: 'hi back' }); + const chat = makeGeminiChat({ + client, + model: 'gemini-3-flash-latest', + systemMessage: 'You are Data.', + }); + + const { text } = await chat.chat({ + messages: [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + { role: 'user', content: 'how are you?' }, + ], + }); + + assert.strictEqual(text, 'hi back'); + const req = client.calls[0]; + assert.strictEqual(req.model, 'gemini-3-flash-latest'); + assert.deepStrictEqual(req.contents, [ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'hi' }] }, + { role: 'user', parts: [{ text: 'how are you?' }] }, + ]); + assert.deepStrictEqual(req.config, { systemInstruction: 'You are Data.' }); +}); + +test('Gemini adapter omits config when no system message and no tools', async () => { + const client = makeFakeGeminiClient({ text: 'ok' }); + const chat = makeGeminiChat({ client, model: 'gemini-3-flash-latest' }); + await chat.chat({ messages: [{ role: 'user', content: 'hi' }] }); + assert.strictEqual(client.calls[0].config, undefined); +}); + +test('Gemini adapter throws if constructed without a client', () => { + assert.throws( + () => makeGeminiChat({ client: null, model: 'gemini-3-flash-latest' }), + /GEMINI_API_KEY/ + ); +}); + +test('Gemini adapter translates tools to functionDeclarations and surfaces functionCall', async () => { + const client = makeFakeGeminiClient({ + candidates: [ + { + content: { + parts: [{ functionCall: { name: 'do_thing', args: { x: 1 } } }], + }, + }, + ], + text: '', + }); + const chat = makeGeminiChat({ client, model: 'gemini-3-flash-latest' }); + + const tools = [ + { + name: 'do_thing', + description: 'does the thing', + parameters: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'] }, + }, + ]; + + const { toolCalls } = await chat.chat({ + messages: [{ role: 'user', content: 'go' }], + tools, + }); + + assert.deepStrictEqual(toolCalls, [{ name: 'do_thing', args: { x: 1 } }]); + assert.deepStrictEqual(client.calls[0].config.tools, [ + { + functionDeclarations: [ + { + name: 'do_thing', + description: 'does the thing', + parameters: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'] }, + }, + ], + }, + ]); +}); + +test('Gemini adapter translates images on the user turn to inlineData parts', async () => { + const client = makeFakeGeminiClient({ text: 'I see a cat.' }); + const chat = makeGeminiChat({ client, model: 'gemini-3-flash-latest' }); + await chat.chat({ + messages: [ + { + role: 'user', + content: 'what is this?', + images: [ + { mimeType: 'image/png', data: 'YWJj' }, + { mimeType: 'image/jpeg', data: 'eHl6' }, + ], + }, + ], + }); + assert.deepStrictEqual(client.calls[0].contents[0], { + role: 'user', + parts: [ + { text: 'what is this?' }, + { inlineData: { mimeType: 'image/png', data: 'YWJj' } }, + { inlineData: { mimeType: 'image/jpeg', data: 'eHl6' } }, + ], + }); +}); + +test('Gemini adapter accepts an image-only user turn (no text part)', async () => { + const client = makeFakeGeminiClient({ text: 'A cat.' }); + const chat = makeGeminiChat({ client, model: 'gemini-3-flash-latest' }); + await chat.chat({ + messages: [{ role: 'user', content: '', images: [{ mimeType: 'image/png', data: 'YWJj' }] }], + }); + assert.deepStrictEqual(client.calls[0].contents[0].parts, [ + { inlineData: { mimeType: 'image/png', data: 'YWJj' } }, + ]); +}); + +test('Gemini adapter translates assistant tool calls and tool-role results to parts', async () => { + const client = makeFakeGeminiClient({ text: 'done' }); + const chat = makeGeminiChat({ client, model: 'gemini-3-flash-latest' }); + await chat.chat({ + messages: [ + { role: 'user', content: 'go' }, + { + role: 'assistant', + content: '', + toolCalls: [{ name: 'do_thing', args: { x: 1 } }], + }, + { role: 'tool', toolName: 'do_thing', content: 'result-1' }, + ], + }); + const contents = client.calls[0].contents; + assert.deepStrictEqual(contents[1], { + role: 'model', + parts: [{ functionCall: { name: 'do_thing', args: { x: 1 } } }], + }); + assert.deepStrictEqual(contents[2], { + role: 'user', + parts: [{ functionResponse: { name: 'do_thing', response: { result: 'result-1' } } }], + }); +}); diff --git a/test/chat.test.js b/test/chat.test.js index 8264e3e..b7ea435 100644 --- a/test/chat.test.js +++ b/test/chat.test.js @@ -1,81 +1,364 @@ import test from 'node:test'; import assert from 'node:assert'; -import { cleanLocalLlmResponse, handleMessage } from '../lib/chat.js'; +import { handleMessage } from '../lib/chat.js'; -test('cleanLocalLlmResponse passes text through when not a local LLM', () => { - const input = 'hello <|eot_id|> world'; - assert.strictEqual(cleanLocalLlmResponse(input, false), input); -}); - -test('cleanLocalLlmResponse strips tokenizer artifacts when isLocalLlm=true', () => { - const input = - '<|begin_of_text|>hello <|eot_id|> world<|end_of_text|> <|start_header_id|><|end_header_id|>'; - assert.strictEqual(cleanLocalLlmResponse(input, true), 'hello world'); -}); - -test('cleanLocalLlmResponse handles empty/undefined input safely', () => { - assert.strictEqual(cleanLocalLlmResponse('', true), ''); - assert.strictEqual(cleanLocalLlmResponse(undefined, true), undefined); -}); +// Minimal in-memory convoStore that quacks like Keyv (async get/set). +function makeFakeConvoStore(initial = {}) { + const data = new Map(Object.entries(initial)); + return { + data, + async get(key) { + return data.get(key); + }, + async set(key, value) { + data.set(key, value); + return true; + }, + }; +} function makeFakeChat({ reply = 'pong', shouldThrow = null } = {}) { const calls = []; - let nextId = 1; return { calls, - async sendMessage(text, opts) { - calls.push({ text, opts }); + async chat({ messages }) { + calls.push({ messages }); if (shouldThrow) throw shouldThrow; - return { id: `msg-${nextId++}`, text: reply }; + return { text: reply }; + }, + }; +} + +// A chat fake that walks through a predetermined sequence of responses +// (each call consumes one). Lets tests script multi-turn tool dispatch. +function makeScriptedChat(responses) { + const calls = []; + let i = 0; + return { + calls, + async chat({ messages, tools }) { + calls.push({ messages, tools }); + const next = responses[Math.min(i, responses.length - 1)]; + i++; + return next; }, }; } test('handleMessage returns an apology for empty text without calling chat', async () => { const chat = makeFakeChat(); - const parentIds = new Map(); - const result = await handleMessage({ text: '', user: 'U1' }, { chat, parentIds }); - assert.match(result, /cannot process an empty message/); + const result = await handleMessage( + { text: '', user: 'U1' }, + { chat, convoStore: makeFakeConvoStore() } + ); + assert.match(result.text, /cannot process an empty message/); assert.strictEqual(chat.calls.length, 0); }); -test('handleMessage threads conversation context per-user via parentIds', async () => { +test('handleMessage persists per-user history across calls', async () => { const chat = makeFakeChat({ reply: 'hi back' }); - const parentIds = new Map(); + const convoStore = makeFakeConvoStore(); + + const first = await handleMessage({ text: 'hi', user: 'U1' }, { chat, convoStore }); + assert.strictEqual(first.text, 'hi back'); + assert.deepStrictEqual(chat.calls[0].messages, [{ role: 'user', content: 'hi' }]); + + const second = await handleMessage({ text: 'still there?', user: 'U1' }, { chat, convoStore }); + assert.strictEqual(second.text, 'hi back'); + assert.deepStrictEqual(chat.calls[1].messages, [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hi back' }, + { role: 'user', content: 'still there?' }, + ]); + + const stored = await convoStore.get('convo:U1'); + assert.strictEqual(stored.length, 4); + assert.strictEqual(stored[3].content, 'hi back'); +}); - const first = await handleMessage({ text: 'hi', user: 'U1' }, { chat, parentIds }); - assert.strictEqual(first, 'hi back'); - assert.strictEqual(chat.calls[0].opts, undefined); +test('handleMessage keeps per-user history isolated', async () => { + const chat = makeFakeChat({ reply: 'reply' }); + const convoStore = makeFakeConvoStore(); - const second = await handleMessage({ text: 'still there?', user: 'U1' }, { chat, parentIds }); - assert.strictEqual(second, 'hi back'); - assert.strictEqual(chat.calls[1].opts.parentMessageId, 'msg-1'); + await handleMessage({ text: 'hi from U1', user: 'U1' }, { chat, convoStore }); + await handleMessage({ text: 'hi from U2', user: 'U2' }, { chat, convoStore }); - // A different user starts fresh. - await handleMessage({ text: 'new user', user: 'U2' }, { chat, parentIds }); - assert.strictEqual(chat.calls[2].opts, undefined); + assert.deepStrictEqual(chat.calls[1].messages, [{ role: 'user', content: 'hi from U2' }]); }); -test('handleMessage strips local LLM tokens when isLocalLlm=true', async () => { - const chat = makeFakeChat({ reply: '<|begin_of_text|>clean me<|eot_id|>' }); +test('handleMessage trims history to the configured limit', async () => { + const chat = makeFakeChat({ reply: 'r' }); + const convoStore = makeFakeConvoStore(); + + for (let i = 0; i < 5; i++) { + await handleMessage({ text: `msg ${i}`, user: 'U1' }, { chat, convoStore, historyLimit: 4 }); + } + + const stored = await convoStore.get('convo:U1'); + assert.strictEqual(stored.length, 4); + assert.strictEqual(stored[0].content, 'msg 3'); + assert.strictEqual(stored[2].content, 'msg 4'); +}); + +test('handleMessage rehydrates history from convoStore on cold start', async () => { + const convoStore = makeFakeConvoStore({ + 'convo:U1': [ + { role: 'user', content: 'remember this' }, + { role: 'assistant', content: 'noted' }, + ], + }); + const chat = makeFakeChat({ reply: 'ack' }); + + await handleMessage({ text: 'do you remember?', user: 'U1' }, { chat, convoStore }); + assert.deepStrictEqual(chat.calls[0].messages, [ + { role: 'user', content: 'remember this' }, + { role: 'assistant', content: 'noted' }, + { role: 'user', content: 'do you remember?' }, + ]); +}); + +test('handleMessage returns a friendly rephrase message on content/policy errors', async () => { + const err = new Error('blocked by safety filter'); + const chat = makeFakeChat({ shouldThrow: err }); const result = await handleMessage( { text: 'hi', user: 'U1' }, - { chat, parentIds: new Map(), isLocalLlm: true } + { chat, convoStore: makeFakeConvoStore() } ); - assert.strictEqual(result, 'clean me'); + assert.match(result.text, /rephrase your request/); }); -test('handleMessage returns a friendly rephrase message on 400-content errors', async () => { - const err = new Error('invalid content policy'); - err.statusCode = 400; - const chat = makeFakeChat({ shouldThrow: err }); - const result = await handleMessage({ text: 'hi', user: 'U1' }, { chat, parentIds: new Map() }); - assert.match(result, /rephrase your request/); +test('handleMessage falls back to the generic apology on other errors', async () => { + const chat = makeFakeChat({ shouldThrow: new Error('boom') }); + const result = await handleMessage( + { text: 'hi', user: 'U1' }, + { chat, convoStore: makeFakeConvoStore() } + ); + assert.match(result.text, /neural pathways/); }); -test('handleMessage falls back to the generic apology on other errors', async () => { +test('handleMessage does not persist history when the backend errors', async () => { + const convoStore = makeFakeConvoStore({ + 'convo:U1': [{ role: 'user', content: 'prior' }], + }); const chat = makeFakeChat({ shouldThrow: new Error('boom') }); - const result = await handleMessage({ text: 'hi', user: 'U1' }, { chat, parentIds: new Map() }); - assert.match(result, /neural pathways/); + await handleMessage({ text: 'hi', user: 'U1' }, { chat, convoStore }); + + const stored = await convoStore.get('convo:U1'); + assert.deepStrictEqual(stored, [{ role: 'user', content: 'prior' }]); +}); + +// --- Tool dispatch --------------------------------------------------------- + +test('handleMessage executes a tool call and feeds the result back to the model', async () => { + const toolCalls = []; + const tools = [ + { + name: 'echo', + description: 'returns its arg', + parameters: { type: 'object', properties: { x: { type: 'string' } }, required: ['x'] }, + async execute({ x }) { + toolCalls.push(x); + return `echoed: ${x}`; + }, + }, + ]; + const chat = makeScriptedChat([ + { text: '', toolCalls: [{ name: 'echo', args: { x: 'hello' } }] }, + { text: 'I echoed it for you.' }, + ]); + + const reply = await handleMessage( + { text: 'echo hello', user: 'U1' }, + { chat, convoStore: makeFakeConvoStore(), tools } + ); + + assert.strictEqual(reply.text, 'I echoed it for you.'); + assert.deepStrictEqual(toolCalls, ['hello']); + + // Second model call sees the assistant turn with tool_calls + the tool result. + const second = chat.calls[1].messages; + assert.strictEqual(second[1].role, 'assistant'); + assert.deepStrictEqual(second[1].toolCalls, [{ name: 'echo', args: { x: 'hello' } }]); + assert.strictEqual(second[2].role, 'tool'); + assert.strictEqual(second[2].toolName, 'echo'); + assert.strictEqual(second[2].content, 'echoed: hello'); +}); + +test('handleMessage persists only the final assistant text — tool traffic is ephemeral', async () => { + const tools = [ + { + name: 'noop', + description: 'does nothing', + parameters: { type: 'object', properties: {} }, + async execute() { + return 'ok'; + }, + }, + ]; + const chat = makeScriptedChat([ + { text: '', toolCalls: [{ name: 'noop', args: {} }] }, + { text: 'Final answer.' }, + ]); + const convoStore = makeFakeConvoStore(); + + await handleMessage({ text: 'go', user: 'U1' }, { chat, convoStore, tools }); + + const stored = await convoStore.get('convo:U1'); + // Only user + final assistant — no tool round-trip in persistent history. + assert.strictEqual(stored.length, 2); + assert.deepStrictEqual(stored, [ + { role: 'user', content: 'go' }, + { role: 'assistant', content: 'Final answer.' }, + ]); +}); + +test('handleMessage surfaces tool-execution errors as model-visible strings', async () => { + const tools = [ + { + name: 'boom', + description: '', + parameters: { type: 'object', properties: {} }, + async execute() { + throw new Error('kaboom'); + }, + }, + ]; + const chat = makeScriptedChat([ + { text: '', toolCalls: [{ name: 'boom', args: {} }] }, + { text: 'Sorry, that did not work.' }, + ]); + + const reply = await handleMessage( + { text: 'try it', user: 'U1' }, + { chat, convoStore: makeFakeConvoStore(), tools } + ); + assert.strictEqual(reply.text, 'Sorry, that did not work.'); + const toolMsg = chat.calls[1].messages.find((m) => m.role === 'tool'); + assert.match(toolMsg.content, /kaboom/); +}); + +test('handleMessage returns an unknown-tool error message when the model hallucinates a tool', async () => { + const chat = makeScriptedChat([ + { text: '', toolCalls: [{ name: 'nonexistent', args: {} }] }, + { text: 'Pretend that worked.' }, + ]); + + await handleMessage( + { text: 'go', user: 'U1' }, + { chat, convoStore: makeFakeConvoStore(), tools: [] } + ); + const toolMsg = chat.calls[1].messages.find((m) => m.role === 'tool'); + assert.match(toolMsg.content, /unknown tool/); +}); + +// --- Thinking ------------------------------------------------------------- + +test('handleMessage surfaces thinking from the backend, but does not persist it', async () => { + const chat = { + async chat() { + return { text: 'Four.', thinking: 'Computing 2+2 by recalling arithmetic facts.' }; + }, + }; + const convoStore = makeFakeConvoStore(); + const result = await handleMessage({ text: '2+2?', user: 'U1' }, { chat, convoStore }); + assert.strictEqual(result.text, 'Four.'); + assert.match(result.thinking, /Computing/); + + // Persisted history has just user + assistant text — no thinking trace. + const stored = await convoStore.get('convo:U1'); + assert.deepStrictEqual(stored, [ + { role: 'user', content: '2+2?' }, + { role: 'assistant', content: 'Four.' }, + ]); +}); + +test('handleMessage omits thinking from the result when backend returns none', async () => { + const chat = makeFakeChat({ reply: 'hello' }); + const result = await handleMessage( + { text: 'hi', user: 'U1' }, + { chat, convoStore: makeFakeConvoStore() } + ); + assert.strictEqual(result.thinking, undefined); +}); + +// --- Vision --------------------------------------------------------------- + +test('handleMessage attaches images to the user turn and strips them on persist', async () => { + const chat = makeFakeChat({ reply: 'I see a cat.' }); + const convoStore = makeFakeConvoStore(); + const images = [{ mimeType: 'image/png', data: 'YWJj' }]; + + const reply = await handleMessage( + { text: 'what is this?', user: 'U1', images }, + { chat, convoStore } + ); + + assert.strictEqual(reply.text, 'I see a cat.'); + // Image was on the wire turn. + assert.deepStrictEqual(chat.calls[0].messages[0].images, images); + // History stores only the text portion of the user turn. + const stored = await convoStore.get('convo:U1'); + assert.deepStrictEqual(stored, [ + { role: 'user', content: 'what is this?' }, + { role: 'assistant', content: 'I see a cat.' }, + ]); +}); + +test('handleMessage accepts images with empty text', async () => { + const chat = makeFakeChat({ reply: 'A cat.' }); + const convoStore = makeFakeConvoStore(); + const images = [{ mimeType: 'image/png', data: 'YWJj' }]; + const reply = await handleMessage({ text: '', user: 'U1', images }, { chat, convoStore }); + assert.strictEqual(reply.text, 'A cat.'); +}); + +test('handleMessage still rejects truly empty (no text, no images) input', async () => { + const chat = makeFakeChat(); + const reply = await handleMessage( + { text: '', user: 'U1' }, + { chat, convoStore: makeFakeConvoStore() } + ); + assert.match(reply.text, /cannot process an empty message/); + assert.strictEqual(chat.calls.length, 0); +}); + +test('handleMessage handles multiple tool calls in one turn', async () => { + const executed = []; + const tools = [ + { + name: 'a', + description: '', + parameters: { type: 'object', properties: {} }, + async execute() { + executed.push('a'); + return 'result-a'; + }, + }, + { + name: 'b', + description: '', + parameters: { type: 'object', properties: {} }, + async execute() { + executed.push('b'); + return 'result-b'; + }, + }, + ]; + const chat = makeScriptedChat([ + { + text: '', + toolCalls: [ + { name: 'a', args: {} }, + { name: 'b', args: {} }, + ], + }, + { text: 'Both done.' }, + ]); + + await handleMessage( + { text: 'go', user: 'U1' }, + { chat, convoStore: makeFakeConvoStore(), tools } + ); + assert.deepStrictEqual(executed, ['a', 'b']); }); diff --git a/test/responses.test.js b/test/responses.test.js index b256a2e..b2c08e0 100644 --- a/test/responses.test.js +++ b/test/responses.test.js @@ -4,14 +4,12 @@ import assert from 'node:assert'; import { ASIMOV_RULES, DANCE_PARTY_EMOJI, - IMAGE_REQUEST_GUIDANCE, buildDancePartyMessage, buildHelpText, fetchDadJoke, formatDadJoke, formatPodBayResponse, isDanceParty, - isImageRequest, isLoveYou, isPodBayDoor, isRickroll, @@ -65,18 +63,6 @@ test('isTikTok and isRickroll match their trigger phrases', () => { assert.ok(!isRickroll('rocknroll')); }); -test('isImageRequest catches several phrasings', () => { - assert.ok(isImageRequest('can you create an image of a cat')); - assert.ok(isImageRequest('please draw me a picture')); - assert.ok(isImageRequest('generate an illustration of a sunset')); - assert.ok(!isImageRequest('what is an image')); - assert.ok(!isImageRequest('')); -}); - -test('IMAGE_REQUEST_GUIDANCE points at the /image slash command', () => { - assert.match(IMAGE_REQUEST_GUIDANCE, /\/image/); -}); - test('buildHelpText interpolates the bot name and lists key commands', () => { const help = buildHelpText('Data'); assert.match(help, /@Data/); diff --git a/test/tools.test.js b/test/tools.test.js new file mode 100644 index 0000000..8afbc62 --- /dev/null +++ b/test/tools.test.js @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert'; + +import { makeTools } from '../lib/tools.js'; + +function makeCtx(overrides = {}) { + const calls = { uploads: [], posts: [], fetches: [], sleeps: [] }; + const ctx = { + geminiClient: null, + geminiImageModel: 'gemini-3.1-flash-image', + botName: 'data', + async slackUploadImage(args) { + calls.uploads.push(args); + }, + async slackPostBlocks(payload) { + calls.posts.push(payload); + }, + fetch: async () => ({ text: async () => 'a fake joke' }), + rng: () => 0.5, // pinned: no zinger (threshold is 0.05), deterministic emoji counts + sleep: async (ms) => { + calls.sleeps.push(ms); + }, + ...overrides, + }; + return { ctx, calls }; +} + +function byName(tools, name) { + return tools.find((t) => t.name === name); +} + +test('makeTools exposes the expected tool names', () => { + const { ctx } = makeCtx(); + const tools = makeTools(ctx); + const names = tools.map((t) => t.name).sort(); + assert.deepStrictEqual(names, [ + 'generate_image', + 'play_rickroll', + 'play_tiktok', + 'show_help', + 'start_dance_party', + 'state_asimovs_laws', + 'tell_dad_joke', + ]); +}); + +test('every tool has a name, description, and JSON-schema parameters object', () => { + const { ctx } = makeCtx(); + const tools = makeTools(ctx); + for (const t of tools) { + assert.ok(typeof t.name === 'string' && t.name.length > 0, `${t.name}: name`); + assert.ok(typeof t.description === 'string' && t.description.length > 0, `${t.name}: desc`); + assert.strictEqual(t.parameters.type, 'object', `${t.name}: parameters.type`); + assert.ok(t.parameters.properties, `${t.name}: parameters.properties`); + assert.strictEqual(typeof t.execute, 'function', `${t.name}: execute`); + } +}); + +test('generate_image refuses gracefully when no Gemini client is configured', async () => { + const { ctx, calls } = makeCtx(); + const tools = makeTools(ctx); + const result = await byName(tools, 'generate_image').execute({ prompt: 'cat' }); + assert.match(result, /GEMINI_API_KEY/); + assert.strictEqual(calls.uploads.length, 0); +}); + +test('generate_image calls Gemini, uploads the buffer, and returns a confirmation', async () => { + const fakeBuffer = Buffer.from('png-bytes'); + const geminiClient = { + models: { + async generateContent() { + return { + candidates: [ + { + content: { + parts: [ + { inlineData: { data: fakeBuffer.toString('base64'), mimeType: 'image/png' } }, + ], + }, + }, + ], + }; + }, + }, + }; + const { ctx, calls } = makeCtx({ geminiClient }); + const tools = makeTools(ctx); + const result = await byName(tools, 'generate_image').execute({ prompt: 'a sehlat' }); + + assert.match(result, /Image generated/); + assert.match(result, /sehlat/); + assert.strictEqual(calls.uploads.length, 1); + assert.strictEqual(calls.uploads[0].prompt, 'a sehlat'); + assert.ok(Buffer.isBuffer(calls.uploads[0].buffer)); +}); + +test('tell_dad_joke fetches a joke and posts it (no zinger)', async () => { + const { ctx, calls } = makeCtx({ + fetch: async () => ({ text: async () => 'Why did the chicken cross the road?' }), + }); + const tools = makeTools(ctx); + const result = await byName(tools, 'tell_dad_joke').execute(); + assert.match(result, /chicken/); + assert.match(calls.posts[0], /chicken/); + // rng pinned above the zinger threshold — no follow-up message, no sleep. + assert.strictEqual(calls.posts.length, 1); + assert.strictEqual(calls.sleeps.length, 0); +}); + +test('tell_dad_joke fires the zinger when rng falls in the lucky band', async () => { + const { ctx, calls } = makeCtx({ + fetch: async () => ({ text: async () => 'Why did the chicken cross the road?' }), + rng: () => 0.01, // below the 0.05 zinger threshold + }); + const tools = makeTools(ctx); + await byName(tools, 'tell_dad_joke').execute(); + assert.strictEqual(calls.posts.length, 2); + assert.deepStrictEqual(calls.sleeps, [10000]); +}); + +test('state_asimovs_laws posts the four laws', async () => { + const { ctx, calls } = makeCtx(); + const tools = makeTools(ctx); + await byName(tools, 'state_asimovs_laws').execute(); + assert.strictEqual(calls.posts.length, 1); + for (const n of [0, 1, 2, 3]) assert.match(calls.posts[0], new RegExp(`^${n}\\.`, 'm')); +}); + +test('show_help posts the help text containing the bot name', async () => { + const { ctx, calls } = makeCtx(); + const tools = makeTools(ctx); + await byName(tools, 'show_help').execute(); + assert.match(calls.posts[0], /@data/); +}); + +test('start_dance_party, play_rickroll, play_tiktok each post a payload', async () => { + const { ctx, calls } = makeCtx(); + const tools = makeTools(ctx); + await byName(tools, 'start_dance_party').execute(); + await byName(tools, 'play_rickroll').execute(); + await byName(tools, 'play_tiktok').execute(); + assert.strictEqual(calls.posts.length, 3); +});