This commit is contained in:
147
packages/cli/src/auth.ts
Normal file
147
packages/cli/src/auth.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
50
packages/cli/src/commands.ts
Normal file
50
packages/cli/src/commands.ts
Normal 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',
|
||||
};
|
||||
78
packages/cli/src/commands/admin.ts
Normal file
78
packages/cli/src/commands/admin.ts
Normal 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
59
packages/cli/src/index.ts
Normal 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);
|
||||
});
|
||||
56
packages/cli/src/mode-detector.ts
Normal file
56
packages/cli/src/mode-detector.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
82
packages/cli/src/renderer.ts
Normal file
82
packages/cli/src/renderer.ts
Normal 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
496
packages/cli/src/repl.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user