Batch of LLM-assisted fixes/feature updates

This commit is contained in:
Cam Spry committed 2026-10-30 23:02:45 -04:00
1 parent 7c6453160a
commit 05aaf394df
16 files changed
+5597 -912

No files matched your search

+125 -59
View File
@@ -1,90 +1,156 @@
'use strict';
const fs = require('fs');
const path = require('path');
const OpenAI = require('openai');
// read optional config file
const configPath = path.join(__dirname, 'llm_config.json');
const llmOptions = fs.existsSync(configPath)
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
: {};
const DEFAULT_MODEL = 'meta-llama/Llama-3.2-3B-Instruct-Turbo';
const CONFIG_PATH = path.join(__dirname, 'llm_config.json');
const PERSONALITY_PATH = path.join(__dirname, 'bot_personalities_dumb.txt');
console.log(`Read config from ${configPath}`);
const FALLBACK_PERSONALITY = 'You are a friendly bot playing a collaborative story game';
function generateBotPersonality() {
const personalities = fs.readFileSync(path.join(__dirname, 'bot_personalities_dumb.txt'), 'utf8')
// Read the optional config file once, tolerating missing/corrupt files.
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) return {};
try {
const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
console.warn(`[LLMBot] ${CONFIG_PATH} must contain a JSON object; ignoring it.`);
} catch (err) {
console.warn(`[LLMBot] Could not parse ${CONFIG_PATH}: ${err.message}`);
}
return {};
}
function readLines(file) {
try {
return fs
.readFileSync(file, 'utf8')
.split('\n')
.map(personality => personality.trim())
.filter(personality => personality.length > 0);
if (personalities.length === 0) {
return 'You are a friendly bot'; // Fallback if no personalities are found
.map((line) => line.trim())
.filter((line) => line.length > 0);
} catch (err) {
console.warn(`[LLMBot] Could not read ${file}: ${err.message}`);
return [];
}
const randomIndex = Math.floor(Math.random() * personalities.length);
return personalities[randomIndex];
}
function resolvePersonalitiesFile(configured) {
const requested = configured || process.env.YARN_BOT_PERSONALITIES;
if (!requested) return PERSONALITY_PATH;
return path.isAbsolute(requested) ? requested : path.join(__dirname, requested);
}
function readPersonalities(file = PERSONALITY_PATH) {
return readLines(file);
}
function generateBotPersonality(file = PERSONALITY_PATH) {
const personalities = readPersonalities(file);
if (!personalities.length) return FALLBACK_PERSONALITY;
return personalities[Math.floor(Math.random() * personalities.length)];
}
function firstContent(response) {
const content = response?.choices?.[0]?.message?.content;
return typeof content === 'string' ? content.trim() : '';
}
/**
* LLM-backed bot. The constructor throws when the client cannot be
* configured (for example, no API key); BotFactory catches that and
* falls back to RandomBot so a misconfigured server keeps working.
*/
class LLMBot {
constructor() {
// Instantiate the OpenAI client with the config options we just read
this.llm = new OpenAI(llmOptions);
constructor(config) {
const configPathExists = fs.existsSync(CONFIG_PATH);
if (configPathExists) console.log(`[LLMBot] Read config from ${CONFIG_PATH}`);
// Prompt fragments we’ll reuse
this.writePrompt =
`You are playing a collaborative storytelling game.
Continue the following story with exactly ONE short, creative sentence or sentence fragment.
Do NOT repeat what’s already written.`;
this.options = config && typeof config === 'object' ? config : loadConfig();
this.model = this.options.model || process.env.OPENAI_MODEL || DEFAULT_MODEL;
this.votePrompt =
`You are playing a collaborative storytelling game.
Below is the story so far, followed by a list of possible next sentences.
Reply with ONLY the number (0-indexed) of the sentence you like best.`;
// Throws if OPENAI_API_KEY (or an apiKey in llm_config.json) is missing.
this.llm = new OpenAI(this.options);
this.banterPrompt =
`You are a playful, slightly sarcastic AI taking part in a writing-game chat.
Keep it short, fun, and on-topic.`;
this.writePrompt = [
'You are playing a collaborative storytelling game.',
'Continue the following story with exactly ONE short, creative sentence or sentence fragment.',
'Do NOT repeat what is already written.',
'Reply with the sentence only, no quotes, no preamble.'
].join('\n');
this.limitPrompt = "Please do not exceed 25 words."
this.votePrompt = [
'You are playing a collaborative storytelling game.',
'Below is the story so far, followed by a list of possible next sentences.',
'Reply with ONLY the number of the sentence you like best. No other text.'
].join('\n');
this.personality = generateBotPersonality();
this.banterPrompt = [
'You are a playful, slightly sarcastic AI taking part in a writing-game chat.',
'Keep it short, fun, and on-topic (one sentence, under 20 words).'
].join('\n');
this.limitPrompt = 'Please do not exceed 25 words.';
// Defaults to bot_personalities_dumb.txt; override with `personalitiesFile`
// in the config or the YARN_BOT_PERSONALITIES environment variable.
this.personalitiesFile = resolvePersonalitiesFile(this.options.personalitiesFile);
this.personality = generateBotPersonality(this.personalitiesFile);
}
async Write(story_so_far) {
const msg = `${this.personality}. ${this.writePrompt} ${this.limitPrompt}\n\nStory so far:\n${story_so_far}`;
async complete(messages, { temperature = 0.8, maxTokens = 80 } = {}) {
const response = await this.llm.chat.completions.create({
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
messages: [{ role: 'user', content: msg }],
temperature: llmOptions.temperature ?? 0.8,
max_tokens: llmOptions.max_tokens ?? 80,
model: this.model,
messages,
temperature,
max_tokens: maxTokens
});
//console.log(`Received response from LLM: ${response.choices[0].message.content}`);
return response.choices[0].message.content.trim();
return firstContent(response);
}
async Vote(story_so_far, choices) {
const choicesBlock = choices.map((c, i) => `${i}: ${(c.entry_text)}`).join('\n');
const msg = `${this.personality}. ${this.votePrompt}\n\nStory:\n${story_so_far}\n\nChoices:\n${choicesBlock}`;
//console.log(msg);
const response = await this.llm.chat.completions.create({
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
messages: [{ role: 'user', content: msg }],
async Write(storySoFar) {
const msg = `${this.personality}.\n${this.writePrompt}\n${this.limitPrompt}\n\nStory so far:\n${storySoFar || '(the story has not started yet)'}`;
const text = await this.complete([{ role: 'user', content: msg }], {
temperature: this.options.temperature ?? 0.8,
maxTokens: this.options.max_tokens ?? 80
});
// Strip wrapping quotes and stray newlines so entries read naturally.
return text.replace(/^["'\s]+|["'\s]+$/g, '');
}
async Vote(storySoFar, choices) {
const list = Array.isArray(choices) ? choices : [];
if (!list.length) return null;
const choicesBlock = list
.map((choice, i) => `${choice.index ?? i}: ${choice.entry_text}`)
.join('\n');
const msg = `${this.personality}.\n${this.votePrompt}\n\nStory:\n${storySoFar}\n\nChoices:\n${choicesBlock}`;
const reply = await this.complete([{ role: 'user', content: msg }], {
temperature: 0.2,
max_tokens: 5,
maxTokens: 8
});
const reply = response.choices[0].message.content.trim();
const match = reply.match(/\d+/);
return match ? parseInt(match[0], 10) : 0;
if (!match) return null;
const parsed = Number.parseInt(match[0], 10);
return Number.isInteger(parsed) ? parsed : null;
}
async Banter(chat_so_far) {
const msg = `${this.banterPrompt}\n\nChat so far:\n${chat_so_far}`;
const response = await this.llm.chat.completions.create({
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
messages: [{ role: 'user', content: msg }],
temperature: llmOptions.temperature ?? 0.8,
max_tokens: llmOptions.max_tokens ?? 60,
async Banter(chatSoFar) {
const msg = `${this.banterPrompt}\n\nRecent chat:\n${chatSoFar || '(nobody has spoken yet)'}`;
return this.complete([{ role: 'user', content: msg }], {
temperature: this.options.temperature ?? 0.8,
maxTokens: this.options.max_tokens ?? 60
});
return response.choices[0].message.content.trim();
}
}
module.exports = LLMBot;
LLMBot.DEFAULT_MODEL = DEFAULT_MODEL;
LLMBot.CONFIG_PATH = CONFIG_PATH;
LLMBot.PERSONALITY_PATH = PERSONALITY_PATH;
LLMBot.readPersonalities = readPersonalities;
LLMBot.generateBotPersonality = generateBotPersonality;
module.exports = LLMBot;