added optional local web dashboard for memory, with seeing/editing memory, daily facts (syncs with discord) and each user

This commit is contained in:
Luna
2026-03-01 13:48:52 +01:00
parent 4ebd94bd30
commit 8aba5399f6
11 changed files with 1377 additions and 36 deletions

View File

@@ -1,10 +1,12 @@
import dotenv from "dotenv";
dotenv.config({ path: "../.env" });
import { Client, GatewayIntentBits, Partials, ChannelType } from 'discord.js';
import { Client, GatewayIntentBits, Partials, ChannelType, ActivityType } from 'discord.js';
import { config } from './config.js';
import { chatCompletion } from './openai.js';
import { appendShortTerm, prepareContext, recordInteraction } from './memory.js';
import { searchWeb, appendSearchLog, detectFilteredPhrase } from './search.js';
import { getDailyMood, setMoodByName, getDailyThought, generateDailyThought } from './mood.js';
import { startDashboard } from './dashboard.js';
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
@@ -19,8 +21,6 @@ let coderPingTimer;
const continuationState = new Map();
let isSleeping = false;
// mood override for testing
let overrideMood = null;
function enterSleepMode() {
if (isSleeping) return;
@@ -120,12 +120,35 @@ function stopContinuationForUser(userId) {
continuationState.set(userId, state);
}
client.once('clientReady', () => {
if (config.dashboardEnabled) {
startDashboard();
}
async function onReady() {
console.log(`[bot] Logged in as ${client.user.tag}`);
scheduleCoderPing();
const m = getDailyMood();
console.log(`[bot] current mood on startup: ${m.name}${m.description}`);
});
try {
await generateDailyThought();
const thought = await getDailyThought();
if (thought && client.user) {
console.log(`[bot] setting presence with thought: "${thought}"`);
await client.user.setPresence({
status: 'online',
activities: [{ name: thought, type: ActivityType.Playing }]
});
console.log('[bot] presence set successfully');
} else {
console.warn('[bot] no thought or client user available');
}
} catch (err) {
console.error('[bot] failed to set presence:', err);
}
}
client.once('ready', onReady);
function shouldRespond(message) {
if (message.author.bot) return false;
@@ -160,26 +183,6 @@ const toneHints = [
{ label: 'excited', regex: /(excited|hyped|omg|yay|stoked)/i },
];
const dailyMoods = [
['Calm','Soft tone; minimal emojis; low sarcasm; concise and soothing.'],
['Goblin','Chaotic, highenergy replies; random emojis; extra sarcasm and hype.'],
['Philosopher','Deep, reflective answers; longer and thoughtful, a bit poetic.'],
['Hype','Enthusiastic and upbeat; lots of exclamation marks, emojis and hype.'],
['Sassy','Playful sarcasm without being mean; snappy replies and quips.'],
].map(([n,d])=>({name:n,description:d}));
function getDailyMood() {
if (overrideMood) return overrideMood;
const day = Math.floor(Date.now() / 86400000);
return dailyMoods[day % dailyMoods.length];
}
function setMoodByName(name) {
if (!name) return null;
const found = dailyMoods.find((m) => m.name.toLowerCase() === name.toLowerCase());
if (found) overrideMood = found;
return found;
}
function detectTone(text) {
@@ -557,8 +560,15 @@ client.on('messageCreate', async (message) => {
});
if (!config.discordToken) {
console.error('Missing DISCORD_TOKEN. Check your .env file.');
process.exit(1);
if (config.dashboardEnabled) {
console.warn('[bot] DISCORD_TOKEN not set; running in dashboard-only mode.');
} else {
console.error('Missing DISCORD_TOKEN. Check your .env file.');
process.exit(1);
}
} else {
client.login(config.discordToken).catch((err) => {
console.error('[bot] login failed:', err);
if (!config.dashboardEnabled) process.exit(1);
});
}
client.login(config.discordToken);

View File

@@ -36,6 +36,10 @@ export const config = {
memoryPruneThreshold: 0.2,
maxMemories: 8000,
relevantMemoryCount: 5,
// Optional local dashboard that runs alongside the bot. Enable with
// `ENABLE_DASHBOARD=true` and customize port with `DASHBOARD_PORT`.
dashboardEnabled: process.env.ENABLE_DASHBOARD === 'true',
dashboardPort: process.env.DASHBOARD_PORT ? parseInt(process.env.DASHBOARD_PORT, 10) : 3000,
// Proactive continuation settings: when a user stops replying, Nova can continue
// the conversation every `continuationIntervalMs` milliseconds until the user
// signals to stop or the `continuationMaxProactive` limit is reached.

123
src/dashboard.js Normal file
View File

@@ -0,0 +1,123 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { config } from './config.js';
import {
listUsers,
getAllShortTerm,
getLongTermMemories,
deleteLongTerm,
findSimilar,
} from './memory.js';
import { getDailyMood, getDailyThought, setDailyThought } from './mood.js';
export function startDashboard() {
if (!config.dashboardEnabled) return;
const app = express();
app.use((req, res, next) => {
console.log(`[dashboard] ${req.method} ${req.url}`);
next();
});
app.use(express.json());
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.join(__dirname, './public');
console.log('[dashboard] static directory:', publicDir);
const indexPath = path.join(publicDir, 'index.html');
console.log('[dashboard] index file path:', indexPath);
import('fs')
.then((fs) => fs.promises.stat(indexPath))
.then(() => console.log('[dashboard] index.html is present'))
.catch((e) => console.warn('[dashboard] index.html missing or inaccessible', e.message));
app.use(express.static(publicDir));
app.get('/api/users', async (req, res) => {
console.log('[dashboard] GET /api/users');
try {
const users = await listUsers();
console.log('[dashboard] returning', users.length, 'users');
res.json(users);
} catch (err) {
console.error('[dashboard] failed to list users', err);
res.status(500).json({ error: 'internal' });
}
});
app.get('/api/users/:id/short', async (req, res) => {
console.log('[dashboard] GET /api/users/' + req.params.id + '/short');
try {
const rows = await getAllShortTerm(req.params.id);
res.json(rows);
} catch (err) {
console.error('[dashboard] fetch short-term failed', err);
res.status(500).json({ error: 'internal' });
}
});
app.get('/api/users/:id/long', async (req, res) => {
console.log('[dashboard] GET /api/users/' + req.params.id + '/long');
try {
const rows = await getLongTermMemories(req.params.id);
res.json(rows);
} catch (err) {
console.error('[dashboard] fetch long-term failed', err);
res.status(500).json({ error: 'internal' });
}
});
app.delete('/api/users/:id/long/:memId', async (req, res) => {
console.log('[dashboard] DELETE /api/users/' + req.params.id + '/long/' + req.params.memId);
try {
await deleteLongTerm(req.params.id, req.params.memId);
res.json({ ok: true });
} catch (err) {
console.error('[dashboard] delete memory failed', err);
res.status(500).json({ error: 'internal' });
}
});
app.post('/api/users/:id/search', async (req, res) => {
console.log('[dashboard] POST /api/users/' + req.params.id + '/search', req.body);
try {
const { query } = req.body;
const results = await findSimilar(req.params.id, query);
res.json(results);
} catch (err) {
console.error('[dashboard] similarity search failed', err);
res.status(500).json({ error: 'internal' });
}
});
app.get('/api/mood', async (req, res) => {
console.log('[dashboard] GET /api/mood');
try {
const thought = await getDailyThought();
res.json({ mood: getDailyMood(), thought });
} catch (err) {
console.error('[dashboard] failed to get mood', err);
res.status(500).json({ error: 'internal' });
}
});
app.post('/api/mood/thought', async (req, res) => {
console.log('[dashboard] POST /api/mood/thought', req.body);
try {
const { thought } = req.body;
if (!thought || typeof thought !== 'string') {
return res.status(400).json({ error: 'thought must be a string' });
}
await setDailyThought(thought);
const updatedThought = await getDailyThought();
res.json({ ok: true, thought: updatedThought });
} catch (err) {
console.error('[dashboard] failed to set thought', err);
res.status(500).json({ error: 'internal' });
}
});
const port = config.dashboardPort || 3000;
app.listen(port, () => {
console.log(`[dashboard] listening on http://localhost:${port}`);
});
}

View File

@@ -113,6 +113,11 @@ const createSchema = (db) => {
timestamp INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS daily_thoughts (
date TEXT PRIMARY KEY,
thought TEXT NOT NULL,
created_at INTEGER NOT NULL
);
`);
};
@@ -301,8 +306,7 @@ const retrieveRelevantMemories = async (db, userId, query) => {
};
})
.sort((a, b) => b.score - a.score)
.slice(0, config.relevantMemoryCount)
.map(({ score, ...rest }) => rest);
.slice(0, config.relevantMemoryCount);
};
export async function appendShortTerm(userId, role, content) {
@@ -357,4 +361,55 @@ export async function pruneLowImportanceMemories(userId) {
ensureUser(db, userId);
run(db, 'DELETE FROM long_term WHERE user_id = ? AND importance < ?', [userId, config.memoryPruneThreshold]);
await persistDb(db);
}
}
// -----------------------------------------------------------------------------
// Dashboard helpers
// -----------------------------------------------------------------------------
export async function listUsers() {
const db = await loadDatabase();
return all(db, 'SELECT id, summary, last_updated FROM users');
}
export async function getAllShortTerm(userId) {
const db = await loadDatabase();
return fullShortTerm(db, userId);
}
export async function getLongTermMemories(userId) {
const db = await loadDatabase();
return all(
db,
'SELECT id, content, importance, timestamp FROM long_term WHERE user_id = ? ORDER BY timestamp DESC',
[userId],
);
}
export async function deleteLongTerm(userId, entryId) {
const db = await loadDatabase();
run(db, 'DELETE FROM long_term WHERE user_id = ? AND id = ?', [userId, entryId]);
await persistDb(db);
}
export async function findSimilar(userId, query) {
const db = await loadDatabase();
return retrieveRelevantMemories(db, userId, query);
}
// Daily thought storage
export async function getDailyThoughtFromDb(date) {
const db = await loadDatabase();
const row = get(db, 'SELECT thought FROM daily_thoughts WHERE date = ?', [date]);
return row?.thought || null;
}
export async function saveDailyThought(date, thought) {
const db = await loadDatabase();
run(db, 'INSERT OR REPLACE INTO daily_thoughts (date, thought, created_at) VALUES (?, ?, ?)', [
date,
thought,
Date.now(),
]);
await persistDb(db);
}

95
src/mood.js Normal file
View File

@@ -0,0 +1,95 @@
import { chatCompletion } from './openai.js';
import { getDailyThoughtFromDb, saveDailyThought } from './memory.js';
const dailyMoods = [
['Calm','Soft tone; minimal emojis; low sarcasm; concise and soothing.'],
['Goblin','Chaotic, highenergy replies; random emojis; extra sarcasm and hype.'],
['Philosopher','Deep, reflective answers; longer and thoughtful, a bit poetic.'],
['Hype','Enthusiastic and upbeat; lots of exclamation marks, emojis and hype.'],
['Sassy','Playful sarcasm without being mean; snappy replies and quips.'],
].map(([n,d])=>({name:n,description:d}));
let overrideMood = null;
let currentDailyMood = null;
/**
* Get today's date as YYYY-MM-DD for comparison
*/
function getTodayDate() {
const d = new Date();
return d.toISOString().split('T')[0];
}
function pickMood(){
return dailyMoods[Math.floor(Math.random() * dailyMoods.length)];
}
function getDailyMood() {
if (overrideMood) return overrideMood;
if (!currentDailyMood) currentDailyMood = pickMood();
return currentDailyMood;
}
function setMoodByName(name) {
if (!name) return null;
const found = dailyMoods.find((m) => m.name.toLowerCase() === name.toLowerCase());
if (found) overrideMood = found;
return found;
}
async function getDailyThought() {
const today = getTodayDate();
return await getDailyThoughtFromDb(today);
}
async function setDailyThought(thought) {
const today = getTodayDate();
await saveDailyThought(today, thought);
}
async function generateDailyThought() {
const today = getTodayDate();
// Check if we already have a thought for today in the DB
const existingThought = await getDailyThoughtFromDb(today);
if (existingThought) {
console.log('[mood] using existing thought for today:', existingThought);
return existingThought;
}
let newThought = null;
try {
const prompt =
'Write a short (one sentence, <20 words, exactly 120 characters max) quirky Discord "nova status" that a friendly bot might use today.';
const messages = [
{ role: 'system', content: 'You are Nova, a playful Discord AI companion.' },
{ role: 'user', content: prompt },
];
const resp = await chatCompletion(messages, { temperature: 0.8, maxTokens: 40 });
newThought = (resp && resp.trim()) || '';
// Truncate to 120 characters if it exceeds
if (newThought.length > 120) {
newThought = newThought.substring(0, 117) + '...';
}
} catch (err) {
console.warn('[mood] failed to generate daily thought:', err);
}
if (!newThought) {
const fallbacks = [
'Vibing in the server like a code ghost.',
'I swear I understand humans… probably.',
'Got an error? I am the error.',
'Just refreshed my cache and I feel alive.',
];
newThought = fallbacks[Math.floor(Math.random() * fallbacks.length)];
}
// Save to database
await saveDailyThought(today, newThought);
console.log('[mood] generated and saved new thought for today:', newThought);
return newThought;
}
export { getDailyMood, setMoodByName, getDailyThought, setDailyThought, generateDailyThought, dailyMoods };

View File

@@ -58,7 +58,6 @@ async function postJson(path, body) {
}
return res.json();
} catch (err) {
// normalize AbortError into a retryable code
if (err.name === 'AbortError' || err.message?.includes('timed out')) {
const e = new Error(`Connect Timeout Error after ${timeout}ms`);
e.code = 'UND_ERR_CONNECT_TIMEOUT';
@@ -85,7 +84,6 @@ export async function chatCompletion(messages, options = {}) {
};
const data = await withRetry(() => postJson('/chat/completions', payload));
// OpenRouter uses OpenAI-compatible response shape
const text = data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '';
return (text && String(text).trim()) || '';
}

172
src/public/index.html Normal file
View File

@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nova Memory Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{
font-family:system-ui, sans-serif;
background:#121212;
color:#e0e0e0;
min-height:100vh;
padding:1rem;
}
.container{max-width:900px;margin:0 auto;}
h1{font-size:2rem;font-weight:700;color:#fff;margin-bottom:1rem;text-align:center;}
.mood-card,.section-card{background:#1e1e1e;border-radius:8px;padding:1.5rem;margin:1rem 0;box-shadow:0 4px 12px rgba(0,0,0,0.6);}
h2{font-size:1.5rem;color:#fff;border-bottom:2px solid #333;padding-bottom:0.5rem;margin-bottom:1rem;}
h3{font-size:1.2rem;color:#ddd;margin-top:1rem;margin-bottom:0.75rem;}
.user-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.75rem;}
.user-card{background:#292929;padding:1rem;border-radius:6px;cursor:pointer;transition:transform .2s,box-shadow .2s;color:#fff;}
.user-card:hover{transform:translateY(-3px);box-shadow:0 6px 20px rgba(0,0,0,0.7);}
.user-id{font-weight:600;font-size:1.1rem;margin-bottom:0.4rem;}
.user-summary{font-size:0.9rem;opacity:0.85;line-height:1.3;}
table{width:100%;border-collapse:collapse;margin-bottom:1rem;font-size:0.9rem;}
th{background:#333;color:#fff;padding:.75rem;text-align:left;}
td{padding:.75rem;border-bottom:1px solid #2a2a2a;}
tr:nth-child(even){background:#1e1e1e;}
.empty{color:#777;font-style:italic;text-align:center;padding:1rem;}
button{background:#444;color:#fff;border:none;padding:.5rem 1rem;border-radius:4px;cursor:pointer;transition:background .2s;}
button:hover{background:#555;}
.delete-btn{background:#880000;}
.delete-btn:hover{background:#aa0000;}
input[type=text]{width:100%;padding:.6rem;border:1px solid #333;border-radius:4px;background:#212121;color:#e0e0e0;}
.search-box{display:flex;gap:.5rem;flex-wrap:wrap;margin:1rem 0;}
.search-results{background:#1e1e1e;border-left:4px solid #555;padding:1rem;border-radius:4px;margin-top:1rem;}
.search-results ul{list-style:none;}
.search-results li{padding:.6rem;margin-bottom:.4rem;background:#292929;border-radius:4px;}
.score-badge{display:inline-block;background:#555;color:#fff;padding:.2rem .4rem;border-radius:3px;font-size:.8rem;margin-right:.4rem;}
.modal-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);display:none;align-items:center;justify-content:center;}
.modal{background:#1e1e1e;padding:2rem;border-radius:8px;max-height:90vh;overflow:auto;width:90%;max-width:800px;position:relative;}
.modal-close{position:absolute;top:0.5rem;right:0.75rem;font-size:1.5rem;color:#bbb;cursor:pointer;}
.loading{color:#999;text-align:center;font-weight:600;padding:1rem;}
.error{background:#550000;color:#fff;padding:1rem;border-radius:4px;}
@media(max-width:600px){.user-list{grid-template-columns:1fr;}}
</style>
</head>
<body>
<div class="container">
<h1>🧠 Nova Memory Dashboard</h1>
<div id="mood"></div>
<div class="section-card">
<h2>Users</h2>
<div id="user-list" class="user-list">loading…</div>
</div>
</div>
<div id="modal" class="modal-overlay">
<div class="modal">
<span class="modal-close" onclick="closeModal()">&times;</span>
<div id="detail"></div>
</div>
</div>
<script>
async function fetchJson(path, opts){const res=await fetch(path,opts);if(!res.ok)throw new Error(res.statusText);return res.json();}
function formatDate(ts){return new Date(ts).toLocaleString();}
function closeModal(){document.getElementById('modal').style.display='none';}
async function loadMood(){
try{
const data=await fetchJson('/api/mood');
const moodCard=document.createElement('div');
moodCard.className='mood-card';
moodCard.innerHTML='<strong>'+data.mood.name+'</strong><p>'+data.mood.description+'</p><p>'+data.thought+'</p>';
const old=document.getElementById('mood');
old.replaceWith(moodCard);
moodCard.id='mood';
}catch(e){console.error(e);}
}
async function loadUsers() {
console.log('client: fetching users');
try {
const users = await fetchJson('/api/users');
console.log('client: got users', users);
const container = document.getElementById('user-list');
if (!users.length) {
container.innerHTML = '<div class="empty">No users yet</div>';
return;
}
container.innerHTML = users.map(u =>
'<div class="user-card" onclick="showUser(\'' + u.id + '\')">' +
'<div class="user-id">👤 ' + u.id + '</div>' +
'<div class="user-summary">' + (u.summary || 'No summary yet') + '</div>' +
'</div>'
).join('');
} catch (e) {
console.error('client: loadUsers error', e);
document.getElementById('user-list').innerHTML = '<div class="error">Error loading users: ' + e.message + '</div>';
}
}
async function showUser(id){
console.log('client: showUser',id);
const detail=document.getElementById('detail');
detail.innerHTML='<div class="loading">Loading…</div>';
document.getElementById('modal').style.display='flex';
try{
const [shortR,longR]=await Promise.all([
fetchJson('/api/users/'+id+'/short'),
fetchJson('/api/users/'+id+'/long')
]);
let html='<h2>Memory for '+id+'</h2>';
html+='<h3>Short-term</h3>';
if(shortR.length){
html+='<table><tr><th>Role</th><th>Content</th><th>When</th></tr>';
shortR.forEach(r=>{
html+='<tr><td><strong>'+r.role+'</strong></td><td>'+r.content.substring(0,100)+(r.content.length>100?'…':'')+'</td><td>'+formatDate(r.timestamp)+'</td></tr>';
});
html+='</table>';
}else html+='<p class="empty">none</p>';
html+='<h3>Long-term</h3>';
if(longR.length){
html+='<table><tr><th>Imp</th><th>Content</th><th>When</th><th></th></tr>';
longR.forEach(r=>{
const imp=r.importance? r.importance.toFixed(2):'N/A';
const impCol=r.importance? (r.importance>0.8?'#ff6b6b':r.importance>0.5?'#ffd43b':'#51cf66'):'#999';
html+='<tr><td><span class="score-badge" style="background:'+impCol+'">'+imp+'</span></td>'+
'<td>'+r.content.substring(0,100).replace(/</g,'&lt;')+(r.content.length>100?'…':'')+'</td>'+
'<td>'+formatDate(r.timestamp)+'</td>'+
'<td><button class="delete-btn" onclick="deleteEntry(\''+id+'\',\''+r.id+'\')">Del</button></td></tr>';
});
html+='</table>';
}else html+='<p class="empty">none</p>';
html+='<h3>Search</h3><div class="search-box"><input id="search-q" type="text" placeholder="query…"/><button onclick="runSearch(\''+id+'\')">Search</button></div><div id="search-results"></div>';
detail.innerHTML=html;
}catch(e){
detail.innerHTML='<div class="error">error: '+e.message+'</div>';
}
}
async function deleteEntry(user, id) {
console.log('client: deleteEntry', user, id);
if (!confirm('Are you sure you want to delete this memory?')) return;
try {
await fetch('/api/users/' + user + '/long/' + id, { method: 'DELETE' });
showUser(user);
} catch (e) {
alert('Error deleting memory: ' + e.message);
}
}
async function runSearch(user){
const q=document.getElementById('search-q').value;
if(!q){return;}
const container=document.getElementById('search-results');
container.innerHTML='<div class="loading">Searching…</div>';
try{
const res=await fetchJson('/api/users/'+user+'/search',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query:q})});
if(!res.length){container.innerHTML='<div class="search-results"><p class="empty">none</p></div>';return;}
let html='<div class="search-results"><ul>';
res.forEach(r=>{const score=r.score?r.score.toFixed(3):'?';html+='<li><span class="score-badge">'+score+'</span>'+r.content.substring(0,100).replace(/</g,'&lt;')+(r.content.length>100?'…':'')+'</li>';});
html+='</ul></div>';
container.innerHTML=html;
}catch(e){container.innerHTML='<div class="error">search error</div>';}
}
loadMood();
loadUsers();
</script>
</body>
</html>