From ef7306c04eea15f3100010cea30328c81a91ede9 Mon Sep 17 00:00:00 2001 From: Sean Carolan Date: Sun, 14 Jun 2026 19:03:20 +0000 Subject: [PATCH 1/6] Replace OpenAI compat layer with native Ollama + Gemini chat backends Drops the chatgpt package and the openai SDK in favor of provider-native clients. The OpenAI chat/completions wire format was a 2023-vintage lowest-common-denominator that blocked modern features (thinking, native tools, structured output, vision, free telemetry); none of which the bot uses today, but none of which it could ever use through the compat shim. Architecture changes: - New lib/chat-backends.js exposes makeOllamaChat() and makeGeminiChat() factories. Each returns a uniform { chat({ messages }) -> { text } } interface. Adapters bind model + system message at construction and own all wire-format translation; handleMessage is backend-agnostic. - lib/chat.js drops the chatgpt-specific parentId Map. Conversation history is now persisted in Keyv as a per-user [{role, content}] array under "convo:", so chats survive process restarts. History is trimmed to historyLimit (default 20 messages) per turn. - lib/deps.js picks the backend via CHAT_BACKEND (default "ollama"). Reuses the existing @google/genai client for the Gemini chat path so we don't construct two of them. Env changes: - Drop OPENAI_API_KEY, LLM_API_BASE_URL, LLM_MODEL, MEMORY_MAX_KEYS. - Add CHAT_BACKEND, OLLAMA_HOST, OLLAMA_MODEL, GEMINI_CHAT_MODEL. Verified end-to-end against http://kepler.local:11434 with gemma4:31b: single-turn replies in character; two-turn conversation correctly recalled the user's earlier statement via the persisted history. 36 tests pass (8 new for the adapter wire-format translation), lint clean, syntax-check clean on all source files. Co-Authored-By: Claude Opus 4.7 --- .env.example | 25 +- ARCHITECTURE.md | 34 +- CLAUDE.md | 78 ++-- app.js | 11 +- lib/chat-backends.js | 68 ++++ lib/chat.js | 60 +-- lib/deps.js | 78 ++-- package-lock.json | 778 +------------------------------------ package.json | 3 +- test/app.test.js | 1 - test/chat-backends.test.js | 129 ++++++ test/chat.test.js | 145 +++++-- 12 files changed, 479 insertions(+), 931 deletions(-) create mode 100644 lib/chat-backends.js create mode 100644 test/chat-backends.test.js diff --git a/.env.example b/.env.example index cad4462..4b7a144 100644 --- a/.env.example +++ b/.env.example @@ -3,22 +3,27 @@ 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 + +# 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..2a82781 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,33 @@ 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() (~300 lines) 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 + chat-backends.js # makeOllamaChat() / makeGeminiChat() adapter factories + 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 + chat-backends.test.js # Ollama + Gemini adapter wire-format translation + 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 +56,22 @@ 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 })` | `lib/chat.js` | Route Slack message through the chat adapter, persist per-user history | | `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 | | `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 +86,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`) +- `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 +- `BOT_PERSONALITY` — Custom system prompt - `THINKING_MESSAGE` — Custom processing indicator text - `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 +109,10 @@ 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 ## Canned Responses @@ -116,14 +130,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..70b0336 100644 --- a/app.js +++ b/app.js @@ -22,7 +22,7 @@ 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 { ASIMOV_RULES, @@ -42,7 +42,7 @@ import { 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.'; @@ -79,13 +79,12 @@ export function registerHandlers(deps) { const { app, chat, + convoStore, geminiClient, geminiImageModel, - isLocalLlm, botName, botToken, thinkingMessage, - parentIds, } = deps; app.message(async ({ message, say, context }) => { @@ -135,7 +134,7 @@ export function registerHandlers(deps) { let thinking = null; try { thinking = await postThinking(say, thinkingMessage); - const responseText = await handleMessage(message, { chat, parentIds, isLocalLlm }); + const responseText = await handleMessage(message, { chat, convoStore }); if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); await say(responseText); } catch (error) { @@ -194,7 +193,7 @@ export function registerHandlers(deps) { let thinking = null; try { thinking = await postThinking(say, thinkingMessage); - const responseText = await handleMessage(message, { chat, parentIds, isLocalLlm }); + const responseText = await handleMessage(message, { chat, convoStore }); if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); await say(responseText); } catch (error) { diff --git a/lib/chat-backends.js b/lib/chat-backends.js new file mode 100644 index 0000000..d2c0e3a --- /dev/null +++ b/lib/chat-backends.js @@ -0,0 +1,68 @@ +// Chat backend adapters. Each factory returns an object with a uniform +// `chat({ messages })` method that takes our canonical message shape +// (`[{ role: 'system' | 'user' | 'assistant', content }]`) and returns +// `{ text }`. The handleMessage code in lib/chat.js never has to know +// which backend it is talking to. +// +// Backends bind model + system message at construction time so handleMessage +// stays thin and the adapter owns all wire-format translation. + +import { Ollama } from 'ollama'; + +// Llama-family tokenizers sometimes leak special tokens into the response +// text. Strip them at the adapter boundary so the rest of the app never sees +// them — including the message we write back into conversation history. +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) ----------------------------------------------------- +// Native Ollama SDK. Supports `think`, native tool calling, vision via +// `images`, structured output via `format`, and free telemetry. We only use +// the basic chat path for now; the adapter shape leaves room to surface the +// rest. +export function makeOllamaChat({ host, model, systemMessage, client }) { + const ollama = client || new Ollama({ host }); + return { + backend: 'ollama', + model, + async chat({ messages }) { + const wire = systemMessage + ? [{ role: 'system', content: systemMessage }, ...messages] + : messages; + const response = await ollama.chat({ model, messages: wire, stream: false }); + return { text: stripLlamaTokens(response?.message?.content || '') }; + }, + }; +} + +// --- Gemini --------------------------------------------------------------- +// Uses the @google/genai client already constructed for image generation. +// Gemini's wire shape is different: the role is 'model' (not 'assistant'), +// messages are `{ role, parts: [{ text }] }`, and the system message lives in +// `config.systemInstruction` rather than the contents array. +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 }) { + const contents = messages.map((m) => ({ + role: m.role === 'assistant' ? 'model' : 'user', + parts: [{ text: m.content }], + })); + const response = await client.models.generateContent({ + model, + contents, + ...(systemMessage ? { config: { systemInstruction: systemMessage } } : {}), + }); + return { text: response?.text || '' }; + }, + }; +} diff --git a/lib/chat.js b/lib/chat.js index 631d5b0..a994ac3 100644 --- a/lib/chat.js +++ b/lib/chat.js @@ -1,39 +1,53 @@ -// 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. History survives +// process restarts because we own all the state — no reliance on any +// provider's server-side conversation tracking. -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; -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 }) { +export async function handleMessage( + message, + { chat, convoStore, historyLimit = DEFAULT_HISTORY_LIMIT } +) { if (!message.text) { return 'I apologize, but I cannot process an empty message. How may I assist you?'; } - const userId = message.user; + const key = convoKey(message.user); + const history = (await convoStore.get(key)) || []; + const turn = { role: 'user', content: message.text }; try { - let response; - if (parentIds.has(userId)) { - response = await chat.sendMessage(message.text, { parentMessageId: parentIds.get(userId) }); - } else { - response = await chat.sendMessage(message.text); - } - parentIds.set(userId, response.id); - return cleanLocalLlmResponse(response.text, isLocalLlm); + const { text } = await chat.chat({ messages: [...history, turn] }); + + const nextHistory = [...history, turn, { role: 'assistant', content: text }].slice( + -historyLimit + ); + await convoStore.set(key, nextHistory); + + return text; } catch (error) { console.error('Error in handleMessage:', error); - if (error.statusCode === 400 && error.message && error.message.includes('content')) { + if (looksLikeContentError(error)) { return 'I apologize, but I encountered an issue processing your message. Could you please rephrase your request?'; } return GENERIC_ERROR_TEXT; diff --git a/lib/deps.js b/lib/deps.js index 565ddd5..718f263 100644 --- a/lib/deps.js +++ b/lib/deps.js @@ -3,14 +3,22 @@ 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'; +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,7 +28,7 @@ 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.`; @@ -29,15 +37,13 @@ export function buildDeps(overrides = {}, env = process.env) { 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 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 +53,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 +69,29 @@ 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 }); + console.log(`Chat backend: ollama @ ${ollamaHost} (model: ${ollamaModel})`); + } 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/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..adcaa7b --- /dev/null +++ b/test/chat-backends.test.js @@ -0,0 +1,129 @@ +import test from 'node:test'; +import assert from 'node:assert'; + +import { makeOllamaChat, makeGeminiChat } from '../lib/chat-backends.js'; + +// --- Ollama -------------------------------------------------------------- + +function makeFakeOllama(reply) { + const calls = []; + return { + calls, + async chat(req) { + calls.push(req); + if (reply instanceof Error) throw reply; + return { message: { role: 'assistant', content: reply } }; + }, + }; +} + +test('Ollama adapter prepends the system message and forwards the model', async () => { + const ollama = makeFakeOllama('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('<|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('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', + }); +}); + +// --- Gemini -------------------------------------------------------------- + +function makeFakeGeminiClient(reply) { + const calls = []; + return { + calls, + models: { + async generateContent(req) { + calls.push(req); + if (reply instanceof Error) throw reply; + return { text: reply }; + }, + }, + }; +} + +test('Gemini adapter translates roles and lifts the system message', async () => { + const client = makeFakeGeminiClient('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 is configured', async () => { + const client = makeFakeGeminiClient('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/ + ); +}); diff --git a/test/chat.test.js b/test/chat.test.js index 8264e3e..ac21ddb 100644 --- a/test/chat.test.js +++ b/test/chat.test.js @@ -1,81 +1,140 @@ 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 }; }, }; } 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 }); + const result = await handleMessage( + { text: '', user: 'U1' }, + { chat, convoStore: makeFakeConvoStore() } + ); assert.match(result, /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, parentIds }); + const first = await handleMessage({ text: 'hi', user: 'U1' }, { chat, convoStore }); assert.strictEqual(first, 'hi back'); - assert.strictEqual(chat.calls[0].opts, undefined); + // First turn: history is just the new user message. + assert.deepStrictEqual(chat.calls[0].messages, [{ role: 'user', content: 'hi' }]); - const second = await handleMessage({ text: 'still there?', user: 'U1' }, { chat, parentIds }); + const second = await handleMessage({ text: 'still there?', user: 'U1' }, { chat, convoStore }); assert.strictEqual(second, 'hi back'); - assert.strictEqual(chat.calls[1].opts.parentMessageId, 'msg-1'); + // Second turn: history includes both prior turns plus the new user message. + assert.deepStrictEqual(chat.calls[1].messages, [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hi back' }, + { role: 'user', content: 'still there?' }, + ]); - // A different user starts fresh. - await handleMessage({ text: 'new user', user: 'U2' }, { chat, parentIds }); - assert.strictEqual(chat.calls[2].opts, undefined); + // Stored history reflects the post-call state. + const stored = await convoStore.get('convo:U1'); + assert.strictEqual(stored.length, 4); + assert.strictEqual(stored[3].content, 'hi back'); }); -test('handleMessage strips local LLM tokens when isLocalLlm=true', async () => { - const chat = makeFakeChat({ reply: '<|begin_of_text|>clean me<|eot_id|>' }); - const result = await handleMessage( - { text: 'hi', user: 'U1' }, - { chat, parentIds: new Map(), isLocalLlm: true } - ); - assert.strictEqual(result, 'clean me'); +test('handleMessage keeps per-user history isolated', async () => { + const chat = makeFakeChat({ reply: 'reply' }); + const convoStore = makeFakeConvoStore(); + + await handleMessage({ text: 'hi from U1', user: 'U1' }, { chat, convoStore }); + await handleMessage({ text: 'hi from U2', user: 'U2' }, { chat, convoStore }); + + // U2's first call must not see U1's history. + assert.deepStrictEqual(chat.calls[1].messages, [{ role: 'user', content: 'hi from U2' }]); }); -test('handleMessage returns a friendly rephrase message on 400-content errors', async () => { - const err = new Error('invalid content policy'); - err.statusCode = 400; +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'); + // Each turn appends 2 messages; with limit=4 we keep only the last 2 turns. + 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 () => { + // Simulates a process restart: history is already in Redis from a prior session. + 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() }); + const result = await handleMessage( + { text: 'hi', user: 'U1' }, + { chat, convoStore: makeFakeConvoStore() } + ); 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, parentIds: new Map() }); + const result = await handleMessage( + { text: 'hi', user: 'U1' }, + { chat, convoStore: makeFakeConvoStore() } + ); assert.match(result, /neural pathways/); }); + +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') }); + await handleMessage({ text: 'hi', user: 'U1' }, { chat, convoStore }); + + const stored = await convoStore.get('convo:U1'); + // Prior history is intact; failed turn was not appended. + assert.deepStrictEqual(stored, [{ role: 'user', content: 'prior' }]); +}); From 132d6315f02f9b0134071e47328c0081da469df8 Mon Sep 17 00:00:00 2001 From: Sean Carolan Date: Sun, 14 Jun 2026 19:26:04 +0000 Subject: [PATCH 2/6] Add native tool calling, kill the image-request guidance shim (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines a small tool registry (lib/tools.js) that Data can choose from on each turn: generate_image, tell_dad_joke, state_asimovs_laws, show_help, start_dance_party, play_rickroll, play_tiktok. handleMessage gains a dispatch loop (capped at 5 iterations) that runs tool calls and feeds results back as tool-role messages until the model returns plain text. Both chat adapters now translate the canonical { tools, toolCalls } shape to/from their native wire format: - Ollama: { type: 'function', function: {...} } and tool_calls on the assistant message; tool-role results carry tool_name. - Gemini: functionDeclarations under config.tools, functionCall parts in candidates, functionResponse parts for tool results. Tool round-trip traffic stays ephemeral inside the dispatch loop — only the final assistant text is persisted to convoStore so conversation history remains a clean user/assistant alternation. Per-message tool registries are built in app.js so closures bind the right Slack channel for upload + post side effects. The makeTools factory accepts rng and sleep so tests can pin the dad-joke zinger without 10-second waits. Drops the isImageRequest matcher + IMAGE_REQUEST_GUIDANCE shim entirely — Data now just generates the image when asked instead of telling the user to type /image. Verified live against gemma4:31b on kepler.local: - "Draw me a picture of a sehlat." -> tool call with a rich prompt, upload posted, reply "I have generated an image of a sehlat for you." - "What is 2 + 2?" -> "Four." (no tool calls when not needed) 53 tests pass (17 new across chat/chat-backends/tools/responses), lint clean. Closes #25 Co-Authored-By: Claude Opus 4.7 --- app.js | 64 ++++++++------ lib/chat-backends.js | 147 +++++++++++++++++++++++++------- lib/chat.js | 76 +++++++++++++++-- lib/responses.js | 9 -- lib/tools.js | 123 +++++++++++++++++++++++++++ test/chat-backends.test.js | 166 ++++++++++++++++++++++++++++++++---- test/chat.test.js | 167 +++++++++++++++++++++++++++++++++++-- test/responses.test.js | 14 ---- test/tools.test.js | 143 +++++++++++++++++++++++++++++++ 9 files changed, 801 insertions(+), 108 deletions(-) create mode 100644 lib/tools.js create mode 100644 test/tools.test.js diff --git a/app.js b/app.js index 70b0336..93c6646 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 { 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,7 +22,6 @@ import { formatDadJoke, formatPodBayResponse, isDanceParty, - isImageRequest, isLoveYou, isPodBayDoor, isRickroll, @@ -74,6 +60,35 @@ async function clearThinking(app, channel, ts) { } } +// 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) { + const { app, geminiClient, geminiImageModel, botToken, botName } = deps; + return makeTools({ + geminiClient, + geminiImageModel, + botName, + fetch, + async slackUploadImage({ buffer, prompt }) { + await app.client.files.uploadV2({ + token: botToken, + channel_id: channel, + 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 }; + 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 { @@ -134,7 +149,8 @@ export function registerHandlers(deps) { let thinking = null; try { thinking = await postThinking(say, thinkingMessage); - const responseText = await handleMessage(message, { chat, convoStore }); + const tools = buildToolsFor(deps, message.channel); + const responseText = await handleMessage(message, { chat, convoStore, tools }); if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); await say(responseText); } catch (error) { @@ -185,15 +201,11 @@ export function registerHandlers(deps) { return; } - if (isImageRequest(message.text)) { - await say(IMAGE_REQUEST_GUIDANCE); - return; - } - let thinking = null; try { thinking = await postThinking(say, thinkingMessage); - const responseText = await handleMessage(message, { chat, convoStore }); + const tools = buildToolsFor(deps, message.channel); + const responseText = await handleMessage(message, { chat, convoStore, tools }); if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); await say(responseText); } catch (error) { diff --git a/lib/chat-backends.js b/lib/chat-backends.js index d2c0e3a..fe18c35 100644 --- a/lib/chat-backends.js +++ b/lib/chat-backends.js @@ -1,17 +1,18 @@ // Chat backend adapters. Each factory returns an object with a uniform -// `chat({ messages })` method that takes our canonical message shape -// (`[{ role: 'system' | 'user' | 'assistant', content }]`) and returns -// `{ text }`. The handleMessage code in lib/chat.js never has to know -// which backend it is talking to. +// `chat({ messages, tools }) → { text, toolCalls? }` method. // -// Backends bind model + system message at construction time so handleMessage -// stays thin and the adapter owns all wire-format translation. +// 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 -// text. Strip them at the adapter boundary so the rest of the app never sees -// them — including the message we write back into conversation history. +// 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; @@ -21,30 +22,115 @@ function stripLlamaTokens(text) { } // --- Ollama (default) ----------------------------------------------------- -// Native Ollama SDK. Supports `think`, native tool calling, vision via -// `images`, structured output via `format`, and free telemetry. We only use -// the basic chat path for now; the adapter shape leaves room to surface the -// rest. + +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 }, + })), + }; + } + return { role: m.role, content: m.content }; +} + +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, client }) { const ollama = client || new Ollama({ host }); return { backend: 'ollama', model, - async chat({ messages }) { + async chat({ messages, tools }) { const wire = systemMessage - ? [{ role: 'system', content: systemMessage }, ...messages] - : messages; - const response = await ollama.chat({ model, messages: wire, stream: false }); - return { text: stripLlamaTokens(response?.message?.content || '') }; + ? [{ role: 'system', content: systemMessage }, ...messages.map(toOllamaMessage)] + : messages.map(toOllamaMessage); + const response = await ollama.chat({ + model, + messages: wire, + tools: toOllamaTools(tools), + stream: false, + }); + const text = stripLlamaTokens(response?.message?.content || ''); + const raw = response?.message?.tool_calls || []; + const toolCalls = raw.map((tc) => ({ + name: tc.function?.name, + args: tc.function?.arguments || {}, + })); + return toolCalls.length ? { text, toolCalls } : { text }; }, }; } // --- Gemini --------------------------------------------------------------- -// Uses the @google/genai client already constructed for image generation. -// Gemini's wire shape is different: the role is 'model' (not 'assistant'), -// messages are `{ role, parts: [{ text }] }`, and the system message lives in -// `config.systemInstruction` rather than the contents array. + +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 }; + } + return { + role: m.role === 'assistant' ? 'model' : 'user', + parts: [{ text: m.content }], + }; +} + +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'); @@ -52,17 +138,20 @@ export function makeGeminiChat({ client, model, systemMessage }) { return { backend: 'gemini', model, - async chat({ messages }) { - const contents = messages.map((m) => ({ - role: m.role === 'assistant' ? 'model' : 'user', - parts: [{ text: m.content }], - })); + 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, - ...(systemMessage ? { config: { systemInstruction: systemMessage } } : {}), + ...(Object.keys(config).length ? { config } : {}), }); - return { text: response?.text || '' }; + 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 a994ac3..541d6d3 100644 --- a/lib/chat.js +++ b/lib/chat.js @@ -1,13 +1,17 @@ // 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. History survives -// process restarts because we own all the state — no reliance on any -// provider's server-side conversation tracking. +// 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. 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; function convoKey(userId) { return `convo:${userId}`; @@ -24,9 +28,41 @@ function looksLikeContentError(error) { ); } +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; +} + export async function handleMessage( message, - { chat, convoStore, historyLimit = DEFAULT_HISTORY_LIMIT } + { chat, convoStore, tools, historyLimit = DEFAULT_HISTORY_LIMIT } ) { if (!message.text) { return 'I apologize, but I cannot process an empty message. How may I assist you?'; @@ -34,17 +70,41 @@ export async function handleMessage( const key = convoKey(message.user); const history = (await convoStore.get(key)) || []; - const turn = { role: 'user', content: message.text }; + const userTurn = { role: 'user', content: message.text }; + + // 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 { - const { text } = await chat.chat({ messages: [...history, turn] }); + let finalText = ''; + for (let iter = 0; iter < MAX_TOOL_ITERATIONS; iter++) { + const result = await chat.chat({ messages: working, tools }); + const { text, toolCalls } = result; + + if (!toolCalls?.length) { + finalText = text || ''; + break; + } + + working.push({ role: 'assistant', content: text || '', 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 || ''; + } + } - const nextHistory = [...history, turn, { role: 'assistant', content: text }].slice( + const nextHistory = [...history, userTurn, { role: 'assistant', content: finalText }].slice( -historyLimit ); await convoStore.set(key, nextHistory); - return text; + return finalText; } catch (error) { console.error('Error in handleMessage:', error); if (looksLikeContentError(error)) { 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/test/chat-backends.test.js b/test/chat-backends.test.js index adcaa7b..39a993d 100644 --- a/test/chat-backends.test.js +++ b/test/chat-backends.test.js @@ -5,29 +5,34 @@ import { makeOllamaChat, makeGeminiChat } from '../lib/chat-backends.js'; // --- Ollama -------------------------------------------------------------- -function makeFakeOllama(reply) { +function makeFakeOllama(replyOrFn) { const calls = []; return { calls, async chat(req) { calls.push(req); - if (reply instanceof Error) throw reply; - return { message: { role: 'assistant', content: reply } }; + 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('hello'); + 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' }], - }); + const { text } = await chat.chat({ messages: [{ role: 'user', content: 'hi' }] }); assert.strictEqual(text, 'hello'); assert.strictEqual(ollama.calls[0].model, 'llama3.1'); @@ -39,14 +44,14 @@ test('Ollama adapter prepends the system message and forwards the model', async }); test('Ollama adapter strips Llama tokenizer artifacts from replies', async () => { - const ollama = makeFakeOllama('<|begin_of_text|>clean me<|eot_id|>'); + 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('ok'); + 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' }]); @@ -71,24 +76,90 @@ test('Ollama adapter propagates errors from the underlying client', async () => }); }); +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 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(reply) { +function makeFakeGeminiClient(replyOrFn) { const calls = []; return { calls, models: { async generateContent(req) { calls.push(req); - if (reply instanceof Error) throw reply; - return { text: reply }; + 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('hi back'); + const client = makeFakeGeminiClient({ text: 'hi back' }); const chat = makeGeminiChat({ client, model: 'gemini-3-flash-latest', @@ -114,8 +185,8 @@ test('Gemini adapter translates roles and lifts the system message', async () => assert.deepStrictEqual(req.config, { systemInstruction: 'You are Data.' }); }); -test('Gemini adapter omits config when no system message is configured', async () => { - const client = makeFakeGeminiClient('ok'); +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); @@ -127,3 +198,68 @@ test('Gemini adapter throws if constructed without a client', () => { /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 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 ac21ddb..5f5d766 100644 --- a/test/chat.test.js +++ b/test/chat.test.js @@ -30,6 +30,22 @@ function makeFakeChat({ reply = 'pong', shouldThrow = null } = {}) { }; } +// 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 result = await handleMessage( @@ -46,19 +62,16 @@ test('handleMessage persists per-user history across calls', async () => { const first = await handleMessage({ text: 'hi', user: 'U1' }, { chat, convoStore }); assert.strictEqual(first, 'hi back'); - // First turn: history is just the new user message. assert.deepStrictEqual(chat.calls[0].messages, [{ role: 'user', content: 'hi' }]); const second = await handleMessage({ text: 'still there?', user: 'U1' }, { chat, convoStore }); assert.strictEqual(second, 'hi back'); - // Second turn: history includes both prior turns plus the new user message. assert.deepStrictEqual(chat.calls[1].messages, [ { role: 'user', content: 'hi' }, { role: 'assistant', content: 'hi back' }, { role: 'user', content: 'still there?' }, ]); - // Stored history reflects the post-call state. const stored = await convoStore.get('convo:U1'); assert.strictEqual(stored.length, 4); assert.strictEqual(stored[3].content, 'hi back'); @@ -71,7 +84,6 @@ test('handleMessage keeps per-user history isolated', async () => { await handleMessage({ text: 'hi from U1', user: 'U1' }, { chat, convoStore }); await handleMessage({ text: 'hi from U2', user: 'U2' }, { chat, convoStore }); - // U2's first call must not see U1's history. assert.deepStrictEqual(chat.calls[1].messages, [{ role: 'user', content: 'hi from U2' }]); }); @@ -84,14 +96,12 @@ test('handleMessage trims history to the configured limit', async () => { } const stored = await convoStore.get('convo:U1'); - // Each turn appends 2 messages; with limit=4 we keep only the last 2 turns. 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 () => { - // Simulates a process restart: history is already in Redis from a prior session. const convoStore = makeFakeConvoStore({ 'convo:U1': [ { role: 'user', content: 'remember this' }, @@ -135,6 +145,149 @@ test('handleMessage does not persist history when the backend errors', async () await handleMessage({ text: 'hi', user: 'U1' }, { chat, convoStore }); const stored = await convoStore.get('convo:U1'); - // Prior history is intact; failed turn was not appended. 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, '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, '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/); +}); + +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); +}); From 868a212c3f9be8a374539365602ce71e47a3779f Mon Sep 17 00:00:00 2001 From: Sean Carolan Date: Sun, 14 Jun 2026 19:32:14 +0000 Subject: [PATCH 3/6] Add vision: pipe attached Slack images into the LLM (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects image attachments on incoming Slack messages, fetches each via the Slack file URL with the bot token, base64-encodes and attaches to the user turn in the canonical chat shape as `images: [{mimeType, data}]`. Both adapters translate to their native wire format: - Ollama: Message.images is a list of base64 strings (mimeType ignored). - Gemini: parts get { inlineData: { mimeType, data } } interleaved with the text part. Image bytes are NOT persisted to convoStore — only the text portion of the user turn lands in history. The Slack file URLs would expire even if we did, and the bytes are big. Empty text plus images is now a valid turn (the model handles "what is this?" implicitly). Truly empty messages (no text, no files) still short-circuit before reaching the LLM. In app.js, both the DM/MPIM and direct-mention handlers now run `extractMessageImages` before calling handleMessage, gated on the message having `files` of `image/png|jpeg|webp|gif` mimetype. Failed file fetches log a warning and skip. 7 new tests covering handleMessage image attach + persistence stripping, empty-text-with-images, and both adapter translations. Closes #26 Co-Authored-By: Claude Opus 4.7 --- app.js | 41 ++++++++++++++++++++++++--- lib/chat-backends.js | 17 +++++++++-- lib/chat.js | 19 +++++++++---- test/chat-backends.test.js | 58 ++++++++++++++++++++++++++++++++++++++ test/chat.test.js | 41 +++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 11 deletions(-) diff --git a/app.js b/app.js index 93c6646..2142acc 100644 --- a/app.js +++ b/app.js @@ -60,6 +60,33 @@ async function clearThinking(app, channel, ts) { } } +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. @@ -143,14 +170,17 @@ 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; try { thinking = await postThinking(say, thinkingMessage); + const images = await extractMessageImages(message, botToken); const tools = buildToolsFor(deps, message.channel); - const responseText = await handleMessage(message, { chat, convoStore, tools }); + const responseText = await handleMessage({ ...message, images }, { chat, convoStore, tools }); if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); await say(responseText); } catch (error) { @@ -190,7 +220,9 @@ export function registerHandlers(deps) { 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.thread_ts || @@ -204,8 +236,9 @@ export function registerHandlers(deps) { let thinking = null; try { thinking = await postThinking(say, thinkingMessage); + const images = await extractMessageImages(message, botToken); const tools = buildToolsFor(deps, message.channel); - const responseText = await handleMessage(message, { chat, convoStore, tools }); + const responseText = await handleMessage({ ...message, images }, { chat, convoStore, tools }); if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); await say(responseText); } catch (error) { diff --git a/lib/chat-backends.js b/lib/chat-backends.js index fe18c35..5de2ef1 100644 --- a/lib/chat-backends.js +++ b/lib/chat-backends.js @@ -36,7 +36,12 @@ function toOllamaMessage(m) { })), }; } - return { role: m.role, content: m.content }; + 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) { @@ -101,9 +106,17 @@ function toGeminiContent(m) { } 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: [{ text: m.content }], + parts, }; } diff --git a/lib/chat.js b/lib/chat.js index 541d6d3..37f7016 100644 --- a/lib/chat.js +++ b/lib/chat.js @@ -64,13 +64,16 @@ export async function handleMessage( message, { chat, convoStore, tools, historyLimit = DEFAULT_HISTORY_LIMIT } ) { - if (!message.text) { + const text = message.text || ''; + const images = message.images || []; + if (!text && !images.length) { return '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: message.text }; + 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 @@ -99,9 +102,15 @@ export async function handleMessage( } } - const nextHistory = [...history, userTurn, { role: 'assistant', content: finalText }].slice( - -historyLimit - ); + // 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. + const persistedUserTurn = { role: 'user', content: text }; + const nextHistory = [ + ...history, + persistedUserTurn, + { role: 'assistant', content: finalText }, + ].slice(-historyLimit); await convoStore.set(key, nextHistory); return finalText; diff --git a/test/chat-backends.test.js b/test/chat-backends.test.js index 39a993d..b55be8b 100644 --- a/test/chat-backends.test.js +++ b/test/chat-backends.test.js @@ -134,6 +134,28 @@ test('Ollama adapter translates assistant turns with tool_calls + tool-role resu assert.deepStrictEqual(wire[2], { role: 'tool', content: 'result-1', tool_name: 'do_thing' }); }); +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 }); @@ -239,6 +261,42 @@ test('Gemini adapter translates tools to functionDeclarations and surfaces funct ]); }); +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' }); diff --git a/test/chat.test.js b/test/chat.test.js index 5f5d766..03b9d75 100644 --- a/test/chat.test.js +++ b/test/chat.test.js @@ -252,6 +252,47 @@ test('handleMessage returns an unknown-tool error message when the model halluci assert.match(toolMsg.content, /unknown tool/); }); +// --- 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, '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, '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, /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 = [ From 372988a47021b71a33349a7f01fde03690b0c2cb Mon Sep 17 00:00:00 2001 From: Sean Carolan Date: Sun, 14 Jun 2026 19:36:07 +0000 Subject: [PATCH 4/6] Surface model thinking traces for reasoning models (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleMessage now returns { text, thinking? } instead of a bare string. When the Ollama backend emits message.thinking (qwq, gpt-oss, deepseek-r1, etc.), app.js renders the reply as a Slack Block Kit message with a context block showing an italicized "Thinking: …" trace above the actual reply text. Wiring: - New OLLAMA_THINK env var. Accepts true/false (or 1/0/yes/no) and low/medium/high. Parsed in deps.js, passed to makeOllamaChat at construction time. - Ollama adapter forwards the think param on every chat() call and surfaces response.message.thinking as result.thinking. - handleMessage carries the trace through the tool dispatch loop — only the final non-tool response's thinking is surfaced. - Thinking traces are NOT persisted to convoStore: ephemeral metadata, same as tool call traffic. - Long traces are clipped to 1200 chars in the Slack context block to keep messages readable. Gemini support for thinking deferred — gemini-3-flash-latest has a thinking field but its API shape is still settling; can be added to makeGeminiChat without further changes to handleMessage. 6 new tests across handleMessage + Ollama adapter (think param wiring, trace surfacing, persistence stripping). Closes #27 Co-Authored-By: Claude Opus 4.7 --- .env.example | 4 +++ app.js | 26 +++++++++++++++++--- lib/chat-backends.js | 9 +++++-- lib/chat.js | 26 ++++++++++++++------ lib/deps.js | 22 +++++++++++++++-- test/chat-backends.test.js | 34 ++++++++++++++++++++++++++ test/chat.test.js | 50 ++++++++++++++++++++++++++++++-------- 7 files changed, 145 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index 4b7a144..59be40f 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,10 @@ GEMINI_API_KEY= # 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 diff --git a/app.js b/app.js index 2142acc..bab565e 100644 --- a/app.js +++ b/app.js @@ -51,6 +51,24 @@ async function postThinking(say, thinkingMessage, visibleText = thinkingMessage) } } +// 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 clearThinking(app, channel, ts) { if (!ts) return; try { @@ -180,9 +198,9 @@ export function registerHandlers(deps) { thinking = await postThinking(say, thinkingMessage); const images = await extractMessageImages(message, botToken); const tools = buildToolsFor(deps, message.channel); - const responseText = await handleMessage({ ...message, images }, { chat, convoStore, tools }); + const result = await handleMessage({ ...message, images }, { chat, convoStore, tools }); if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); - await say(responseText); + 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); @@ -238,9 +256,9 @@ export function registerHandlers(deps) { thinking = await postThinking(say, thinkingMessage); const images = await extractMessageImages(message, botToken); const tools = buildToolsFor(deps, message.channel); - const responseText = await handleMessage({ ...message, images }, { chat, convoStore, tools }); + const result = await handleMessage({ ...message, images }, { chat, convoStore, tools }); if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); - await say(responseText); + await say(buildReplyPayload(result)); } catch (error) { console.error('Error in direct mention processing:', error); if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); diff --git a/lib/chat-backends.js b/lib/chat-backends.js index 5de2ef1..37088fc 100644 --- a/lib/chat-backends.js +++ b/lib/chat-backends.js @@ -56,7 +56,7 @@ function toOllamaTools(tools) { })); } -export function makeOllamaChat({ host, model, systemMessage, client }) { +export function makeOllamaChat({ host, model, systemMessage, think, client }) { const ollama = client || new Ollama({ host }); return { backend: 'ollama', @@ -69,15 +69,20 @@ export function makeOllamaChat({ host, model, systemMessage, client }) { 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 || {}, })); - return toolCalls.length ? { text, toolCalls } : { text }; + const result = { text }; + if (thinking) result.thinking = thinking; + if (toolCalls.length) result.toolCalls = toolCalls; + return result; }, }; } diff --git a/lib/chat.js b/lib/chat.js index 37f7016..2f0e94b 100644 --- a/lib/chat.js +++ b/lib/chat.js @@ -6,6 +6,9 @@ // 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 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.'; @@ -67,7 +70,7 @@ export async function handleMessage( const text = message.text || ''; const images = message.images || []; if (!text && !images.length) { - return 'I apologize, but I cannot process an empty message. How may I assist you?'; + return { text: 'I apologize, but I cannot process an empty message. How may I assist you?' }; } const key = convoKey(message.user); @@ -82,16 +85,18 @@ export async function handleMessage( try { let finalText = ''; + let finalThinking = ''; for (let iter = 0; iter < MAX_TOOL_ITERATIONS; iter++) { const result = await chat.chat({ messages: working, tools }); - const { text, toolCalls } = result; + const { text: turnText, toolCalls, thinking } = result; if (!toolCalls?.length) { - finalText = text || ''; + finalText = turnText || ''; + finalThinking = thinking || ''; break; } - working.push({ role: 'assistant', content: text || '', toolCalls }); + working.push({ role: 'assistant', content: turnText || '', toolCalls }); const toolResults = await dispatchToolCalls(toolCalls, tools); working.push(...toolResults); @@ -99,12 +104,13 @@ export async function handleMessage( // Force one final non-tool response so the user sees something. const wrap = await chat.chat({ messages: working }); finalText = wrap.text || ''; + finalThinking = wrap.thinking || ''; } } // 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. + // try to keep them. Thinking traces are likewise ephemeral. const persistedUserTurn = { role: 'user', content: text }; const nextHistory = [ ...history, @@ -113,12 +119,16 @@ export async function handleMessage( ].slice(-historyLimit); await convoStore.set(key, nextHistory); - return finalText; + const out = { text: finalText }; + if (finalThinking) out.thinking = finalThinking; + return out; } catch (error) { console.error('Error in handleMessage:', error); if (looksLikeContentError(error)) { - return 'I apologize, but I encountered an issue processing your message. Could you please rephrase your request?'; + 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 718f263..86dbfd9 100644 --- a/lib/deps.js +++ b/lib/deps.js @@ -10,6 +10,17 @@ 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'; @@ -42,6 +53,7 @@ export function buildDeps(overrides = {}, env = process.env) { 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; @@ -75,8 +87,14 @@ export function buildDeps(overrides = {}, env = process.env) { 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 }); - console.log(`Chat backend: ollama @ ${ollamaHost} (model: ${ollamaModel})`); + 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".` diff --git a/test/chat-backends.test.js b/test/chat-backends.test.js index b55be8b..b1bd702 100644 --- a/test/chat-backends.test.js +++ b/test/chat-backends.test.js @@ -134,6 +134,40 @@ test('Ollama adapter translates assistant turns with tool_calls + tool-role resu 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 }); diff --git a/test/chat.test.js b/test/chat.test.js index 03b9d75..b7ea435 100644 --- a/test/chat.test.js +++ b/test/chat.test.js @@ -52,7 +52,7 @@ test('handleMessage returns an apology for empty text without calling chat', asy { text: '', user: 'U1' }, { chat, convoStore: makeFakeConvoStore() } ); - assert.match(result, /cannot process an empty message/); + assert.match(result.text, /cannot process an empty message/); assert.strictEqual(chat.calls.length, 0); }); @@ -61,11 +61,11 @@ test('handleMessage persists per-user history across calls', async () => { const convoStore = makeFakeConvoStore(); const first = await handleMessage({ text: 'hi', user: 'U1' }, { chat, convoStore }); - assert.strictEqual(first, 'hi back'); + 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, 'hi back'); + assert.strictEqual(second.text, 'hi back'); assert.deepStrictEqual(chat.calls[1].messages, [ { role: 'user', content: 'hi' }, { role: 'assistant', content: 'hi back' }, @@ -125,7 +125,7 @@ test('handleMessage returns a friendly rephrase message on content/policy errors { text: 'hi', user: 'U1' }, { chat, convoStore: makeFakeConvoStore() } ); - assert.match(result, /rephrase your request/); + assert.match(result.text, /rephrase your request/); }); test('handleMessage falls back to the generic apology on other errors', async () => { @@ -134,7 +134,7 @@ test('handleMessage falls back to the generic apology on other errors', async () { text: 'hi', user: 'U1' }, { chat, convoStore: makeFakeConvoStore() } ); - assert.match(result, /neural pathways/); + assert.match(result.text, /neural pathways/); }); test('handleMessage does not persist history when the backend errors', async () => { @@ -173,7 +173,7 @@ test('handleMessage executes a tool call and feeds the result back to the model' { chat, convoStore: makeFakeConvoStore(), tools } ); - assert.strictEqual(reply, 'I echoed it for you.'); + 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. @@ -233,7 +233,7 @@ test('handleMessage surfaces tool-execution errors as model-visible strings', as { text: 'try it', user: 'U1' }, { chat, convoStore: makeFakeConvoStore(), tools } ); - assert.strictEqual(reply, 'Sorry, that did not work.'); + 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/); }); @@ -252,6 +252,36 @@ test('handleMessage returns an unknown-tool error message when the model halluci 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 () => { @@ -264,7 +294,7 @@ test('handleMessage attaches images to the user turn and strips them on persist' { chat, convoStore } ); - assert.strictEqual(reply, 'I see a cat.'); + 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. @@ -280,7 +310,7 @@ test('handleMessage accepts images with empty text', async () => { const convoStore = makeFakeConvoStore(); const images = [{ mimeType: 'image/png', data: 'YWJj' }]; const reply = await handleMessage({ text: '', user: 'U1', images }, { chat, convoStore }); - assert.strictEqual(reply, 'A cat.'); + assert.strictEqual(reply.text, 'A cat.'); }); test('handleMessage still rejects truly empty (no text, no images) input', async () => { @@ -289,7 +319,7 @@ test('handleMessage still rejects truly empty (no text, no images) input', async { text: '', user: 'U1' }, { chat, convoStore: makeFakeConvoStore() } ); - assert.match(reply, /cannot process an empty message/); + assert.match(reply.text, /cannot process an empty message/); assert.strictEqual(chat.calls.length, 0); }); From ab32182f4d95566cb0a0ab7cd8ca85769fbff87f Mon Sep 17 00:00:00 2001 From: Sean Carolan Date: Sun, 14 Jun 2026 19:38:44 +0000 Subject: [PATCH 5/6] Replace 'thinking' message with a :brain: reaction on the user's message (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-then-delete dance for the "thinking" indicator created message churn, occasional delete failures to log around, and ugly Slack history. Reactions are the right primitive for "I'm working on this" — Slack ephemeralizes them for free. - `addThinkingReaction` / `removeThinkingReaction` replace postThinking / clearThinking. Both handlers add :brain: to the user's message and remove it before replying. - manifest.yaml: adds reactions:read and reactions:write scopes. - Drops the THINKING_MESSAGE env var, lib/deps.js thinkingMessage field, and matching field destructure in app.js — no longer needed. - Reply path simplifies: no more `let thinking = null` mutation, no post-and-track ts threading through the try/catch. Closes #28 Co-Authored-By: Claude Opus 4.7 --- .env.example | 1 - app.js | 56 +++++++++++++++++++-------------------------------- lib/deps.js | 4 ---- manifest.yaml | 2 ++ 4 files changed, 23 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index 59be40f..c677558 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,5 @@ GEMINI_API_KEY= # --- Other --------------------------------------------------------------- BOT_PERSONALITY= -THINKING_MESSAGE= REDIS_URL=redis://localhost:6379 MEMORY_TTL_HOURS=24 diff --git a/app.js b/app.js index bab565e..7029e19 100644 --- a/app.js +++ b/app.js @@ -33,21 +33,18 @@ 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; } } @@ -69,12 +66,12 @@ function buildReplyPayload({ text, thinking }) { }; } -async function clearThinking(app, channel, ts) { - if (!ts) return; +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); } } @@ -136,16 +133,7 @@ function buildToolsFor(deps, channel) { // 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, - thinkingMessage, - } = deps; + const { app, chat, convoStore, geminiClient, geminiImageModel, botName, botToken } = deps; app.message(async ({ message, say, context }) => { if (!message) { @@ -193,17 +181,16 @@ export function registerHandlers(deps) { 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 images = await extractMessageImages(message, botToken); const tools = buildToolsFor(deps, message.channel); const result = await handleMessage({ ...message, images }, { chat, convoStore, tools }); - if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); + 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); } }); @@ -251,17 +238,16 @@ export function registerHandlers(deps) { return; } - let thinking = null; + const reacted = await addThinkingReaction(app, message.channel, message.ts); try { - thinking = await postThinking(say, thinkingMessage); const images = await extractMessageImages(message, botToken); const tools = buildToolsFor(deps, message.channel); const result = await handleMessage({ ...message, images }, { chat, convoStore, tools }); - if (thinking && thinking.ts) await clearThinking(app, message.channel, thinking.ts); + if (reacted) await removeThinkingReaction(app, message.channel, message.ts); await say(buildReplyPayload(result)); } catch (error) { console.error('Error in direct mention 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); } }); diff --git a/lib/deps.js b/lib/deps.js index 86dbfd9..7c90f44 100644 --- a/lib/deps.js +++ b/lib/deps.js @@ -43,9 +43,6 @@ export function buildDeps(overrides = {}, env = process.env) { 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 memoryTtlMs = Math.max(60_000, memoryTtlHours * 60 * 60 * 1000); @@ -110,6 +107,5 @@ export function buildDeps(overrides = {}, env = process.env) { geminiImageModel, botName: env.SLACK_BOT_USER_NAME, botToken: env.SLACK_BOT_TOKEN, - thinkingMessage, }; } 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: From 354b0596821c5520d867d20ed3d4ca7abb4d8fa5 Mon Sep 17 00:00:00 2001 From: Sean Carolan Date: Sun, 14 Jun 2026 19:41:25 +0000 Subject: [PATCH 6/6] Reply in-thread for channel @-mentions (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel @-mentions used to bail entirely on threaded messages, and top-level mentions replied at the channel root — which floods the channel for any back-and-forth. Now Data threads its replies: - @data at top level -> reply starts a thread rooted at the mention - @data inside an existing thread -> reply continues that thread - Tool side effects (image uploads, joke posts, asimov recitation, etc.) also land in-thread because buildToolsFor plumbs thread_ts through into chat.postMessage and files.uploadV2. Bail conditions in the direct-mention handler are tightened to just { bot_profile, bot_id, edited } — message.thread_ts and message.parent_user_id no longer cause Data to drop the message, so follow-up @-mentions inside threads Data started are now answered. The bot_id/bot_profile guard still prevents loops with other bots. DMs are unchanged — they reply flat as before. Closes #29 Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 42 ++++++++++++++++++++++++++++++++++++------ app.js | 42 ++++++++++++++++++++++++------------------ 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2a82781..47981a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,17 +28,19 @@ npm run format # Format with Prettier ## 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 — backend-agnostic, history in convoStore + 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 + convoStore persistence - chat-backends.test.js # Ollama + Gemini adapter wire-format translation + 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 @@ -56,8 +58,9 @@ AGENTS.md # Conventions for AI agents working in this repo | Export | Source | Purpose | |--------|--------|---------| -| `handleMessage(msg, { chat, convoStore })` | `lib/chat.js` | Route Slack message through the chat adapter, persist per-user history | +| `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` | +| `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()` | @@ -92,10 +95,10 @@ Loaded from `.env` via dotenv (see `.env.example`). - `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 -- `THINKING_MESSAGE` — Custom processing indicator text - `REDIS_URL` — Redis connection (default: `redis://localhost:6379`) - `MEMORY_TTL_HOURS` — Conversation memory lifetime (default: 24) @@ -114,6 +117,33 @@ Loaded from `.env` via dotenv (see `.env.example`). - **icanhazdadjoke.com** — Dad jokes endpoint - **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 Pattern matchers live in `lib/responses.js` as pure functions; the Bolt handlers in `app.js` are thin shims that call `say()` with the result: diff --git a/app.js b/app.js index 7029e19..1ef4905 100644 --- a/app.js +++ b/app.js @@ -105,7 +105,7 @@ async function extractMessageImages(message, botToken) { // 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) { +function buildToolsFor(deps, channel, threadTs) { const { app, geminiClient, geminiImageModel, botToken, botName } = deps; return makeTools({ geminiClient, @@ -116,6 +116,7 @@ function buildToolsFor(deps, channel) { await app.client.files.uploadV2({ token: botToken, channel_id: channel, + ...(threadTs ? { thread_ts: threadTs } : {}), file: buffer, filename: 'gemini-image.png', title: prompt, @@ -126,6 +127,7 @@ function buildToolsFor(deps, channel) { 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); }, }); @@ -198,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; } @@ -213,14 +227,14 @@ 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; } @@ -228,27 +242,19 @@ export function registerHandlers(deps) { const hasText = message.text && message.text.trim() !== ''; const hasFiles = !!message.files?.length; if (!hasText && !hasFiles) return; - if ( - message.edited || - message.thread_ts || - message.parent_user_id || - message.bot_profile || - message.bot_id - ) { - return; - } + if (message.edited) return; const reacted = await addThinkingReaction(app, message.channel, message.ts); try { const images = await extractMessageImages(message, botToken); - const tools = buildToolsFor(deps, message.channel); + 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 say(buildReplyPayload(result)); + await sayInThread(buildReplyPayload(result)); } catch (error) { console.error('Error in direct mention processing:', error); if (reacted) await removeThinkingReaction(app, message.channel, message.ts); - await say(GENERIC_ERROR_TEXT); + await sayInThread(GENERIC_ERROR_TEXT); } });