Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3e40252
feat: add DB-backed AI conversation persistence
BillChirico Feb 11, 2026
08d1da7
fix: prevent race condition in async DB hydration
BillChirico Feb 11, 2026
804d662
chore: remove stray file
BillChirico Feb 11, 2026
8a58e8c
fix: expand biome.json includes to src/**/*.js and fix all lint errors
BillChirico Feb 11, 2026
92b2c19
fix: add CHECK constraint on role column in conversations table
BillChirico Feb 11, 2026
7313073
docs: add migration TODO for loadState in DB startup path
BillChirico Feb 11, 2026
4955beb
fix: harden async history hydration and DB persistence behavior
BillChirico Feb 11, 2026
010c8e3
test: cover hydration race handling and align header test naming
BillChirico Feb 11, 2026
59991e7
fix: guard config set return-shape traversal
BillChirico Feb 11, 2026
afa64bc
fix: add created_at index for conversation ttl cleanup
BillChirico Feb 11, 2026
8fd72d7
fix: dedupe concurrent async history hydrations per channel
BillChirico Feb 11, 2026
6a099f4
fix: include stack traces in welcome error logging
BillChirico Feb 11, 2026
cb40ef3
test: harden hydration race assertion with waitFor
BillChirico Feb 11, 2026
ebb6b44
test: assert DB write failure logging in ai history persistence
BillChirico Feb 11, 2026
c797b66
fix: replace channel history on DB hydration to avoid duplicates
BillChirico Feb 11, 2026
36baa91
chore: sort config command imports for biome
BillChirico Feb 11, 2026
468df96
fix(ai): coordinate sync/async history hydration
BillChirico Feb 11, 2026
ed5283a
fix(ai): unref conversation cleanup timer
BillChirico Feb 11, 2026
929dd5b
test(db): cover initDb re-entrancy guard
BillChirico Feb 11, 2026
3c5fa44
refactor: remove dead code getHistory and update tests to use getHist…
BillChirico Feb 11, 2026
e306ff9
fix: remove duplicate error handlers in index.js
BillChirico Feb 11, 2026
6d9ce1b
fix: add TTL filter to conversation history initialization
BillChirico Feb 11, 2026
eb513b3
chore: resolve merge conflicts with main
BillChirico Feb 11, 2026
252cd9d
fix: align tests with persistence architecture and resolve merge conf…
BillChirico Feb 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ logs/security/
data/*
!data/.gitkeep

# Test and coverage outputs
coverage/
Comment on lines +21 to +22
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Duplicate coverage/ entry.

coverage/ is already listed on Line 5. This second entry is redundant.

🧹 Proposed fix
-# Test and coverage outputs
-coverage/
-
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Test and coverage outputs
coverage/
🤖 Prompt for AI Agents
In @.gitignore around lines 21 - 22, Remove the redundant duplicate entry
"coverage/" from .gitignore (the duplicate found between lines shown in the
diff); keep the original listing and delete the second "coverage/" line so the
file only contains one entry for coverage.


# Verification scripts
verify-*.js
VERIFICATION_GUIDE.md
36 changes: 36 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
"files": {
"includes": [
"src/db.js",
"src/index.js",
"src/modules/ai.js",
"tests/**/*.js",
"!node_modules/**",
"!coverage/**",
"!logs/**",
"!data/**"
]
},
"linter": {
"enabled": true,
"rules": {
"suspicious": {
"noConsole": "error"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
}
}
4 changes: 3 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"model": "claude-sonnet-4-20250514",
"maxTokens": 1024,
"systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\n⚠️ CRITICAL RULES:\n- NEVER type @.everyone or @.here (remove the dots) - these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.",
"channels": []
"channels": [],
"historyLength": 20,
"historyTTLDays": 30
},
"chimeIn": {
"enabled": false,
Expand Down
15 changes: 13 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"deploy": "node src/deploy-commands.js"
"deploy": "node src/deploy-commands.js",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "biome check src/ tests/",
"lint:fix": "biome check --write src/ tests/",
"format": "biome format --write src/ tests/"
},
"dependencies": {
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"dotenv": "^17.2.4",
"pg": "^8.18.0",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
Expand All @@ -24,5 +30,10 @@
},
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.3.14",
"@vitest/coverage-v8": "^4.0.18",
"vitest": "^4.0.18"
}
}
16 changes: 16 additions & 0 deletions src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ export async function initDb() {
)
`);

await pool.query(`
CREATE TABLE IF NOT EXISTS conversations (
id SERIAL PRIMARY KEY,
channel_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
username TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);

await pool.query(`
CREATE INDEX IF NOT EXISTS idx_conversations_channel_created
ON conversations (channel_id, created_at)
`);

info('Database schema initialized');
} catch (err) {
// Clean up the pool so getPool() doesn't return an unusable instance
Expand Down
75 changes: 46 additions & 29 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,26 @@
* - Structured logging
*/

import { Client, GatewayIntentBits, Collection } from 'discord.js';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Client, Collection, GatewayIntentBits } from 'discord.js';
import { config as dotenvConfig } from 'dotenv';
import { readdirSync, writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { info, warn, error } from './logger.js';
import { initDb, closeDb } from './db.js';
import { loadConfig, getConfig } from './modules/config.js';
import { closeDb, initDb } from './db.js';
import { error, info, warn } from './logger.js';
import {
getConversationHistory,
initConversationHistory,
setConversationHistory,
setPool,
startConversationCleanup,
stopConversationCleanup,
} from './modules/ai.js';
import { loadConfig } from './modules/config.js';
import { registerEventHandlers } from './modules/events.js';
import { HealthMonitor } from './utils/health.js';
import { getPermissionError, hasPermission } from './utils/permissions.js';
import { registerCommands } from './utils/registerCommands.js';
import { hasPermission, getPermissionError } from './utils/permissions.js';
import { getConversationHistory, setConversationHistory } from './modules/ai.js';

// ES module dirname equivalent
const __filename = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -125,7 +132,7 @@ function loadState() {
*/
async function loadCommands() {
const commandsPath = join(__dirname, 'commands');
const commandFiles = readdirSync(commandsPath).filter(file => file.endsWith('.js'));
const commandFiles = readdirSync(commandsPath).filter((file) => file.endsWith('.js'));

for (const file of commandFiles) {
const filePath = join(commandsPath, file);
Expand All @@ -152,12 +159,7 @@ client.once('clientReady', async () => {
const commands = Array.from(client.commands.values());
const guildId = process.env.GUILD_ID || null;

await registerCommands(
commands,
client.user.id,
process.env.DISCORD_TOKEN,
guildId
);
await registerCommands(commands, client.user.id, process.env.DISCORD_TOKEN, guildId);
} catch (err) {
error('Command registration failed', { error: err.message });
}
Expand Down Expand Up @@ -189,7 +191,7 @@ client.on('interactionCreate', async (interaction) => {
if (!hasPermission(member, commandName, config)) {
await interaction.reply({
content: getPermissionError(commandName),
ephemeral: true
ephemeral: true,
});
warn('Permission denied', { user: interaction.user.tag, command: commandName });
return;
Expand All @@ -200,7 +202,7 @@ client.on('interactionCreate', async (interaction) => {
if (!command) {
await interaction.reply({
content: '❌ Command not found.',
ephemeral: true
ephemeral: true,
});
return;
}
Expand All @@ -212,7 +214,7 @@ client.on('interactionCreate', async (interaction) => {

const errorMessage = {
content: '❌ An error occurred while executing this command.',
ephemeral: true
ephemeral: true,
};

if (interaction.replied || interaction.deferred) {
Expand All @@ -236,8 +238,8 @@ async function gracefulShutdown(signal) {
info('Waiting for pending requests', { count: pendingRequests.size });
const startTime = Date.now();

while (pendingRequests.size > 0 && (Date.now() - startTime) < SHUTDOWN_TIMEOUT) {
await new Promise(resolve => setTimeout(resolve, 100));
while (pendingRequests.size > 0 && Date.now() - startTime < SHUTDOWN_TIMEOUT) {
await new Promise((resolve) => setTimeout(resolve, 100));
}

if (pendingRequests.size > 0) {
Expand All @@ -247,23 +249,26 @@ async function gracefulShutdown(signal) {
}
}

// 2. Save state after pending requests complete
// 2. Stop conversation cleanup timer
stopConversationCleanup();

// 3. Save state after pending requests complete
info('Saving conversation state');
saveState();

// 3. Close database pool
// 4. Close database pool
info('Closing database connection');
try {
await closeDb();
} catch (err) {
error('Failed to close database pool', { error: err.message });
}

// 4. Destroy Discord client
// 5. Destroy Discord client
info('Disconnecting from Discord');
client.destroy();

// 5. Log clean exit
// 6. Log clean exit
info('Shutdown complete');
process.exit(0);
}
Expand All @@ -277,15 +282,15 @@ client.on('error', (err) => {
error('Discord client error', {
error: err.message,
stack: err.stack,
code: err.code
code: err.code,
});
});

process.on('unhandledRejection', (err) => {
error('Unhandled promise rejection', {
error: err?.message || String(err),
stack: err?.stack,
type: typeof err
type: typeof err,
});
});

Expand All @@ -307,8 +312,9 @@ if (!token) {
*/
async function startup() {
// Initialize database
let dbPool = null;
if (process.env.DATABASE_URL) {
await initDb();
dbPool = await initDb();
info('Database initialized');
} else {
warn('DATABASE_URL not set — using config.json only (no persistence)');
Expand All @@ -318,9 +324,20 @@ async function startup() {
config = await loadConfig();
info('Configuration loaded', { sections: Object.keys(config) });

// Load previous conversation state
// Set up AI module's DB pool reference
if (dbPool) {
setPool(dbPool);
}

// Load previous conversation state from file (fallback)
loadState();

// Hydrate conversation history from DB (overwrites file state if DB is available)
await initConversationHistory();

// Start periodic conversation cleanup
startConversationCleanup();

// Register event handlers with live config reference
registerEventHandlers(client, config, healthMonitor);

Expand Down
Loading