Dashboard updated

Added life log, persona, and plugins manager.
changed it so that any new .json files aren't uploaded
This commit is contained in:
Dani 2025-05-05 13:23:38 -04:00
parent a1a6c77e59
commit 48585acb6f
9 changed files with 649 additions and 200 deletions

3
.gitignore vendored
View File

@ -171,5 +171,4 @@ cython_debug/
.vscode/launch.json .vscode/launch.json
/books/* /books/*
/memory/* /memory/*
vocab.json *.json
progress.json

260
body.py
View File

@ -1,97 +1,215 @@
# body.py
# flake8: noqa
import os import os
import asyncio
import glob import glob
import threading
import json import json
import threading
import asyncio
from collections import deque from collections import deque
from datetime import datetime, time, timedelta
import logging import logging
import requests
import discord import discord
from nervous_system import NervousSystem from nervous_system import NervousSystem
import dashboard # <-- import your new Flask app from persona import Persona
import brain_map # <-- import the blueprint to inject system from life_log import LifeLog
from plugin_manager import PluginManager
import dashboard
import brain_map
# Path for progress persistence # ─── Config & Paths ────────────────────────────────────────────────────────────
PROGRESS_PATH = 'progress.json' VOCAB_PATH = "vocab.json"
PROGRESS_PATH = "progress.json"
# Mute logger PERSONA_PATH = "persona.json"
for noisy_logger in ["werkzeug", "flask", "flask.app"]: LIFELOG_PATH = "life_log.json"
logging.getLogger(noisy_logger).setLevel(logging.CRITICAL) OPENWEATHER_KEY = os.getenv("OPENWEATHER_API_KEY")
WEATHER_CITY = os.getenv("WEATHER_CITY", "New York")
# ─── Initialize Ruby & Discord ───────────────────────────────────────────────── # noqa: E501 WEATHER_HOUR = 6 # daily at 6 AM
# ─── Discord & System Setup ────────────────────────────────────────────────────
intents = discord.Intents.default() intents = discord.Intents.default()
intents.message_content = True intents.message_content = True
client = discord.Client(intents=intents) client = discord.Client(intents=intents)
system = NervousSystem() system = NervousSystem()
system.history = deque(maxlen=100) persona = Persona(path=PERSONA_PATH)
life_log = LifeLog(path=LIFELOG_PATH)
plugins = PluginManager()
# Load or resume vocab + embeddings # keep last 6 turns for conversational context
system.sensory.load_vocab('vocab.json') system.history = deque(maxlen=6)
# load/resize vocab
system.sensory.load_vocab(VOCAB_PATH)
system._resize_embeddings() system._resize_embeddings()
print('Loaded vocab size:', len(system.sensory.stoi))
# Resume progress # resume booktraining progress (per-book)
if os.path.isfile(PROGRESS_PATH): if os.path.isfile(PROGRESS_PATH):
with open(PROGRESS_PATH, 'r', encoding='utf-8') as f: with open(PROGRESS_PATH, "r", encoding="utf-8") as f:
data = json.load(f) prog = json.load(f)
system.processed_lines = data.get('processed_lines', 0) current_book = prog.get("current_book", 0)
line_offset = prog.get("line_offset", 0)
else: else:
system.processed_lines = 0 current_book = 0
line_offset = 0
# Compute total book lines book_list = sorted(glob.glob("books/*.txt"))
total = sum( total_books = len(book_list)
1
for path in glob.glob('books/*.txt') # count lines per book
for line in open(path, encoding='utf-8') book_line_counts = []
if line.strip() for path in book_list:
cnt = sum(1 for line in open(path, encoding="utf-8") if line.strip())
book_line_counts.append(cnt)
# set up overall progress counters for the dashboard
system.total_lines = sum(book_line_counts)
lines_done = sum(book_line_counts[:current_book]) + line_offset
system.processed_lines = lines_done
print(
f"Resuming training: book {current_book+1}/{total_books}, "
f"line {line_offset}/{book_line_counts[current_book] if current_book < total_books else 0}\n"
f"Overall progress: {system.processed_lines}/{system.total_lines} lines"
) )
system.total_lines = total
print(f'Resuming training at {system.processed_lines}/{system.total_lines} lines') # inject for dashboard routes
# Inject into Flask contexts
dashboard.system = system dashboard.system = system
brain_map.system = system brain_map.system = system
# ─── Book-training when idle ──────────────────────────────────────────────────── # noqa: E501 print(
f"Loaded vocab {len(system.sensory.stoi)}, "
f"resuming at book {current_book+1}/{total_books}, "
f"line {line_offset}/{book_line_counts[current_book] if current_book < total_books else 0}"
)
def refine_diary_entry(raw: str) -> str:
prompt = (
"Here is a rough diary draft. Rewrite it as a clear, first-person "
"diary entry in 23 sentences:\n\n"
f"Draft:\n{raw}\n\nRefined entry:"
)
return system.generate(prompt, max_len=100, temperature=0.7, top_p=0.9)
# ─── Seed Book-Title Diary Entries ─────────────────────────────────────────────
for path in book_list:
title = os.path.splitext(os.path.basename(path))[0]
fact = f"I just finished reading “{title}.”"
system.train("", fact)
draft = system.generate(
f"Diary prompt: Stream-of-thought draft about reading “{title}.”",
max_len=100, temperature=0.9, top_p=0.95
)
entry = refine_diary_entry(draft)
life_log.add(entry)
# ─── Idle Book-Training Task (per-book) ────────────────────────────────────────
async def train_books_idle(): async def train_books_idle():
global current_book, line_offset
await client.wait_until_ready() await client.wait_until_ready()
await asyncio.sleep(5) await asyncio.sleep(5)
processed = 0
skip = system.processed_lines
for path in glob.glob('books/*.txt'): # process one book at a time
with open(path, encoding='utf-8') as f: while current_book < total_books:
path = book_list[current_book]
processed = 0
cnt = book_line_counts[current_book]
with open(path, encoding="utf-8") as f:
for raw in f: for raw in f:
text = raw.strip() text = raw.strip()
if not text: if not text:
continue continue
if processed < skip: if processed < line_offset:
processed += 1 processed += 1
continue continue
# train on this line
await asyncio.to_thread(system.train, text, text) await asyncio.to_thread(system.train, text, text)
processed += 1 processed += 1
system.processed_lines = processed line_offset = processed
if processed % 200 == 0 or processed == system.total_lines: # checkpoint every 200 lines or at end
system.sensory.save_vocab('vocab.json') if processed % 200 == 0 or processed == cnt:
with open(PROGRESS_PATH, 'w', encoding='utf-8') as pf: system.sensory.save_vocab(VOCAB_PATH)
json.dump({'processed_lines': processed}, pf) with open(PROGRESS_PATH, "w", encoding="utf-8") as pf:
json.dump({
"current_book": current_book,
"line_offset": line_offset
}, pf, indent=2)
# Final checkpoint # finished current book
system.sensory.save_vocab('vocab.json') print(f"Finished book {current_book+1}/{total_books}: {path}")
with open(PROGRESS_PATH, 'w', encoding='utf-8') as pf: # reset for next book
json.dump({'processed_lines': system.processed_lines}, pf) current_book += 1
line_offset = 0
# save progress
with open(PROGRESS_PATH, "w", encoding="utf-8") as pf:
json.dump({
"current_book": current_book,
"line_offset": 0
}, pf, indent=2)
# optional small break between books
await asyncio.sleep(2)
# all books done; bootstrap persona
await asyncio.to_thread(persona.bootstrap, system)
print("All books trained. Persona bootstrapped:", persona.traits)
# ─── Idle Weather-Ingestion & Diary Task ───────────────────────────────────────
async def ingest_weather_idle():
await client.wait_until_ready()
# sleep until next WEATHER_HOUR
now = datetime.now()
target = datetime.combine(now.date(), time(WEATHER_HOUR))
if now >= target:
target += timedelta(days=1)
await asyncio.sleep((target - now).total_seconds())
while True:
if OPENWEATHER_KEY:
try:
url = (
"https://api.openweathermap.org/data/2.5/weather"
f"?q={WEATHER_CITY}&units=metric&appid={OPENWEATHER_KEY}"
)
data = requests.get(url, timeout=5).json()
desc = data["weather"][0]["description"]
temp = data["main"]["temp"]
fact = f"Current weather: {desc}, {temp:.1f}°C."
await asyncio.to_thread(system.train, "", fact)
draft = system.generate(
f"Diary prompt: Draft about todays weather: {desc}, {temp:.1f}°C.",
max_len=100, temperature=0.9, top_p=0.95
)
entry = refine_diary_entry(draft)
life_log.add(entry)
print("Journaled weather:", entry)
except Exception:
pass
await asyncio.sleep(24 * 3600)
# ─── Idle Self-Reflection Task ─────────────────────────────────────────────────
async def reflect_idle():
await client.wait_until_ready()
while True:
await asyncio.sleep(600)
await asyncio.to_thread(persona.bootstrap, system)
print("Persona adapted:", persona.traits)
@client.event @client.event
async def on_ready(): async def on_ready():
print(f'Ruby is online as {client.user}!') print(f"Ruby is online as {client.user}!")
asyncio.create_task(train_books_idle()) asyncio.create_task(train_books_idle())
asyncio.create_task(ingest_weather_idle())
asyncio.create_task(reflect_idle())
@client.event @client.event
@ -100,27 +218,53 @@ async def on_message(message: discord.Message):
return return
user_text = message.content.strip() user_text = message.content.strip()
reply = system.generate(user_text)
# 1) Diary: top 5 recent
entries = life_log.recent(5)
diary_sec = "### Diary Entries\n"
for e in reversed(entries):
diary_sec += f"- {e}\n"
# 2) Persona
persona_sec = "\n### Persona\n" + persona.summary()
# 3) Conversation
convo_sec = "\n### Conversation\n"
for turn in system.history:
convo_sec += f"User: {turn['user']}\nRuby: {turn['bot']}\n"
convo_sec += f"User: {user_text}\nRuby:"
prompt = diary_sec + persona_sec + convo_sec
reply = system.generate(prompt)
await message.channel.send(reply) await message.channel.send(reply)
system.history.append({'user': user_text, 'bot': reply}) # record & train
asyncio.create_task(asyncio.to_thread(system.train, user_text, reply)) system.history.append({"user": user_text, "bot": reply})
asyncio.create_task(
asyncio.to_thread(system.train, user_text, reply)
# ─── Launch Dashboard & Bot ──────────────────────────────────────────────────── # noqa: E501 )
# ─── Silence Flask/Werkzeug request logs ────────────────────────────────────────
logging.getLogger('werkzeug').setLevel(logging.ERROR)
def run_dashboard(): def run_dashboard():
dashboard.app.run( dashboard.app.run(
host='0.0.0.0', port=5000, host="0.0.0.0",
debug=False, use_reloader=False port=5000,
debug=False,
use_reloader=False
) )
threading.Thread(target=run_dashboard, daemon=True).start() threading.Thread(target=run_dashboard, daemon=True).start()
print('Dashboard available at http://127.0.0.1:5000') dashboard.system = system
dashboard.persona = persona
dashboard.life_log = life_log
dashboard.plugins = plugins
dashboard.brain_map = brain_map
print("Dashboard available at http://127.0.0.1:5000")
token = os.getenv('DISCORD_TOKEN') token = os.getenv("DISCORD_TOKEN")
if not token: if not token:
raise RuntimeError('Please set the DISCORD_TOKEN environment variable') raise RuntimeError("Please set DISCORD_TOKEN in env")
client.run(token) client.run(token)

View File

@ -1,17 +1,21 @@
# dashboard.py
from flask import Flask, render_template, jsonify from flask import Flask, render_template, jsonify
from datetime import datetime
import brain_map import brain_map
app = Flask( app = Flask(
__name__, __name__,
template_folder='templates', template_folder='templates',
static_folder='static', static_folder='static'
) )
# Register the brain_map blueprint
app.register_blueprint(brain_map.bp) app.register_blueprint(brain_map.bp)
# Will be injected from body.py # Injected from body.py
system = None system = None
persona = None
life_log = None
plugins = None
@app.route('/') @app.route('/')
@ -19,6 +23,25 @@ def dashboard():
return render_template('dashboard.html') return render_template('dashboard.html')
@app.route('/stats')
def stats():
"""
Returns JSON with the key metrics for the dashboard to display.
Uses getattr to supply 0 if any attribute is missing.
"""
if system is None:
return jsonify({})
return jsonify({
'processed_lines': getattr(system, 'processed_lines', 0),
'total_lines': getattr(system, 'total_lines', 0),
'history_len': len(getattr(system, 'history', [])),
'life_log_count': len(getattr(life_log, 'entries', [])),
'plugin_count': len(getattr(plugins, 'registry', {})),
'timestamp': datetime.utcnow().timestamp()
})
@app.route('/progress') @app.route('/progress')
def progress(): def progress():
if system is None: if system is None:

28
life_log.py Normal file
View File

@ -0,0 +1,28 @@
import json
import os
class LifeLog:
"""
Records Rubys diary entries over time and lets you fetch her recent reflections.
"""
def __init__(self, path="life_log.json"):
self.path = path
self.entries = []
self.load()
def load(self):
if os.path.isfile(self.path):
with open(self.path, "r", encoding="utf-8") as f:
self.entries = json.load(f)
def save(self):
with open(self.path, "w", encoding="utf-8") as f:
json.dump(self.entries, f, ensure_ascii=False, indent=2)
def add(self, entry: str):
self.entries.append(entry)
self.save()
def recent(self, n=5):
return self.entries[-n:]

View File

@ -1,84 +1,139 @@
# nervous_system.py
import threading
import torch import torch
import torch.optim as optim import torch.nn as nn
from torch.nn import CrossEntropyLoss
import torch.nn.functional as F import torch.nn.functional as F
import torch.optim as optim
from sensory import Sensory from sensory import Sensory
from brain import Brain from brain import Brain
class NervousSystem: class NervousSystem:
"""Wraps the Brain, handles token growth, generation and on-the-fly training.""" # noqa: E501 """Wraps the Brain, handles token growth, generation, and training."""
def __init__(self, device: str = "cuda"): def __init__(self, device: str = "cuda"):
self.device = torch.device(device if torch.cuda.is_available() else "cpu") # noqa: E501 self.device = torch.device(
device if torch.cuda.is_available() else "cpu"
)
self.sensory = Sensory() self.sensory = Sensory()
vocab_size = len(self.sensory.stoi) vocab_size = len(self.sensory.stoi)
self.brain = Brain(vocab_size).to(self.device) self.brain = Brain(vocab_size).to(self.device)
# disable any inplace ops in the model
for m in self.brain.modules():
if hasattr(m, "inplace"):
m.inplace = False # ensure no in-place ReLUs, etc.
self.optimizer = optim.Adam(self.brain.parameters(), lr=1e-4) self.optimizer = optim.Adam(self.brain.parameters(), lr=1e-4)
self.criterion = CrossEntropyLoss(ignore_index=0) self.criterion = nn.CrossEntropyLoss(ignore_index=0)
self.meta_steps = 0 self.meta_steps = 0
# ← NEW: lock to serialize all training calls
self._train_lock = threading.Lock()
def _resize_embeddings(self) -> None: def _resize_embeddings(self) -> None:
"""
Resize token & output embeddings entirely on-device
to match the current vocab size, avoiding any CPUGPU copies.
"""
device = self.device
new_size = len(self.sensory.stoi) new_size = len(self.sensory.stoi)
# ─── Resize token embeddings ────────────────────────────────────────
old_emb = self.brain.token_emb old_emb = self.brain.token_emb
old_num, dim = old_emb.num_embeddings, old_emb.embedding_dim
# rebuild token embeddings # allocate new on same device
self.brain.token_emb = torch.nn.Embedding( new_emb = nn.Embedding(new_size, dim).to(device)
new_size, old_emb.embedding_dim # copy existing weights
).to(self.device)
with torch.no_grad(): with torch.no_grad():
self.brain.token_emb.weight[: old_emb.num_embeddings] = old_emb.weight # noqa: E501 new_emb.weight[:old_num].copy_(old_emb.weight)
self.brain.token_emb = new_emb
# rebuild output head # ─── Resize output head ─────────────────────────────────────────────
old_out = self.brain.fc_out old_out = self.brain.fc_out
self.brain.fc_out = torch.nn.Linear( out_dim, old_out_feat = old_out.in_features, old_out.out_features
old_emb.embedding_dim, new_size
).to(self.device) new_out = nn.Linear(out_dim, new_size).to(device)
with torch.no_grad(): with torch.no_grad():
self.brain.fc_out.weight[: old_out.out_features] = old_out.weight new_out.weight[:old_out_feat].copy_(old_out.weight)
self.brain.fc_out.bias[: old_out.out_features] = old_out.bias new_out.bias[:old_out_feat].copy_(old_out.bias)
self.brain.fc_out = new_out
def generate(self, prompt: str, max_len: int = 50, def generate(
temperature: float = 0.8, top_k: int = 50) -> str: self,
prompt: str,
max_len: int = 50,
temperature: float = 0.8,
top_p: float = 0.9,
) -> str:
"""Autoregressive nucleus sampling with proper cloning to avoid aliasing."""
self.brain.eval() self.brain.eval()
raw_ids = self.sensory.encode(prompt, grow=False)[-self.brain.max_seq_len:] # noqa: E501 eos_id = self.sensory.stoi.get("<eos>")
out = torch.tensor(raw_ids, dtype=torch.long, device=self.device).unsqueeze(0) # noqa: E501
result = [] raw_ids = self.sensory.encode(prompt, grow=False)
max_ctx = self.brain.max_seq_len - 1
if len(raw_ids) > max_ctx:
raw_ids = raw_ids[-max_ctx:]
input_ids = torch.tensor(
raw_ids, dtype=torch.long, device=self.device
).unsqueeze(0)
generated = []
for _ in range(max_len): for _ in range(max_len):
logits = self.brain(out)[:, -1, :] logits = self.brain(input_ids)[:, -1, :] / temperature
# apply temperature probs = F.softmax(logits, dim=-1)
logits = logits / temperature
# top-k filtering
values, indices = torch.topk(logits, top_k)
probs = F.softmax(values, dim=-1)
next_tok = indices[0, torch.multinomial(probs, 1)].unsqueeze(0).unsqueeze(0) # noqa: E501
tok_id = next_tok.item()
if tok_id == self.sensory.stoi["<eos>"]:
break
result.append(tok_id)
out = torch.cat([out, next_tok], dim=1)
return self.sensory.decode(result) # top-p (nucleus) filtering
sorted_p, sorted_idx = torch.sort(probs, descending=True)
cum_p = torch.cumsum(sorted_p, dim=-1)
mask = cum_p > top_p
# clone the slice before writing to avoid overlap
mask_shift = mask[..., :-1].clone()
mask[..., 1:] = mask_shift
sorted_p[mask] = 0
sorted_p = sorted_p / sorted_p.sum(dim=-1, keepdim=True)
next_tok = sorted_idx[0, torch.multinomial(sorted_p[0], 1)]
t_id = next_tok.item()
if eos_id is not None and t_id == eos_id:
break
generated.append(t_id)
input_ids = torch.cat([input_ids, next_tok.unsqueeze(0)], dim=1)
if input_ids.size(1) > self.brain.max_seq_len:
input_ids = input_ids[:, -self.brain.max_seq_len:]
return self.sensory.decode(generated)
def train(self, user_text: str, bot_text: str) -> None: def train(self, user_text: str, bot_text: str) -> None:
# 1) grow vocab on _train_ only """
On-the-fly self-supervised training from a userbot exchange.
Serialized by a threading.Lock to avoid in-place grad conflicts.
"""
with self._train_lock: # ← NEW: no two threads can enter here at once
# 1) grow vocab & resize embeddings
for txt in (user_text, bot_text): for txt in (user_text, bot_text):
_ = self.sensory.encode(txt, grow=True) _ = self.sensory.encode(txt, grow=True)
self._resize_embeddings() self._resize_embeddings()
# ensure <sep> # 2) ensure a <sep> token for concatenation
if "<sep>" not in self.sensory.stoi: if "<sep>" not in self.sensory.stoi:
idx = len(self.sensory.stoi) idx = len(self.sensory.stoi)
self.sensory.stoi["<sep>"] = idx self.sensory.stoi["<sep>"] = idx
self.sensory.itos[idx] = "<sep>" self.sensory.itos[idx] = "<sep>"
self._resize_embeddings() self._resize_embeddings()
# 3) build input/output IDs
combined = f"{user_text} <sep> {bot_text}" combined = f"{user_text} <sep> {bot_text}"
ids = torch.tensor( ids = torch.tensor(
self.sensory.encode(combined, grow=False), dtype=torch.long, device=self.device # noqa: E501 self.sensory.encode(combined, grow=False),
dtype=torch.long,
device=self.device,
).unsqueeze(0) ).unsqueeze(0)
if ids.size(1) < 2: if ids.size(1) < 2:
@ -87,6 +142,7 @@ class NervousSystem:
inputs = ids[:, :-1] inputs = ids[:, :-1]
targets = ids[:, 1:] targets = ids[:, 1:]
# 4) forward + backward + step
self.brain.train() self.brain.train()
logits = self.brain(inputs) logits = self.brain(inputs)
loss = self.criterion( loss = self.criterion(
@ -96,11 +152,8 @@ class NervousSystem:
loss.backward() loss.backward()
self.optimizer.step() self.optimizer.step()
# a tiny meta-learning bump # 5) optional meta learning step adjustment
self.meta_steps += 1 self.meta_steps += 1
if self.meta_steps % 100 == 0: if self.meta_steps % 100 == 0:
for g in self.optimizer.param_groups: for g in self.optimizer.param_groups:
old_lr = g["lr"] g["lr"] *= 1.1
g["lr"] = old_lr * 1.1
torch.cuda.synchronize(self.device)
g["lr"] = old_lr

67
persona.py Normal file
View File

@ -0,0 +1,67 @@
import json
import os
import re
class Persona:
"""
Learns Rubys persona entirely from her model by asking for JSON,
with no hard-coded examples or defaults.
"""
def __init__(self, path="persona.json"):
self.path = path
self.traits = {}
self.load()
def load(self):
if os.path.isfile(self.path):
with open(self.path, "r", encoding="utf-8") as f:
self.traits = json.load(f)
def save(self):
with open(self.path, "w", encoding="utf-8") as f:
json.dump(self.traits, f, ensure_ascii=False, indent=2)
def summary(self) -> str:
if not self.traits:
return ""
name = self.traits.get("name", "")
age = self.traits.get("age", "")
hobbies = self.traits.get("hobbies", [])
tone = self.traits.get("tone", "")
hlist = ", ".join(hobbies) if isinstance(hobbies, list) else str(hobbies)
return f"I'm {name}, {age} years old, I love {hlist}, and speak in a {tone} tone."
def bootstrap(self, system):
"""
Ask Ruby to introspect and output a JSON object with keys:
name, age, hobbies (array), and tone (string). No examples given.
"""
prompt = (
"Based on all the text you have absorbed, introduce yourself by OUTPUTTING "
"ONLY a JSON object with these keys exactly:\n"
' "name": string,\n'
' "age": number,\n'
' "hobbies": array of strings,\n'
' "tone": string describing your speaking style\n'
"Do not output anything else."
)
raw = system.generate(prompt, max_len=150, temperature=0.7, top_p=0.9)
# extract the first JSON object block
m = re.search(r"\{.*?\}", raw, flags=re.DOTALL)
if not m:
return
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
return
# keep only expected keys
updated = {}
for key in ("name", "age", "hobbies", "tone"):
if key in data:
updated[key] = data[key]
if updated:
self.traits = updated
self.save()

55
plugin_manager.py Normal file
View File

@ -0,0 +1,55 @@
import os
import importlib.util
import sys
from typing import Any, Callable, Dict
PLUGINS_DIR = "plugins"
os.makedirs(PLUGINS_DIR, exist_ok=True)
class PluginManager:
"""
Dynamically loads Python modules from plugins/ and
exposes their functions in a registry.
"""
def __init__(self):
self.registry: Dict[str, Callable[..., Any]] = {}
self._load_all()
def _load_all(self):
"""Scan plugins/ and import every .py as a module."""
for fname in os.listdir(PLUGINS_DIR):
if not fname.endswith(".py"):
continue
path = os.path.join(PLUGINS_DIR, fname)
name = os.path.splitext(fname)[0]
self._load_module(name, path)
def _load_module(self, name: str, path: str):
"""Load a single plugin module and register its callables."""
spec = importlib.util.spec_from_file_location(name, path)
if spec and spec.loader:
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod) # type: ignore
for attr in dir(mod):
if attr.startswith("_"):
continue
obj = getattr(mod, attr)
if callable(obj):
key = f"{name}.{attr}"
self.registry[key] = obj
def register_plugin(self, code: str, name: str):
"""
Persist a new plugin file (name.py), load it immediately,
and add its functions to the registry.
"""
path = os.path.join(PLUGINS_DIR, f"{name}.py")
with open(path, "w", encoding="utf-8") as f:
f.write(code)
# replace any existing module
if name in sys.modules:
del sys.modules[name]
self._load_module(name, path)

View File

@ -1,13 +1,14 @@
import os
import json import json
import os
class Sensory: class Sensory:
"""Dynamic whitespace tokenizer that can grow (or not) its vocab.""" """Dynamic whitespace tokenizer that can grow (or not) its vocab."""
def __init__(self): def __init__(self):
self.stoi = {"<pad>": 0, "<unk>": 1} # ensure <pad>, <unk>, AND <eos> are present from the start
self.itos = {0: "<pad>", 1: "<unk>"} self.stoi = {"<pad>": 0, "<unk>": 1, "<eos>": 2}
self.itos = {0: "<pad>", 1: "<unk>", 2: "<eos>"}
def encode(self, text: str, grow: bool = True) -> list[int]: def encode(self, text: str, grow: bool = True) -> list[int]:
ids: list[int] = [] ids: list[int] = []
@ -25,24 +26,27 @@ class Sensory:
return ids return ids
def decode(self, ids: list[int]) -> str: def decode(self, ids: list[int]) -> str:
return " ".join(self.itos.get(i, "<unk>") for i in ids) out = []
for i in ids:
if i == self.stoi["<eos>"]:
break
out.append(self.itos.get(i, "<unk>"))
return " ".join(out)
def save_vocab(self, path: str = "vocab.json") -> None: def save_vocab(self, path: str = "vocab.json") -> None:
"""Dump stoi+itos to disk.""" data = {"stoi": self.stoi, "itos": {str(k): v for k, v in self.itos.items()}}
data = {
"stoi": self.stoi,
# JSON keys must be strings
"itos": {str(k): v for k, v in self.itos.items()}
}
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2) json.dump(data, f, indent=2)
def load_vocab(self, path: str = "vocab.json") -> None: def load_vocab(self, path: str = "vocab.json") -> None:
"""Load stoi+itos if it exists."""
if not os.path.isfile(path): if not os.path.isfile(path):
return return
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
self.stoi = data["stoi"] self.stoi = data["stoi"]
# convert itos keys back to int
self.itos = {int(k): v for k, v in data["itos"].items()} self.itos = {int(k): v for k, v in data["itos"].items()}
# if somehow <eos> got lost, re-add it
if "<eos>" not in self.stoi:
idx = len(self.stoi)
self.stoi["<eos>"] = idx
self.itos[idx] = "<eos>"

View File

@ -3,70 +3,146 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<title>Ruby Dashboard</title> <title>Ruby Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<!-- Bootstrap CSS -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.5.1/dist/chart.min.js"></script>
<style> <style>
body { body {
background:#1e1e1e; color:#ddd; background-color: #121212;
font-family:sans-serif; padding:20px; color: #e0e0e0;
font-family: Arial, sans-serif;
} }
h1 { color:#fff; } .card {
.section { margin-bottom:20px; } background-color: #1e1e1e;
button { border: none;
background:#333; border:1px solid #555;
color:#ddd; padding:8px 12px;
border-radius:4px; cursor:pointer;
} }
#history { .card-title, .card-text {
max-height:300px; overflow:auto; color: #ffffff;
border:1px solid #444; padding:10px;
border-radius:4px; background:#2e2e2e;
} }
.entry { margin-bottom:8px; }
.user { color:#8af; }
.bot { color:#fa8; }
</style> </style>
</head> </head>
<body> <body>
<h1>Ruby Dashboard</h1> <div class="container py-4">
<h1 class="mb-4">Ruby Dashboard</h1>
<div class="section"> <!-- Metrics Cards -->
<strong id="progress">Progress: 0/0</strong> <div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card p-3">
<h5 class="card-title">Training Progress</h5>
<div class="progress mb-2">
<div
id="training-bar"
class="progress-bar bg-success"
role="progressbar"
style="width: 0%"
>0%</div>
</div>
<p class="card-text">
<span id="processed-count">0</span> /
<span id="total-count">0</span> lines
</p>
</div>
</div>
<div class="col-md-3">
<div class="card p-3">
<h5 class="card-title">Recent Turns</h5>
<p class="card-text"><span id="history-len">0</span> turns</p>
</div>
</div>
<div class="col-md-3">
<div class="card p-3">
<h5 class="card-title">Diary Entries</h5>
<p class="card-text"><span id="life-log-count">0</span> entries</p>
</div>
</div>
<div class="col-md-3">
<div class="card p-3">
<h5 class="card-title">Plugins</h5>
<p class="card-text"><span id="plugin-count">0</span></p>
</div>
</div>
</div> </div>
<div class="section"> <!-- Progress Over Time Chart -->
<h2>Recent Interactions</h2> <div class="card p-3 mb-4">
<div id="history">Loading…</div> <h5 class="card-title">Processed Lines Over Time</h5>
<canvas id="progressChart" height="100"></canvas>
</div> </div>
<div class="section"> <!-- Brain Map Link -->
<button id="load-graph">Load Brain Map</button> <div class="text-center">
<a href="/graph" class="btn btn-outline-light">View Brain Map</a>
</div>
</div> </div>
<script> <script>
async function refreshProgress() { const ctx = document.getElementById('progressChart').getContext('2d');
const { processed, total } = await fetch('/progress').then(r=>r.json()); const chart = new Chart(ctx, {
document.getElementById('progress').textContent = type: 'line',
`Progress: ${processed}/${total}`; data: {
labels: [],
datasets: [{
label: 'Processed Lines',
data: [],
fill: false,
tension: 0.2,
borderColor: '#4caf50'
}]
},
options: {
scales: {
x: { title: { display: true, text: 'Time' }, ticks: { color: '#e0e0e0' } },
y: { title: { display: true, text: 'Lines' }, ticks: { color: '#e0e0e0' } }
},
plugins: { legend: { labels: { color: '#e0e0e0' } } }
} }
async function refreshHistory() {
const hist = await fetch('/interactions').then(r=>r.json());
const div = document.getElementById('history');
div.innerHTML = '';
if (hist.length === 0) {
div.textContent = 'No interactions yet.';
return;
}
hist.slice(-20).forEach(({user, bot}) => {
const e = document.createElement('div'); e.className='entry';
const u = document.createElement('div'); u.className='user'; u.textContent='User: '+user;
const b = document.createElement('div'); b.className='bot'; b.textContent='Bot: '+bot;
e.append(u, b); div.appendChild(e);
}); });
async function updateStats() {
try {
const res = await fetch('/stats');
const s = await res.json();
const { processed_lines, total_lines,
history_len, life_log_count,
plugin_count, timestamp } = s;
// update cards
document.getElementById('processed-count').textContent = processed_lines;
document.getElementById('total-count').textContent = total_lines;
const pct = total_lines
? Math.round((processed_lines / total_lines) * 100)
: 0;
const bar = document.getElementById('training-bar');
bar.style.width = pct + '%';
bar.textContent = pct + '%';
document.getElementById('history-len').textContent = history_len;
document.getElementById('life-log-count').textContent = life_log_count;
document.getElementById('plugin-count').textContent = plugin_count;
// update chart
const timeLabel = new Date(timestamp * 1000).toLocaleTimeString();
chart.data.labels.push(timeLabel);
chart.data.datasets[0].data.push(processed_lines);
if (chart.data.labels.length > 20) {
chart.data.labels.shift();
chart.data.datasets[0].data.shift();
} }
document.getElementById('load-graph') chart.update();
.onclick = () => window.location = '/graph'; } catch (err) {
refreshProgress(); refreshHistory(); console.error('Failed to fetch stats', err);
setInterval(refreshProgress, 5000); }
setInterval(refreshHistory, 5000); }
// initial load + periodic
updateStats();
setInterval(updateStats, 5000);
</script> </script>
</body> </body>
</html> </html>