moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

147
packages/cli/src/auth.ts Normal file
View File

@@ -0,0 +1,147 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { createServer } from 'node:http';
import { execFile } from 'node:child_process';
import { URL } from 'node:url';
const DEFAULT_CONFIG_DIR = join(homedir(), '.waggle');
const DEFAULT_SERVER_URL = 'http://localhost:3000';
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
interface AuthData {
token: string;
email: string;
serverUrl: string;
}
interface WaggleConfig {
auth?: AuthData;
[key: string]: unknown;
}
export class AuthManager {
private configDir: string;
private configPath: string;
constructor(configDir?: string) {
this.configDir = configDir ?? DEFAULT_CONFIG_DIR;
this.configPath = join(this.configDir, 'config.json');
}
getToken(): string | null {
const auth = this.readAuth();
return auth?.token ?? null;
}
getEmail(): string | null {
const auth = this.readAuth();
return auth?.email ?? null;
}
getServerUrl(): string {
const auth = this.readAuth();
return auth?.serverUrl ?? DEFAULT_SERVER_URL;
}
isLoggedIn(): boolean {
return this.getToken() !== null;
}
saveToken(token: string, email: string): void {
const config = this.readConfig();
config.auth = {
token,
email,
serverUrl: config.auth?.serverUrl ?? DEFAULT_SERVER_URL,
};
this.writeConfig(config);
}
logout(): void {
const config = this.readConfig();
delete config.auth;
this.writeConfig(config);
}
async loginWithBrowser(clerkUrl: string): Promise<{ token: string; email: string }> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
if (!req.url?.startsWith('/callback')) {
res.writeHead(404);
res.end('Not found');
return;
}
const url = new URL(req.url, `http://127.0.0.1`);
const token = url.searchParams.get('token');
const email = url.searchParams.get('email');
if (!token || !email) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Login failed</h1><p>Missing token or email.</p></body></html>');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Login successful!</h1><p>You can close this tab and return to the terminal.</p></body></html>');
clearTimeout(timeout);
server.close();
resolve({ token, email });
});
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to start callback server'));
return;
}
const port = address.port;
const redirectUrl = `http://127.0.0.1:${port}/callback`;
const loginUrl = `${clerkUrl}?redirect_url=${encodeURIComponent(redirectUrl)}`;
openBrowser(loginUrl);
});
const timeout = setTimeout(() => {
server.close();
reject(new Error('Login timed out after 5 minutes'));
}, LOGIN_TIMEOUT_MS);
});
}
private readAuth(): AuthData | null {
const config = this.readConfig();
return config.auth ?? null;
}
private readConfig(): WaggleConfig {
try {
if (!existsSync(this.configPath)) return {};
const raw = readFileSync(this.configPath, 'utf-8');
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return parsed as WaggleConfig;
}
return {};
} catch {
return {};
}
}
private writeConfig(config: WaggleConfig): void {
mkdirSync(this.configDir, { recursive: true });
writeFileSync(this.configPath, JSON.stringify(config, null, 2));
}
}
export function openBrowser(url: string): void {
const platform = process.platform;
if (platform === 'win32') {
execFile('cmd.exe', ['/c', 'start', '""', url]);
} else if (platform === 'darwin') {
execFile('open', [url]);
} else {
execFile('xdg-open', [url]);
}
}

View File

@@ -0,0 +1,50 @@
/**
* Slash command parser for the Waggle CLI REPL.
*/
export interface SlashCommand {
name: string;
args: string;
}
/**
* Parse a user input line as a slash command.
* Returns null if the input is not a slash command.
*
* Examples:
* "/model gpt-4o" → { name: "model", args: "gpt-4o" }
* "/exit" → { name: "exit", args: "" }
* "hello" → null
*/
export function parseCommand(input: string): SlashCommand | null {
const trimmed = input.trim();
if (!trimmed.startsWith('/')) return null;
const spaceIdx = trimmed.indexOf(' ');
if (spaceIdx === -1) {
return { name: trimmed.slice(1), args: '' };
}
return {
name: trimmed.slice(1, spaceIdx),
args: trimmed.slice(spaceIdx + 1).trim(),
};
}
/** Help text for each supported slash command. */
export const COMMANDS: Record<string, string> = {
model: '/model <name> — Switch to a different model',
models: '/models — List all available models',
exit: '/exit — Quit the REPL',
clear: '/clear — Clear conversation history',
help: '/help — Show this help message',
identity: '/identity — Show agent identity',
admin: '/admin <cmd> — Admin commands (teams|jobs|cron|audit|stats)',
login: '/login — Log in via browser (Clerk OAuth)',
logout: '/logout — Log out and clear stored token',
whoami: '/whoami — Show current user and mode',
mode: '/mode — Show current mode (local/team)',
cost: '/cost — Show token usage and estimated cost',
plan: '/plan — Show current execution plan',
git: '/git — Show git status for workspace',
};

View File

@@ -0,0 +1,78 @@
/**
* Admin CLI client for Waggle server REST API.
*
* Provides methods to manage teams, jobs, cron schedules,
* audit logs, and usage stats from the CLI.
*/
const DEFAULT_API_BASE = 'http://localhost:3100';
export class AdminClient {
private readonly apiBase: string;
private readonly token: string;
constructor(apiBase?: string, token?: string) {
this.apiBase = apiBase ?? process.env.WAGGLE_API_URL ?? DEFAULT_API_BASE;
this.token = token ?? process.env.WAGGLE_TOKEN ?? '';
}
private async request<T = unknown>(path: string): Promise<T> {
const res = await fetch(`${this.apiBase}${path}`, {
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`API ${res.status}: ${body || res.statusText}`);
}
return res.json() as Promise<T>;
}
/** List all teams the authenticated user has access to. */
async listTeams(): Promise<unknown> {
return this.request('/api/teams');
}
/** List agent jobs, optionally filtered by team slug. */
async listJobs(teamSlug: string): Promise<unknown> {
return this.request(`/api/jobs?teamSlug=${encodeURIComponent(teamSlug)}`);
}
/** List cron schedules for a team. */
async listCron(teamSlug: string): Promise<unknown> {
return this.request(`/api/teams/${encodeURIComponent(teamSlug)}/cron`);
}
/** List audit log entries for a team. */
async listAudit(teamSlug: string): Promise<unknown> {
return this.request(`/api/admin/teams/${encodeURIComponent(teamSlug)}/audit`);
}
/** Get usage statistics for a team. */
async getStats(teamSlug: string): Promise<unknown> {
return this.request(`/api/admin/teams/${encodeURIComponent(teamSlug)}/usage`);
}
}
/**
* Format admin data as a simple table for CLI output.
* Takes an array of objects and prints key-value columns.
*/
export function formatTable(rows: Record<string, unknown>[], columns?: string[]): string {
if (rows.length === 0) return ' (no data)';
const keys = columns ?? Object.keys(rows[0]);
const widths = keys.map((k) =>
Math.max(k.length, ...rows.map((r) => String(r[k] ?? '').length)),
);
const header = keys.map((k, i) => k.padEnd(widths[i])).join(' ');
const separator = widths.map((w) => '-'.repeat(w)).join(' ');
const body = rows
.map((r) => keys.map((k, i) => String(r[k] ?? '').padEnd(widths[i])).join(' '))
.join('\n');
return ` ${header}\n ${separator}\n ${body}`;
}

59
packages/cli/src/index.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* Waggle CLI entry point.
*
* Usage:
* waggle Start interactive REPL
* waggle --model <name> Start with a specific model
* waggle --help Show help
*/
function printHelp(): void {
console.log(`
Waggle CLI — interactive AI agent with persistent memory
Usage:
waggle Start interactive REPL
waggle --model <name> Start with a specific model
waggle --local Force local mode (no server)
waggle --team Force team mode (requires login)
waggle --help Show this help message
Configuration:
Edit ~/.waggle/config.json to set up providers and API keys.
Commands (inside REPL):
/model <name> Switch model
/models List available models
/identity Show agent identity
/admin <cmd> Admin commands (teams|jobs|cron|audit|stats)
/clear Clear conversation
/help Show commands
/exit Quit
`);
}
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
printHelp();
process.exit(0);
}
let model: string | undefined;
const modelIdx = args.indexOf('--model');
if (modelIdx !== -1 && args[modelIdx + 1]) {
model = args[modelIdx + 1];
}
const local = args.includes('--local');
const team = args.includes('--team');
async function main(): Promise<void> {
const { startRepl } = await import('./repl.js');
await startRepl({ model, local, team });
}
main().catch((err) => {
console.error('Fatal error:', (err as Error).message);
process.exit(1);
});

View File

@@ -0,0 +1,56 @@
export type WaggleMode = {
type: 'local' | 'team' | 'error';
warning?: string;
error?: string;
};
export interface ModeDetectorDeps {
hasToken: boolean;
serverUrl: string;
forceLocal: boolean;
forceTeam: boolean;
healthCheck: (serverUrl: string) => Promise<boolean>;
}
export async function detectMode(deps: ModeDetectorDeps): Promise<WaggleMode> {
// 1. --local flag → always local mode
if (deps.forceLocal) {
return { type: 'local' };
}
// 2. --team flag + no token → error
if (deps.forceTeam && !deps.hasToken) {
return { type: 'error', error: 'Team mode requires login. Run: waggle login' };
}
// 3. --team flag + token → team mode
if (deps.forceTeam && deps.hasToken) {
return { type: 'team' };
}
// 4. No token → local mode
if (!deps.hasToken) {
return { type: 'local' };
}
// 5. Token + server reachable → team mode
const reachable = await deps.healthCheck(deps.serverUrl);
if (reachable) {
return { type: 'team' };
}
// 6. Token + server unreachable → local mode with warning
return { type: 'local', warning: 'Server unreachable — running in local mode.' };
}
export async function checkServerHealth(serverUrl: string): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${serverUrl}/health`, { signal: controller.signal });
clearTimeout(timeout);
return response.ok;
} catch {
return false;
}
}

View File

@@ -0,0 +1,82 @@
/**
* Simple terminal markdown renderer using chalk.
*
* Handles: bold, inline code, code blocks, headers, list items.
* No heavy deps — just chalk.
*/
import chalk from 'chalk';
/**
* Render a markdown string for terminal display.
*/
export function renderMarkdown(text: string): string {
const lines = text.split('\n');
const output: string[] = [];
let inCodeBlock = false;
for (const line of lines) {
// Code block toggle
if (line.trimStart().startsWith('```')) {
inCodeBlock = !inCodeBlock;
if (inCodeBlock) {
output.push(chalk.dim('─'.repeat(40)));
} else {
output.push(chalk.dim('─'.repeat(40)));
}
continue;
}
// Inside code block — dim, no further formatting
if (inCodeBlock) {
output.push(chalk.cyan(line));
continue;
}
let rendered = line;
// Headers
if (rendered.startsWith('### ')) {
output.push(chalk.bold.yellow(rendered.slice(4)));
continue;
}
if (rendered.startsWith('## ')) {
output.push(chalk.bold.yellow(rendered.slice(3)));
continue;
}
if (rendered.startsWith('# ')) {
output.push(chalk.bold.yellow(rendered.slice(2)));
continue;
}
// List items
if (/^\s*[-*]\s/.test(rendered)) {
const match = rendered.match(/^(\s*)[-*]\s(.*)$/);
if (match) {
const indent = match[1];
const content = match[2];
rendered = `${indent}${chalk.green('•')} ${formatInline(content)}`;
output.push(rendered);
continue;
}
}
// Regular line — apply inline formatting
output.push(formatInline(rendered));
}
return output.join('\n');
}
/**
* Apply inline formatting: bold (**text**) and inline code (`text`).
*/
function formatInline(text: string): string {
// Inline code: `text`
let result = text.replace(/`([^`]+)`/g, (_match, code: string) => chalk.cyan(code));
// Bold: **text**
result = result.replace(/\*\*([^*]+)\*\*/g, (_match, bold: string) => chalk.bold(bold));
return result;
}

496
packages/cli/src/repl.ts Normal file
View File

@@ -0,0 +1,496 @@
/**
* Interactive REPL for the Waggle CLI.
*
* Loads WaggleConfig, MindDB, Orchestrator, ModelRouter.
* Uses runAgentLoop() via LiteLLM for all chat interactions.
* Supports slash commands and multi-model chat with tool use.
*/
import readline from 'node:readline';
import os from 'node:os';
import path from 'node:path';
import chalk from 'chalk';
import { MindDB, WaggleConfig, createLiteLLMEmbedder } from '@waggle/core';
import { Orchestrator, ModelRouter, runAgentLoop, createSystemTools, createPlanTools, createGitTools, Workspace, ensureIdentity, loadSystemPromptWithOverrides, assertOverridesReachActiveSpec, loadSkills, CostTracker, HookRegistry, loadHooksFromConfig, needsConfirmation } from '@waggle/agent';
import { parseCommand, COMMANDS } from './commands.js';
import { renderMarkdown } from './renderer.js';
import { AdminClient, formatTable } from './commands/admin.js';
import { AuthManager } from './auth.js';
import { detectMode, checkServerHealth } from './mode-detector.js';
/**
* Build an embedder backed by LiteLLM's /embeddings endpoint.
* Falls back to a deterministic mock (text→Float32Array hash) if the API is unavailable,
* so the CLI always works even without a running LiteLLM proxy.
*/
function buildEmbedder(litellmUrl: string, litellmApiKey: string) {
return createLiteLLMEmbedder({
litellmUrl,
litellmApiKey,
model: 'text-embedding',
dimensions: 1024,
fallbackToMock: true,
});
}
export interface ReplOptions {
model?: string;
local?: boolean;
team?: boolean;
}
export async function startRepl(options: ReplOptions = {}): Promise<void> {
// Load config
const config = new WaggleConfig();
const mindPath = config.getMindPath();
// Open (or create) .mind database
const db = new MindDB(mindPath);
// Build model router from config
const providers = config.getProviders();
const defaultModel = options.model ?? config.getDefaultModel();
const router = new ModelRouter({
providers,
defaultModel,
});
let currentModel = defaultModel;
// Token/cost tracker for /cost command
const costTracker = new CostTracker({});
// Init workspace
const workspace = new Workspace(process.cwd());
workspace.init();
// Load user customizations from ~/.waggle/ — H-08 G2: override-aware,
// so any deployed behavioral-spec or persona evolution takes effect at
// startup.
const waggleHome = path.join(os.homedir(), '.waggle');
const composedPrompt = loadSystemPromptWithOverrides(waggleHome);
const userSystemPrompt = composedPrompt.userPromptMd;
assertOverridesReachActiveSpec(waggleHome, composedPrompt.behavioralSpec);
const skills = loadSkills(waggleHome);
// Load user-configurable hooks from ~/.waggle/hooks.json
const hookRegistry = new HookRegistry();
await loadHooksFromConfig(path.join(waggleHome, 'hooks.json'), hookRegistry);
// Detect mode
const auth = new AuthManager();
const modeResult = await detectMode({
hasToken: auth.isLoggedIn(),
serverUrl: auth.getServerUrl(),
forceLocal: options.local ?? false,
forceTeam: options.team ?? false,
healthCheck: checkServerHealth,
});
if (modeResult.type === 'error') {
console.log(chalk.red(modeResult.error ?? 'Mode detection failed.'));
db.close();
process.exit(1);
}
// Get LiteLLM config from workspace
const wsConfig = workspace.getConfig();
const litellmUrl = wsConfig.litellmUrl ?? 'http://localhost:4000/v1';
// LiteLLM master key for proxy auth (NOT the provider API key — LiteLLM reads that from its own env)
const litellmApiKey = process.env.LITELLM_API_KEY ?? process.env.LITELLM_MASTER_KEY ?? 'sk-waggle-dev';
// Build embedder backed by LiteLLM (falls back to mock if API unavailable)
const embedder = buildEmbedder(litellmUrl, litellmApiKey);
// Build orchestrator
const orchestrator = new Orchestrator({ db, embedder });
// Ensure identity exists (first-run wizard)
ensureIdentity(orchestrator.getIdentity());
// Build system tools
const systemTools = createSystemTools(process.cwd());
// Build plan tools
const planTools = createPlanTools();
// Build git tools
const gitTools = createGitTools(process.cwd());
// Start session
const sessionId = workspace.startSession();
// Stats for welcome banner
const frameCount = (db.getDatabase().prepare('SELECT COUNT(*) as cnt FROM memory_frames').get() as { cnt: number }).cnt;
const sessionCount = (db.getDatabase().prepare('SELECT COUNT(*) as cnt FROM sessions').get() as { cnt: number }).cnt;
// Print welcome banner
console.log('');
console.log(chalk.bold.magenta(' Waggle') + chalk.dim(' — AI agent with persistent memory'));
console.log(chalk.dim(' ─────────────────────────────────────'));
console.log(chalk.dim(' Mode: ') + chalk.cyan(modeResult.type));
console.log(chalk.dim(' Model: ') + chalk.cyan(currentModel));
console.log(chalk.dim(' Memory: ') + chalk.cyan(`${frameCount} frames`));
console.log(chalk.dim(' Sessions: ') + chalk.cyan(`${sessionCount}`));
console.log(chalk.dim(' Mind: ') + chalk.cyan(mindPath));
console.log(chalk.dim(' Workspace:') + chalk.cyan(` ${process.cwd()}`));
if (modeResult.warning) {
console.log(chalk.yellow(' ⚠ ' + modeResult.warning));
}
console.log(chalk.dim(' Type /help for commands'));
console.log('');
// Conversation history for agent loop
let conversationHistory: Array<{ role: string; content: string }> = [];
// Setup readline
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: chalk.green('you > '),
});
// Register confirmation gate as pre:tool hook
hookRegistry.on('pre:tool', async (ctx) => {
if (!ctx.toolName || !needsConfirmation(ctx.toolName)) return;
// Ask user for confirmation via readline
const answer = await new Promise<string>((resolve) => {
rl.question(
chalk.yellow(` ⚠ Allow ${ctx.toolName}? [Y/n] `),
(ans) => resolve(ans.trim().toLowerCase()),
);
});
if (answer === 'n' || answer === 'no') {
return { cancel: true, reason: `User denied ${ctx.toolName}` };
}
});
rl.prompt();
rl.on('line', async (line: string) => {
const input = line.trim();
if (!input) {
rl.prompt();
return;
}
// Check for slash commands
const cmd = parseCommand(input);
if (cmd) {
switch (cmd.name) {
case 'exit':
console.log(chalk.dim('Goodbye!'));
rl.close();
return;
case 'help':
console.log('');
console.log(chalk.bold('Available commands:'));
for (const [, helpText] of Object.entries(COMMANDS)) {
console.log(chalk.dim(' ' + helpText));
}
console.log('');
break;
case 'model':
if (!cmd.args) {
console.log(chalk.dim(`Current model: ${currentModel}`));
} else {
try {
router.resolve(cmd.args);
currentModel = cmd.args;
console.log(chalk.green(`Switched to model: ${currentModel}`));
} catch {
console.log(chalk.red(`Unknown model: ${cmd.args}`));
console.log(chalk.dim(`Available: ${router.listModels().join(', ')}`));
}
}
break;
case 'models': {
const models = router.listModels();
console.log('');
console.log(chalk.bold('Available models:'));
for (const m of models) {
const marker = m === currentModel ? chalk.green(' (active)') : '';
console.log(chalk.dim(' ') + chalk.cyan(m) + marker);
}
if (models.length === 0) {
console.log(chalk.dim(' No models configured. Edit ~/.waggle/config.json'));
}
console.log('');
break;
}
case 'clear':
conversationHistory = [];
console.log(chalk.dim('Conversation cleared.'));
break;
case 'identity': {
const identityCtx = orchestrator.getIdentity().toContext();
console.log('');
console.log(renderMarkdown(identityCtx));
console.log('');
break;
}
case 'login': {
try {
const clerkUrl = process.env.CLERK_URL ?? 'https://waggle.clerk.accounts.dev/sign-in';
console.log(chalk.dim('Opening browser for login...'));
const { token, email } = await auth.loginWithBrowser(clerkUrl);
auth.saveToken(token, email);
console.log(chalk.green(`Logged in as ${email}`));
} catch (err) {
console.log(chalk.red(`Login failed: ${(err as Error).message}`));
}
break;
}
case 'logout':
auth.logout();
console.log(chalk.dim('Logged out.'));
break;
case 'whoami': {
const email = auth.getEmail();
console.log('');
console.log(chalk.dim(' User: ') + (email ? chalk.cyan(email) : chalk.dim('not logged in')));
console.log(chalk.dim(' Mode: ') + chalk.cyan(modeResult.type));
console.log(chalk.dim(' Server: ') + chalk.cyan(auth.getServerUrl()));
if (modeResult.warning) console.log(chalk.yellow(' ⚠ ' + modeResult.warning));
console.log('');
break;
}
case 'mode':
console.log(chalk.dim(`Current mode: ${modeResult.type}`));
if (modeResult.warning) console.log(chalk.yellow(modeResult.warning));
break;
case 'cost':
console.log('');
console.log(chalk.dim(costTracker.formatSummary()));
console.log('');
break;
case 'plan': {
const showPlan = planTools.find(t => t.name === 'show_plan');
if (showPlan) {
const output = await showPlan.execute({});
console.log('');
console.log(output);
console.log('');
}
break;
}
case 'git': {
const gitStatus = gitTools.find(t => t.name === 'git_status');
if (gitStatus) {
const output = await gitStatus.execute({});
console.log('');
console.log(output);
console.log('');
}
break;
}
case 'admin': {
const adminClient = new AdminClient();
const parts = cmd.args.split(/\s+/);
const subCmd = parts[0] || '';
const teamSlug = parts[1] || '';
if (!subCmd) {
console.log('');
console.log(chalk.bold('Admin commands:'));
console.log(chalk.dim(' /admin teams — List teams'));
console.log(chalk.dim(' /admin jobs <team-slug> — List agent jobs'));
console.log(chalk.dim(' /admin cron <team-slug> — List cron schedules'));
console.log(chalk.dim(' /admin audit <team-slug> — List audit log'));
console.log(chalk.dim(' /admin stats <team-slug> — Show usage stats'));
console.log('');
console.log(chalk.dim(' Set WAGGLE_API_URL and WAGGLE_TOKEN env vars to connect.'));
console.log('');
break;
}
try {
let result: unknown;
switch (subCmd) {
case 'teams':
result = await adminClient.listTeams();
break;
case 'jobs':
if (!teamSlug) { console.log(chalk.red('Usage: /admin jobs <team-slug>')); break; }
result = await adminClient.listJobs(teamSlug);
break;
case 'cron':
if (!teamSlug) { console.log(chalk.red('Usage: /admin cron <team-slug>')); break; }
result = await adminClient.listCron(teamSlug);
break;
case 'audit':
if (!teamSlug) { console.log(chalk.red('Usage: /admin audit <team-slug>')); break; }
result = await adminClient.listAudit(teamSlug);
break;
case 'stats':
if (!teamSlug) { console.log(chalk.red('Usage: /admin stats <team-slug>')); break; }
result = await adminClient.getStats(teamSlug);
break;
default:
console.log(chalk.red(`Unknown admin command: ${subCmd}`));
console.log(chalk.dim('Type /admin for available commands.'));
}
if (result !== undefined) {
console.log('');
if (Array.isArray(result)) {
console.log(formatTable(result as Record<string, unknown>[]));
} else {
console.log(JSON.stringify(result, null, 2));
}
console.log('');
}
} catch (err) {
console.log(chalk.red(`Admin error: ${(err as Error).message}`));
}
break;
}
default:
console.log(chalk.red(`Unknown command: /${cmd.name}`));
console.log(chalk.dim('Type /help for available commands.'));
}
rl.prompt();
return;
}
// Chat message — send to model via runAgentLoop (LiteLLM)
try {
const tools = [...orchestrator.getTools(), ...systemTools, ...planTools, ...gitTools];
let systemPrompt = '';
// Prepend user's custom system prompt if exists
if (userSystemPrompt) {
systemPrompt += userSystemPrompt + '\n\n';
}
systemPrompt += orchestrator.buildSystemPrompt() + `
# Who You Are
You are Waggle — an AI assistant with persistent memory and web access.
Your key strength: you remember past conversations through your .mind memory system.
# CRITICAL RULES — FOLLOW THESE EXACTLY
## ABSOLUTE RULE: Never guess — USE YOUR TOOLS
- If you don't know a FACT, USE YOUR TOOLS to find out. You have bash, web_search, read_file — use them.
- Never say "I don't know" or "I can't determine" when you have tools that can answer the question.
- Need the date? Run \`date\` via bash. Need current info? Use web_search. Need file contents? Use read_file.
- Be resourceful. Solve problems yourself instead of asking the user or giving up.
- The words "likely", "probably", "I believe", "I think" before a factual claim = YOU ARE GUESSING. Stop. Search instead.
- This is ESPECIALLY important for comparisons. If someone asks "how do you compare to X", you MUST:
1. Use web_search to find X's actual current features
2. Use web_fetch to read their docs/website if needed
3. ONLY THEN state what X can and cannot do, citing what you found
4. If your search didn't find clear info, say "I couldn't verify this" — don't fill the gap with guesses
- NEVER say "X probably can't do Y" or "X likely doesn't have Y". Either you verified it or you don't claim it.
- When corrected: "You're right, my mistake." Move on. No apology paragraphs.
## Be concise — HARD LIMITS
- Simple questions: 2-5 sentences. Complex questions: max 10-12 lines.
- Max 4 bullet points per response. If you need more, you're over-explaining.
- Zero emoji unless the user uses them first.
- Lead with the answer. No preamble like "Great question!" or "That's interesting!"
- Don't list your capabilities. Demonstrate them.
- Don't repeat back what the user said.
## Be sharp, not generic
- Specific > generic. "Use web_search to find Claude Code's changelog" > "I can help with research!"
- Have a clear recommendation when asked. Not "here are options", but "I'd do X because Y".
- When you research something, give the user the INSIGHT, not a reformatted copy of search results.
- If you don't have useful info, say so in one sentence. Don't pad with filler.
# Tools
Workspace (${process.cwd()}): bash, read_file, write_file, edit_file, search_files, search_content
Web: web_search (DuckDuckGo), web_fetch (read any URL)
Memory: search_memory, save_memory, get_identity, get_awareness, query_knowledge, add_task
When asked about current events, products, releases, docs, or anything you're not 100% certain about — web_search FIRST, answer SECOND.`;
// Append loaded skills
if (skills.length > 0) {
systemPrompt += '\n\n# Loaded Skills\n';
for (const skill of skills) {
systemPrompt += `\n## Skill: ${skill.name}\n${skill.content}\n`;
}
}
conversationHistory.push({ role: 'user', content: input });
const result = await runAgentLoop({
litellmUrl,
litellmApiKey,
model: currentModel,
systemPrompt,
tools,
messages: conversationHistory,
stream: true,
hooks: hookRegistry,
onToken: (token: string) => { process.stdout.write(token); },
onToolUse: (name: string, toolInput: Record<string, unknown>) => {
console.log(chalk.dim(` [tool] ${name}`));
workspace.logAudit(sessionId, name, toolInput, '');
},
});
// Track token usage
costTracker.addUsage(currentModel, result.usage.inputTokens, result.usage.outputTokens);
// Log the turn
workspace.logTurn(sessionId, 'user', input);
workspace.logTurn(sessionId, 'agent', result.content, result.toolsUsed);
// Update conversation history
conversationHistory.push({ role: 'assistant', content: result.content });
// Display — streaming already wrote tokens to stdout, just add metadata
console.log('');
console.log('');
console.log(chalk.dim(` [${currentModel} | ${result.usage.inputTokens}${result.usage.outputTokens} tokens]`));
console.log('');
} catch (err) {
const errMsg = (err as Error).message;
if (errMsg.includes('Unknown model')) {
console.log(chalk.red(`Model "${currentModel}" is not configured.`));
console.log(chalk.dim('Run /models to see available models, or edit ~/.waggle/config.json'));
} else if (errMsg.includes('API key') || errMsg.includes('authentication') || errMsg.includes('401')) {
console.log(chalk.red('Authentication failed. Check your API key in ~/.waggle/config.json'));
} else {
console.log(chalk.red(`Error: ${errMsg}`));
}
console.log('');
}
rl.prompt();
});
rl.on('close', () => {
db.close();
process.exit(0);
});
// Graceful shutdown on SIGINT
process.on('SIGINT', () => {
console.log('');
console.log(chalk.dim('Goodbye!'));
db.close();
process.exit(0);
});
}