This commit is contained in:
2
packages/cli/bin/waggle.js
Normal file
2
packages/cli/bin/waggle.js
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import '../dist/index.js';
|
||||
63
packages/cli/package.json
Normal file
63
packages/cli/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@waggle/cli",
|
||||
"version": "0.1.0",
|
||||
"description": "Waggle CLI — interactive AI agent with persistent memory",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"waggle": "bin/waggle.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin"
|
||||
],
|
||||
"license": "MIT",
|
||||
"author": "Marko Markovic",
|
||||
"homepage": "https://github.com/marolinik/waggle-os/tree/main/packages/cli#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/marolinik/waggle-os.git",
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/marolinik/waggle-os/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"waggle",
|
||||
"ai",
|
||||
"agent",
|
||||
"cli",
|
||||
"memory",
|
||||
"llm"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node ../../node_modules/vitest/vitest.mjs run --root ../.. --config vitest.config.ts packages/cli/tests/commands.test.ts packages/cli/tests/admin.test.ts packages/cli/tests/renderer.test.ts packages/cli/tests/memory-persistence-hard.test.ts packages/cli/tests/auth.test.ts packages/cli/tests/mode-detector.test.ts packages/cli/tests/comprehensive-e2e.test.ts packages/cli/tests/cli-runtime.test.ts",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@waggle/core": "*",
|
||||
"@waggle/agent": "*",
|
||||
"@waggle/weaver": "*",
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"chalk": "^5.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
1
packages/cli/test-hello.txt
Normal file
1
packages/cli/test-hello.txt
Normal file
@@ -0,0 +1 @@
|
||||
Waggle works
|
||||
75
packages/cli/tests/admin.test.ts
Normal file
75
packages/cli/tests/admin.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AdminClient, formatTable } from '../src/commands/admin.js';
|
||||
|
||||
describe('AdminClient', () => {
|
||||
it('constructs with defaults', () => {
|
||||
const client = new AdminClient();
|
||||
expect(client).toBeInstanceOf(AdminClient);
|
||||
});
|
||||
|
||||
it('constructs with custom base URL and token', () => {
|
||||
const client = new AdminClient('http://localhost:9999', 'my-token');
|
||||
expect(client).toBeInstanceOf(AdminClient);
|
||||
});
|
||||
|
||||
it('has all admin methods', () => {
|
||||
const client = new AdminClient('http://localhost:3100', 'test-token');
|
||||
expect(typeof client.listTeams).toBe('function');
|
||||
expect(typeof client.listJobs).toBe('function');
|
||||
expect(typeof client.listCron).toBe('function');
|
||||
expect(typeof client.listAudit).toBe('function');
|
||||
expect(typeof client.getStats).toBe('function');
|
||||
});
|
||||
|
||||
it('methods return promises', () => {
|
||||
const client = new AdminClient('http://localhost:3100', 'test-token');
|
||||
// These will fail to connect, but they should return promises
|
||||
const p = client.listTeams();
|
||||
expect(p).toBeInstanceOf(Promise);
|
||||
// Suppress unhandled rejection
|
||||
p.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTable', () => {
|
||||
it('returns "(no data)" for empty array', () => {
|
||||
expect(formatTable([])).toBe(' (no data)');
|
||||
});
|
||||
|
||||
it('formats rows into aligned columns', () => {
|
||||
const rows = [
|
||||
{ id: '1', name: 'Alice', role: 'admin' },
|
||||
{ id: '2', name: 'Bob', role: 'member' },
|
||||
];
|
||||
const result = formatTable(rows);
|
||||
expect(result).toContain('id');
|
||||
expect(result).toContain('name');
|
||||
expect(result).toContain('role');
|
||||
expect(result).toContain('Alice');
|
||||
expect(result).toContain('Bob');
|
||||
expect(result).toContain('admin');
|
||||
expect(result).toContain('member');
|
||||
});
|
||||
|
||||
it('respects explicit column selection', () => {
|
||||
const rows = [
|
||||
{ id: '1', name: 'Alice', secret: 'hidden' },
|
||||
];
|
||||
const result = formatTable(rows, ['id', 'name']);
|
||||
expect(result).toContain('id');
|
||||
expect(result).toContain('name');
|
||||
expect(result).not.toContain('secret');
|
||||
expect(result).not.toContain('hidden');
|
||||
});
|
||||
|
||||
it('handles missing values gracefully', () => {
|
||||
const rows = [
|
||||
{ id: '1', name: 'Alice' },
|
||||
{ id: '2' },
|
||||
];
|
||||
const result = formatTable(rows);
|
||||
expect(result).toContain('Alice');
|
||||
// Second row should have empty name
|
||||
expect(result).toContain('2');
|
||||
});
|
||||
});
|
||||
90
packages/cli/tests/auth.test.ts
Normal file
90
packages/cli/tests/auth.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { AuthManager } from '../src/auth.js';
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
describe('AuthManager', () => {
|
||||
let tempDir: string;
|
||||
let auth: AuthManager;
|
||||
|
||||
function setup() {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'waggle-auth-test-'));
|
||||
auth = new AuthManager(tempDir);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when no token stored', () => {
|
||||
setup();
|
||||
expect(auth.getToken()).toBeNull();
|
||||
expect(auth.getEmail()).toBeNull();
|
||||
});
|
||||
|
||||
it('saves and retrieves token', () => {
|
||||
setup();
|
||||
auth.saveToken('test-jwt-token', 'user@example.com');
|
||||
expect(auth.getToken()).toBe('test-jwt-token');
|
||||
expect(auth.getEmail()).toBe('user@example.com');
|
||||
});
|
||||
|
||||
it('clears token on logout', () => {
|
||||
setup();
|
||||
auth.saveToken('test-jwt-token', 'user@example.com');
|
||||
expect(auth.getToken()).toBe('test-jwt-token');
|
||||
auth.logout();
|
||||
expect(auth.getToken()).toBeNull();
|
||||
expect(auth.getEmail()).toBeNull();
|
||||
});
|
||||
|
||||
it('isLoggedIn returns true/false correctly', () => {
|
||||
setup();
|
||||
expect(auth.isLoggedIn()).toBe(false);
|
||||
auth.saveToken('test-jwt-token', 'user@example.com');
|
||||
expect(auth.isLoggedIn()).toBe(true);
|
||||
auth.logout();
|
||||
expect(auth.isLoggedIn()).toBe(false);
|
||||
});
|
||||
|
||||
it('stores token in config.json file', () => {
|
||||
setup();
|
||||
auth.saveToken('file-check-token', 'file@example.com');
|
||||
const configPath = join(tempDir, 'config.json');
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
expect(config.auth).toEqual({
|
||||
token: 'file-check-token',
|
||||
email: 'file@example.com',
|
||||
serverUrl: 'http://localhost:3000',
|
||||
});
|
||||
});
|
||||
|
||||
it('getServerUrl returns default', () => {
|
||||
setup();
|
||||
expect(auth.getServerUrl()).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('preserves existing config keys when saving auth', () => {
|
||||
setup();
|
||||
// Write some pre-existing config
|
||||
const configPath = join(tempDir, 'config.json');
|
||||
try {
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
} catch {
|
||||
// tempDir already exists from setup(); recursive mkdir is idempotent.
|
||||
}
|
||||
writeFileSync(configPath, JSON.stringify({ apiKey: 'sk-existing', model: 'claude' }));
|
||||
|
||||
auth.saveToken('my-token', 'me@test.com');
|
||||
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
expect(config.apiKey).toBe('sk-existing');
|
||||
expect(config.model).toBe('claude');
|
||||
expect(config.auth.token).toBe('my-token');
|
||||
});
|
||||
});
|
||||
580
packages/cli/tests/cli-runtime.test.ts
Normal file
580
packages/cli/tests/cli-runtime.test.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
|
||||
const CLI_DIR = path.join(ROOT, 'packages', 'cli');
|
||||
|
||||
function bin(name: string): string {
|
||||
return process.platform === 'win32' ? `${name}.cmd` : name;
|
||||
}
|
||||
|
||||
function makeHome(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-cli-runtime-'));
|
||||
}
|
||||
|
||||
function run(
|
||||
command: string,
|
||||
args: string[],
|
||||
home: string,
|
||||
): Promise<AsyncRunResult> {
|
||||
return runInCwdAsync(command, args, ROOT, home);
|
||||
}
|
||||
|
||||
function runInCwd(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
home: string,
|
||||
): Promise<AsyncRunResult> {
|
||||
return runInCwdAsync(command, args, cwd, home);
|
||||
}
|
||||
|
||||
function spawnInCwd(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
home: string,
|
||||
extraEnv: NodeJS.ProcessEnv = {},
|
||||
): ChildProcessWithoutNullStreams {
|
||||
return spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
NO_COLOR: '1',
|
||||
...extraEnv,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32' && command.endsWith('.cmd'),
|
||||
});
|
||||
}
|
||||
|
||||
interface AsyncRunResult {
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function runInCwdAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
home: string,
|
||||
): Promise<AsyncRunResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawnInCwd(command, args, cwd, home);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('error', reject);
|
||||
child.on('exit', (status, signal) => resolve({ status, signal, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
interface MockLiteLlmRequest {
|
||||
url: string;
|
||||
authorization: string | undefined;
|
||||
body: {
|
||||
model?: string;
|
||||
messages?: Array<{ role: string; content?: string | null }>;
|
||||
stream?: boolean;
|
||||
stream_options?: { include_usage?: boolean };
|
||||
};
|
||||
}
|
||||
|
||||
interface MockLiteLlm {
|
||||
baseUrl: string;
|
||||
requests: MockLiteLlmRequest[];
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function readJson(req: IncomingMessage): Promise<MockLiteLlmRequest['body']> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
return raw ? JSON.parse(raw) as MockLiteLlmRequest['body'] : {};
|
||||
}
|
||||
|
||||
function writeSse(res: ServerResponse, payload: unknown): void {
|
||||
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
async function startMockLiteLlm(): Promise<MockLiteLlm> {
|
||||
const requests: MockLiteLlmRequest[] = [];
|
||||
const server: Server = createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readJson(req);
|
||||
requests.push({
|
||||
url: req.url,
|
||||
authorization: req.headers.authorization,
|
||||
body,
|
||||
});
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
const id = 'chatcmpl-installed-cli-test';
|
||||
writeSse(res, {
|
||||
id,
|
||||
object: 'chat.completion.chunk',
|
||||
choices: [{ index: 0, delta: { content: 'Mock installed ' }, finish_reason: null }],
|
||||
});
|
||||
writeSse(res, {
|
||||
id,
|
||||
object: 'chat.completion.chunk',
|
||||
choices: [{ index: 0, delta: { content: 'chat response' }, finish_reason: null }],
|
||||
});
|
||||
writeSse(res, {
|
||||
id,
|
||||
object: 'chat.completion.chunk',
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 12, completion_tokens: 5, total_tokens: 17 },
|
||||
});
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end((err as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
requests,
|
||||
close: () => new Promise((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function extractTarball(file: string, cwd: string): Promise<void> {
|
||||
const tar = await import('tar');
|
||||
await tar.x({ file, cwd });
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
predicate: () => boolean | Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => child.once('exit', () => resolve())),
|
||||
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('process did not exit')), timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => child.once('exit', () => resolve())),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
||||
]);
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
const CLI_PACKAGE_CLOSURE = [
|
||||
'@waggle/shared',
|
||||
'@waggle/hive-mind-core',
|
||||
'@waggle/core',
|
||||
'@waggle/marketplace',
|
||||
'@waggle/agent',
|
||||
'@waggle/weaver',
|
||||
'@waggle/cli',
|
||||
] as const;
|
||||
|
||||
describe('@waggle/cli runtime UX', () => {
|
||||
it('runs built help without loading the REPL dependency graph', async () => {
|
||||
const home = makeHome();
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle/cli'], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const result = await run(process.execPath, [path.join(CLI_DIR, 'dist', 'index.js'), '--help'], home);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Waggle CLI');
|
||||
expect(result.stdout).toContain('Usage:');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(fs.existsSync(path.join(home, '.waggle'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('packs a tarball with a runnable bin help command', async () => {
|
||||
const home = makeHome();
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle/cli'], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', '@waggle/cli', '--pack-destination', home, '--json'],
|
||||
home,
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const extractDir = path.join(home, 'packed');
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
await extractTarball(path.join(home, packResult.filename), extractDir);
|
||||
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(extractDir, 'package', 'package.json'), 'utf8'),
|
||||
);
|
||||
const result = await run(
|
||||
process.execPath,
|
||||
[path.join(extractDir, 'package', 'bin', 'waggle.js'), '--help'],
|
||||
home,
|
||||
);
|
||||
|
||||
expect(pkg.bin.waggle).toBe('bin/waggle.js');
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Waggle CLI');
|
||||
expect(result.stdout).toContain('Usage:');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(fs.existsSync(path.join(home, '.waggle'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('installs the local package closure and runs npx help', async () => {
|
||||
const home = makeHome();
|
||||
try {
|
||||
const packsDir = path.join(home, 'packs');
|
||||
const projectDir = path.join(home, 'project');
|
||||
fs.mkdirSync(packsDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const dependencies: Record<string, string> = {};
|
||||
for (const workspace of CLI_PACKAGE_CLOSURE) {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', workspace], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', workspace, '--pack-destination', packsDir, '--json'],
|
||||
home,
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const tarball = path.join(packsDir, packResult.filename).replace(/\\/g, '/');
|
||||
dependencies[workspace] = `file:${tarball}`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ private: true, type: 'module', dependencies }, null, 2),
|
||||
);
|
||||
|
||||
const install = await runInCwd(
|
||||
bin('npm'),
|
||||
['install', '--no-audit', '--no-fund', '--ignore-scripts', '--prefer-offline'],
|
||||
projectDir,
|
||||
home,
|
||||
);
|
||||
expect(install.status).toBe(0);
|
||||
|
||||
const result = await runInCwd(bin('npx'), ['waggle', '--help'], projectDir, home);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Waggle CLI');
|
||||
expect(result.stdout).toContain('Usage:');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(fs.existsSync(path.join(home, '.waggle'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it('installs the local package closure and starts the local REPL', async () => {
|
||||
const home = makeHome();
|
||||
let child: ChildProcessWithoutNullStreams | undefined;
|
||||
try {
|
||||
const packsDir = path.join(home, 'packs');
|
||||
const projectDir = path.join(home, 'project');
|
||||
fs.mkdirSync(packsDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const dependencies: Record<string, string> = {};
|
||||
for (const workspace of CLI_PACKAGE_CLOSURE) {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', workspace], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', workspace, '--pack-destination', packsDir, '--json'],
|
||||
home,
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const tarball = path.join(packsDir, packResult.filename).replace(/\\/g, '/');
|
||||
dependencies[workspace] = `file:${tarball}`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ private: true, type: 'module', dependencies }, null, 2),
|
||||
);
|
||||
|
||||
const install = await runInCwdAsync(
|
||||
bin('npm'),
|
||||
// The REPL opens MindDB on startup, so this install must allow better-sqlite3's
|
||||
// native binding lifecycle rather than using the help-only --ignore-scripts path.
|
||||
['install', '--no-audit', '--no-fund', '--prefer-offline'],
|
||||
projectDir,
|
||||
home,
|
||||
);
|
||||
if (install.status !== 0) {
|
||||
throw new Error(
|
||||
[
|
||||
'Installed waggle REPL dependency install failed.',
|
||||
`status=${install.status ?? 'null'} signal=${install.signal ?? 'none'}`,
|
||||
`stdout:\n${install.stdout}`,
|
||||
`stderr:\n${install.stderr}`,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child = spawnInCwd(bin('npx'), ['waggle', '--local'], projectDir, home);
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
|
||||
try {
|
||||
await waitFor(() => stdout.includes('Type /help for commands') && stdout.includes('you >'), 30_000);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
[
|
||||
'Installed waggle REPL did not reach the prompt.',
|
||||
`exitCode=${child.exitCode ?? 'running'} signalCode=${child.signalCode ?? 'none'}`,
|
||||
`stdout:\n${stdout}`,
|
||||
`stderr:\n${stderr}`,
|
||||
].join('\n'),
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
const writeCommand = async (command: string, expected: string | string[]) => {
|
||||
child!.stdin.write(`${command}\n`);
|
||||
const expectedText = Array.isArray(expected) ? expected : [expected];
|
||||
await waitFor(() => expectedText.every((text) => stdout.includes(text)), 10_000);
|
||||
};
|
||||
|
||||
await writeCommand('/help', ['Available commands:', '/models', '/whoami']);
|
||||
await writeCommand('/mode', 'Current mode: local');
|
||||
await writeCommand('/whoami', ['User:', 'not logged in', 'Server:']);
|
||||
await writeCommand('/models', ['Available models:', 'No models configured']);
|
||||
await writeCommand('/cost', ['Tokens: 0 in / 0 out', 'Est. cost: $0.0000']);
|
||||
await writeCommand('/clear', 'Conversation cleared.');
|
||||
|
||||
child.stdin.write('/exit\n');
|
||||
await waitForExit(child, 10_000);
|
||||
|
||||
expect(child.exitCode).toBe(0);
|
||||
expect(stdout).toContain('Waggle');
|
||||
expect(stdout).toContain('Mode:');
|
||||
expect(stdout).toContain('local');
|
||||
expect(stdout).toContain('Available commands:');
|
||||
expect(stdout).toContain('Current mode: local');
|
||||
expect(stdout).toContain('No models configured');
|
||||
expect(stdout).toContain('Conversation cleared.');
|
||||
expect(stdout).toContain('Goodbye!');
|
||||
expect(stderr).not.toContain('Fatal error');
|
||||
expect(fs.existsSync(path.join(home, '.waggle', 'default.mind'))).toBe(true);
|
||||
} finally {
|
||||
if (child) await stopProcess(child);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
it('installs the local package closure and completes a streamed chat turn', async () => {
|
||||
const home = makeHome();
|
||||
let child: ChildProcessWithoutNullStreams | undefined;
|
||||
let mockLiteLlm: MockLiteLlm | undefined;
|
||||
try {
|
||||
const packsDir = path.join(home, 'packs');
|
||||
const projectDir = path.join(home, 'project');
|
||||
fs.mkdirSync(packsDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const dependencies: Record<string, string> = {};
|
||||
for (const workspace of CLI_PACKAGE_CLOSURE) {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', workspace], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', workspace, '--pack-destination', packsDir, '--json'],
|
||||
home,
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const tarball = path.join(packsDir, packResult.filename).replace(/\\/g, '/');
|
||||
dependencies[workspace] = `file:${tarball}`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ private: true, type: 'module', dependencies }, null, 2),
|
||||
);
|
||||
|
||||
const install = await runInCwdAsync(
|
||||
bin('npm'),
|
||||
['install', '--no-audit', '--no-fund', '--prefer-offline'],
|
||||
projectDir,
|
||||
home,
|
||||
);
|
||||
if (install.status !== 0) {
|
||||
throw new Error(
|
||||
[
|
||||
'Installed waggle chat dependency install failed.',
|
||||
`status=${install.status ?? 'null'} signal=${install.signal ?? 'none'}`,
|
||||
`stdout:\n${install.stdout}`,
|
||||
`stderr:\n${install.stderr}`,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
mockLiteLlm = await startMockLiteLlm();
|
||||
const waggleHome = path.join(home, '.waggle');
|
||||
const workspaceConfigDir = path.join(projectDir, '.waggle');
|
||||
fs.mkdirSync(waggleHome, { recursive: true });
|
||||
fs.mkdirSync(workspaceConfigDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(waggleHome, 'config.json'),
|
||||
JSON.stringify({
|
||||
defaultModel: 'mock-model',
|
||||
providers: {
|
||||
litellm: {
|
||||
apiKey: 'sk-test',
|
||||
models: ['mock-model'],
|
||||
},
|
||||
},
|
||||
}, null, 2),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(workspaceConfigDir, 'workspace.json'),
|
||||
JSON.stringify({
|
||||
model: 'mock-model',
|
||||
litellmUrl: mockLiteLlm.baseUrl,
|
||||
}, null, 2),
|
||||
);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child = spawnInCwd(
|
||||
bin('npx'),
|
||||
['waggle', '--local'],
|
||||
projectDir,
|
||||
home,
|
||||
{
|
||||
LITELLM_API_KEY: 'sk-test',
|
||||
WAGGLE_LLM_TIMEOUT_MS: '30000',
|
||||
},
|
||||
);
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
|
||||
try {
|
||||
await waitFor(() => stdout.includes('Type /help for commands') && stdout.includes('you >'), 30_000);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
[
|
||||
'Installed waggle REPL did not reach the prompt before chat.',
|
||||
`exitCode=${child.exitCode ?? 'running'} signalCode=${child.signalCode ?? 'none'}`,
|
||||
`stdout:\n${stdout}`,
|
||||
`stderr:\n${stderr}`,
|
||||
].join('\n'),
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
child.stdin.write('Say hello from the installed CLI test.\n');
|
||||
await waitFor(
|
||||
() => stdout.includes('Mock installed chat response') && stdout.includes('[mock-model |'),
|
||||
30_000,
|
||||
);
|
||||
|
||||
child.stdin.write('/exit\n');
|
||||
await waitForExit(child, 10_000);
|
||||
|
||||
const chatRequest = mockLiteLlm.requests.find((request) => request.url === '/v1/chat/completions');
|
||||
expect(chatRequest).toBeDefined();
|
||||
expect(chatRequest?.authorization).toBe('Bearer sk-test');
|
||||
expect(chatRequest?.body.model).toBe('mock-model');
|
||||
expect(chatRequest?.body.stream).toBe(true);
|
||||
expect(chatRequest?.body.stream_options).toEqual({ include_usage: true });
|
||||
expect(JSON.stringify(chatRequest?.body.messages)).toContain('Say hello from the installed CLI test.');
|
||||
expect(child.exitCode).toBe(0);
|
||||
expect(stdout).toContain('Model:');
|
||||
expect(stdout).toContain('mock-model');
|
||||
expect(stdout).toContain('Mock installed chat response');
|
||||
expect(stdout).toContain('12');
|
||||
expect(stdout).toContain('5');
|
||||
expect(stdout).toContain('Goodbye!');
|
||||
expect(stderr).not.toContain('Fatal error');
|
||||
} finally {
|
||||
if (child) await stopProcess(child);
|
||||
if (mockLiteLlm) await mockLiteLlm.close();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 240_000);
|
||||
});
|
||||
36
packages/cli/tests/commands.test.ts
Normal file
36
packages/cli/tests/commands.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCommand } from '../src/commands.js';
|
||||
|
||||
describe('parseCommand', () => {
|
||||
it('parses /model command with args', () => {
|
||||
const result = parseCommand('/model gpt-4o');
|
||||
expect(result).toEqual({ name: 'model', args: 'gpt-4o' });
|
||||
});
|
||||
|
||||
it('parses /exit (no args)', () => {
|
||||
const result = parseCommand('/exit');
|
||||
expect(result).toEqual({ name: 'exit', args: '' });
|
||||
});
|
||||
|
||||
it('parses /help', () => {
|
||||
const result = parseCommand('/help');
|
||||
expect(result).toEqual({ name: 'help', args: '' });
|
||||
});
|
||||
|
||||
it('returns null for regular messages', () => {
|
||||
expect(parseCommand('hello world')).toBeNull();
|
||||
expect(parseCommand('what is the weather?')).toBeNull();
|
||||
expect(parseCommand('')).toBeNull();
|
||||
expect(parseCommand(' some text ')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses /clear', () => {
|
||||
const result = parseCommand('/clear');
|
||||
expect(result).toEqual({ name: 'clear', args: '' });
|
||||
});
|
||||
|
||||
it('parses /identity', () => {
|
||||
const result = parseCommand('/identity');
|
||||
expect(result).toEqual({ name: 'identity', args: '' });
|
||||
});
|
||||
});
|
||||
373
packages/cli/tests/comprehensive-e2e.test.ts
Normal file
373
packages/cli/tests/comprehensive-e2e.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Comprehensive E2E test — exercises the same code paths as the CLI REPL
|
||||
* but programmatically (no LiteLLM/API needed).
|
||||
*
|
||||
* Tests 14 scenarios covering identity, awareness, memory persistence,
|
||||
* knowledge graph, cross-session recall, tool execution, system prompt,
|
||||
* hooks, permissions, and more.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { MindDB } from '@waggle/core';
|
||||
import {
|
||||
Orchestrator,
|
||||
createSystemTools,
|
||||
createPlanTools,
|
||||
createGitTools,
|
||||
HookRegistry,
|
||||
PermissionManager,
|
||||
filterToolsForContext,
|
||||
needsConfirmation,
|
||||
} from '@waggle/agent';
|
||||
import { MockEmbedder } from '../../hive-mind-core/tests/mind/helpers/mock-embedder.js';
|
||||
|
||||
// Helper: create a file-backed .mind DB in temp dir
|
||||
function createTmpMind(): { path: string; db: MindDB } {
|
||||
const p = path.join(os.tmpdir(), `waggle-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}.mind`);
|
||||
return { path: p, db: new MindDB(p) };
|
||||
}
|
||||
|
||||
function cleanup(filePath: string) {
|
||||
for (const f of [filePath, filePath + '-wal', filePath + '-shm']) {
|
||||
if (fs.existsSync(f)) fs.unlinkSync(f);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Comprehensive CLI E2E Test', () => {
|
||||
let mindPath: string;
|
||||
let db: MindDB;
|
||||
let orchestrator: Orchestrator;
|
||||
const embedder = new MockEmbedder();
|
||||
|
||||
beforeEach(() => {
|
||||
const tmp = createTmpMind();
|
||||
mindPath = tmp.path;
|
||||
db = tmp.db;
|
||||
orchestrator = new Orchestrator({ db, embedder });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
cleanup(mindPath);
|
||||
});
|
||||
|
||||
// ─── Test 1: Identity CRUD ───
|
||||
it('T1: Create and retrieve agent identity', async () => {
|
||||
orchestrator.getIdentity().create({
|
||||
name: 'Waggle',
|
||||
role: 'Personal AI Assistant',
|
||||
department: 'Engineering',
|
||||
personality: 'Helpful and precise',
|
||||
capabilities: 'Memory, search, knowledge graph',
|
||||
system_prompt: 'You are Waggle.',
|
||||
});
|
||||
|
||||
const result = await orchestrator.executeTool('get_identity', {});
|
||||
expect(result).toContain('Waggle');
|
||||
expect(result).toContain('Personal AI Assistant');
|
||||
expect(result).toContain('Engineering');
|
||||
});
|
||||
|
||||
// ─── Test 2: Awareness Layer ───
|
||||
it('T2: Add tasks and flags to awareness', async () => {
|
||||
await orchestrator.executeTool('add_task', { content: 'Review PR #42', priority: 9 });
|
||||
await orchestrator.executeTool('add_task', { content: 'Deploy staging', priority: 5 });
|
||||
orchestrator.getAwareness().add('flag', 'User prefers dark mode', 10);
|
||||
|
||||
const result = await orchestrator.executeTool('get_awareness', {});
|
||||
expect(result).toContain('Review PR #42');
|
||||
expect(result).toContain('Deploy staging');
|
||||
expect(result).toContain('dark mode');
|
||||
});
|
||||
|
||||
// ─── Test 3: Save and Search Memory (within session) ───
|
||||
it('T3: Save memory and search it back', async () => {
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: 'The quarterly report deadline is March 15th',
|
||||
importance: 'important',
|
||||
});
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: 'Project Alpha uses React and TypeScript',
|
||||
importance: 'normal',
|
||||
});
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: 'Team standup is at 9:30 AM every day',
|
||||
importance: 'normal',
|
||||
});
|
||||
|
||||
// Search should find the memory
|
||||
const result = await orchestrator.executeTool('search_memory', { query: 'quarterly report' });
|
||||
expect(result).toContain('quarterly report');
|
||||
expect(result).toContain('March 15th');
|
||||
});
|
||||
|
||||
// ─── Test 4: Knowledge Graph ───
|
||||
it('T4: Create entities and relations, query them', async () => {
|
||||
const kg = orchestrator.getKnowledge();
|
||||
const alice = kg.createEntity('person', 'Alice', { role: 'Tech Lead' });
|
||||
const project = kg.createEntity('project', 'Phoenix', { status: 'active' });
|
||||
const react = kg.createEntity('technology', 'React', { version: '18' });
|
||||
|
||||
kg.createRelation(alice.id, project.id, 'leads', 0.95);
|
||||
kg.createRelation(project.id, react.id, 'uses', 0.9);
|
||||
|
||||
const result = await orchestrator.executeTool('query_knowledge', { query: 'Alice' });
|
||||
expect(result).toContain('Alice');
|
||||
expect(result).toContain('leads');
|
||||
expect(result).toContain('Phoenix');
|
||||
});
|
||||
|
||||
// ─── Test 5: Cross-Session Memory Persistence ───
|
||||
it('T5: Memories persist across sessions (file-backed .mind)', async () => {
|
||||
// Session 1: save memories
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: 'My name is Marko and I work on the Waggle project',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: 'The API key for production is stored in 1Password',
|
||||
importance: 'important',
|
||||
});
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: 'Python is used for data processing scripts',
|
||||
importance: 'normal',
|
||||
});
|
||||
|
||||
// Close DB (simulates CLI exit)
|
||||
db.close();
|
||||
|
||||
// Session 2: reopen same .mind file
|
||||
const db2 = new MindDB(mindPath);
|
||||
const orchestrator2 = new Orchestrator({ db: db2, embedder });
|
||||
|
||||
// Search for memories from previous session
|
||||
const result1 = await orchestrator2.executeTool('search_memory', { query: 'Marko Waggle' });
|
||||
expect(result1).toContain('Marko');
|
||||
expect(result1).toContain('Waggle');
|
||||
|
||||
const result2 = await orchestrator2.executeTool('search_memory', { query: 'API key production' });
|
||||
expect(result2).toContain('1Password');
|
||||
|
||||
const result3 = await orchestrator2.executeTool('search_memory', { query: 'Python data' });
|
||||
expect(result3).toContain('Python');
|
||||
|
||||
db2.close();
|
||||
|
||||
// Reassign so afterEach cleanup works
|
||||
db = new MindDB(mindPath);
|
||||
orchestrator = new Orchestrator({ db, embedder });
|
||||
});
|
||||
|
||||
// ─── Test 6: System Prompt Builder ───
|
||||
it('T6: System prompt includes identity, awareness, and tools', () => {
|
||||
orchestrator.getIdentity().create({
|
||||
name: 'TestBot',
|
||||
role: 'Tester',
|
||||
department: '',
|
||||
personality: 'Thorough',
|
||||
capabilities: 'Testing',
|
||||
system_prompt: 'You run tests.',
|
||||
});
|
||||
orchestrator.getAwareness().add('task', 'Run integration tests', 10);
|
||||
|
||||
const prompt = orchestrator.buildSystemPrompt();
|
||||
expect(prompt).toContain('TestBot');
|
||||
expect(prompt).toContain('Run integration tests');
|
||||
// System prompt includes self-awareness block with tool summary
|
||||
expect(prompt).toContain('# Self-Awareness');
|
||||
expect(prompt).toContain('tools available');
|
||||
});
|
||||
|
||||
// ─── Test 7: Tool Definitions ───
|
||||
it('T7: All required tools are defined with correct shape', () => {
|
||||
const tools = orchestrator.getTools();
|
||||
const required = ['get_identity', 'get_awareness', 'search_memory', 'save_memory', 'query_knowledge', 'add_task', 'correct_knowledge'];
|
||||
|
||||
for (const name of required) {
|
||||
const tool = tools.find(t => t.name === name);
|
||||
expect(tool, `Tool ${name} should exist`).toBeDefined();
|
||||
expect(tool!.description).toBeTruthy();
|
||||
expect(typeof tool!.execute).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 8: System Tools ───
|
||||
it('T8: System tools (bash, read_file, etc.) are created', () => {
|
||||
const systemTools = createSystemTools(process.cwd());
|
||||
const names = systemTools.map(t => t.name);
|
||||
|
||||
expect(names).toContain('bash');
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('write_file');
|
||||
expect(names).toContain('edit_file');
|
||||
expect(names).toContain('search_files');
|
||||
expect(names).toContain('search_content');
|
||||
});
|
||||
|
||||
// ─── Test 9: Plan Tools ───
|
||||
it('T9: Plan tools are created', () => {
|
||||
const planTools = createPlanTools(process.cwd());
|
||||
const names = planTools.map(t => t.name);
|
||||
|
||||
expect(names).toContain('create_plan');
|
||||
expect(names).toContain('add_plan_step');
|
||||
expect(names).toContain('show_plan');
|
||||
});
|
||||
|
||||
// ─── Test 10: Git Tools ───
|
||||
it('T10: Git tools are created', () => {
|
||||
const gitTools = createGitTools(process.cwd());
|
||||
const names = gitTools.map(t => t.name);
|
||||
|
||||
expect(names).toContain('git_status');
|
||||
expect(names).toContain('git_diff');
|
||||
expect(names).toContain('git_log');
|
||||
expect(names).toContain('git_commit');
|
||||
});
|
||||
|
||||
// ─── Test 11: Hook Registry ───
|
||||
it('T11: Hook registry fires pre/post hooks', async () => {
|
||||
const hooks = new HookRegistry();
|
||||
const events: string[] = [];
|
||||
|
||||
hooks.on('pre:tool', async (ctx) => { events.push(`pre:${ctx.toolName}`); });
|
||||
hooks.on('post:tool', async (ctx) => { events.push(`post:${ctx.toolName}`); });
|
||||
|
||||
await hooks.fire('pre:tool', { toolName: 'bash', args: {} });
|
||||
await hooks.fire('post:tool', { toolName: 'bash', args: {}, result: 'ok' });
|
||||
|
||||
expect(events).toEqual(['pre:bash', 'post:bash']);
|
||||
});
|
||||
|
||||
// ─── Test 12: Permission Manager ───
|
||||
it('T12: Permission manager filters tools', () => {
|
||||
const perms = new PermissionManager({
|
||||
blacklist: ['bash', 'write_file'],
|
||||
});
|
||||
|
||||
expect(perms.isAllowed('bash')).toBe(false);
|
||||
expect(perms.isAllowed('write_file')).toBe(false);
|
||||
expect(perms.isAllowed('read_file')).toBe(true);
|
||||
expect(perms.isAllowed('search_memory')).toBe(true);
|
||||
|
||||
// Sandbox mode only allows readonly tools
|
||||
const sandbox = PermissionManager.sandbox();
|
||||
expect(sandbox.isAllowed('bash')).toBe(false);
|
||||
expect(sandbox.isAllowed('write_file')).toBe(false);
|
||||
expect(sandbox.isAllowed('read_file')).toBe(true);
|
||||
expect(sandbox.isAllowed('search_memory')).toBe(true);
|
||||
});
|
||||
|
||||
// ─── Test 13: Confirmation Gate ───
|
||||
it('T13: Confirmation gate identifies sensitive tools', () => {
|
||||
// Non-bash tools
|
||||
expect(needsConfirmation('write_file')).toBe(true);
|
||||
expect(needsConfirmation('edit_file')).toBe(true);
|
||||
expect(needsConfirmation('git_commit')).toBe(true);
|
||||
expect(needsConfirmation('read_file')).toBe(false);
|
||||
expect(needsConfirmation('search_memory')).toBe(false);
|
||||
|
||||
// Bash without args = unknown command = confirm
|
||||
expect(needsConfirmation('bash')).toBe(true);
|
||||
|
||||
// Safe bash commands (read-only)
|
||||
expect(needsConfirmation('bash', { command: 'date' })).toBe(false);
|
||||
expect(needsConfirmation('bash', { command: 'ls -la' })).toBe(false);
|
||||
expect(needsConfirmation('bash', { command: 'git status' })).toBe(false);
|
||||
expect(needsConfirmation('bash', { command: 'git log --oneline' })).toBe(false);
|
||||
expect(needsConfirmation('bash', { command: 'whoami' })).toBe(false);
|
||||
|
||||
// Destructive bash commands
|
||||
expect(needsConfirmation('bash', { command: 'rm -rf /tmp/foo' })).toBe(true);
|
||||
expect(needsConfirmation('bash', { command: 'git push origin main' })).toBe(true);
|
||||
expect(needsConfirmation('bash', { command: 'sudo apt install foo' })).toBe(true);
|
||||
});
|
||||
|
||||
// ─── Test 14: Memory Stats ───
|
||||
it('T14: Memory stats reflect actual data', async () => {
|
||||
// Start with empty
|
||||
let stats = orchestrator.getMemoryStats();
|
||||
expect(stats.frameCount).toBe(0);
|
||||
expect(stats.sessionCount).toBe(0);
|
||||
expect(stats.entityCount).toBe(0);
|
||||
|
||||
// Save some memories
|
||||
await orchestrator.executeTool('save_memory', { content: 'Memory one' });
|
||||
await orchestrator.executeTool('save_memory', { content: 'Memory two' });
|
||||
orchestrator.getKnowledge().createEntity('test', 'Entity1', {});
|
||||
|
||||
stats = orchestrator.getMemoryStats();
|
||||
expect(stats.frameCount).toBeGreaterThanOrEqual(2);
|
||||
expect(stats.sessionCount).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.entityCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// ─── Test 15: Empty state handling ───
|
||||
it('T15: Graceful handling of empty state', async () => {
|
||||
const id = await orchestrator.executeTool('get_identity', {});
|
||||
expect(id).toContain('No identity configured');
|
||||
|
||||
const aw = await orchestrator.executeTool('get_awareness', {});
|
||||
expect(aw).toContain('No active awareness items');
|
||||
|
||||
const search = await orchestrator.executeTool('search_memory', { query: 'anything' });
|
||||
expect(search).toContain('No relevant memories');
|
||||
|
||||
const kg = await orchestrator.executeTool('query_knowledge', { query: 'nobody' });
|
||||
expect(kg).toContain('No entities found');
|
||||
});
|
||||
|
||||
// ─── Test 16: Unknown tool throws ───
|
||||
it('T16: Unknown tool name throws error', async () => {
|
||||
await expect(orchestrator.executeTool('nonexistent_tool', {})).rejects.toThrow('Unknown tool');
|
||||
});
|
||||
|
||||
// ─── Test 17: Heavy memory load (50 per session, rate-limited by W2.10) ───
|
||||
it('T17: memories up to session rate limit are all searchable', async () => {
|
||||
// W2.10: save_memory is rate-limited to 50 per session to prevent flooding.
|
||||
// Save 60 — first 50 succeed, remaining are rate-limited.
|
||||
const RATE_LIMIT = 50;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: `Observation ${i}: topic-${i % 10} with detail about area-${i % 5}`,
|
||||
importance: i % 20 === 0 ? 'important' : 'normal',
|
||||
});
|
||||
}
|
||||
|
||||
// Search by topic (hyphens in queries should work after FTS5 sanitization)
|
||||
const result = await orchestrator.executeTool('search_memory', { query: 'topic-7' });
|
||||
expect(result).toContain('topic-7');
|
||||
|
||||
// Search by area
|
||||
const result2 = await orchestrator.executeTool('search_memory', { query: 'area-3' });
|
||||
expect(result2).toContain('area-3');
|
||||
|
||||
// Stats should show exactly the rate limit count (50 saved, rest blocked)
|
||||
const stats = orchestrator.getMemoryStats();
|
||||
expect(stats.frameCount).toBeGreaterThanOrEqual(RATE_LIMIT);
|
||||
});
|
||||
|
||||
// ─── Test 18: Cross-session knowledge graph persistence ───
|
||||
it('T18: Knowledge graph persists across sessions', async () => {
|
||||
const kg = orchestrator.getKnowledge();
|
||||
const alice = kg.createEntity('person', 'Alice', { role: 'Engineer' });
|
||||
const bob = kg.createEntity('person', 'Bob', { role: 'Designer' });
|
||||
kg.createRelation(alice.id, bob.id, 'collaborates_with', 0.85);
|
||||
|
||||
// Close and reopen
|
||||
db.close();
|
||||
const db2 = new MindDB(mindPath);
|
||||
const orchestrator2 = new Orchestrator({ db: db2, embedder });
|
||||
|
||||
const result = await orchestrator2.executeTool('query_knowledge', { query: 'Alice' });
|
||||
expect(result).toContain('Alice');
|
||||
expect(result).toContain('collaborates_with');
|
||||
expect(result).toContain('Bob');
|
||||
|
||||
db2.close();
|
||||
db = new MindDB(mindPath);
|
||||
orchestrator = new Orchestrator({ db, embedder });
|
||||
});
|
||||
});
|
||||
283
packages/cli/tests/memory-persistence-hard.test.ts
Normal file
283
packages/cli/tests/memory-persistence-hard.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* HARD memory persistence test.
|
||||
*
|
||||
* Simulates 3 real work sessions across a multi-day project,
|
||||
* then verifies a cold-start "session 4" can recall everything
|
||||
* that matters — not trivia, but the kind of context that makes
|
||||
* an assistant feel like it was there the whole time.
|
||||
*
|
||||
* This is the crown jewel test: .mind file = portable brain.
|
||||
*/
|
||||
import { describe, it, expect, afterAll } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { MindDB } from '@waggle/core';
|
||||
import { Orchestrator } from '@waggle/agent';
|
||||
import { MockEmbedder } from '../../hive-mind-core/tests/mind/helpers/mock-embedder.js';
|
||||
|
||||
const MIND_PATH = path.join(os.tmpdir(), `waggle-hard-memory-${Date.now()}.mind`);
|
||||
let lastDb: MindDB | null = null;
|
||||
|
||||
function cleanup() {
|
||||
lastDb?.close();
|
||||
lastDb = null;
|
||||
for (const f of [MIND_PATH, MIND_PATH + '-wal', MIND_PATH + '-shm']) {
|
||||
try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(cleanup);
|
||||
|
||||
describe('Hard Memory Persistence — Real Work Simulation', () => {
|
||||
const embedder = new MockEmbedder();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SESSION 1: Monday morning — project kickoff
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
it('Session 1: Project kickoff — identity, decisions, architecture', async () => {
|
||||
const db = new MindDB(MIND_PATH);
|
||||
const orch = new Orchestrator({ db, embedder });
|
||||
|
||||
// Set up identity
|
||||
orch.getIdentity().create({
|
||||
name: 'Waggle',
|
||||
role: 'Senior Engineering Assistant',
|
||||
department: 'Platform Team',
|
||||
personality: 'Thorough, opinionated, remembers everything',
|
||||
capabilities: 'Code review, architecture, memory, search, knowledge graph',
|
||||
system_prompt: 'You are Waggle, a senior engineering assistant for Marko.',
|
||||
});
|
||||
|
||||
// User context
|
||||
orch.getAwareness().add('flag', 'User is Marko Markovic, prefers direct communication', 10);
|
||||
orch.getAwareness().add('flag', 'Project: Rewrite payment service from Python to Go', 10);
|
||||
orch.getAwareness().add('task', 'Design new payment service architecture', 9);
|
||||
orch.getAwareness().add('pending', 'Waiting for Stripe API credentials from DevOps (asked Alice)', 7);
|
||||
|
||||
// Memories from the kickoff meeting
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Architecture decision: Payment service will use Go with chi router, PostgreSQL, and connect to Stripe via their Go SDK. Rejected gRPC in favor of REST for simplicity. Team voted 4-1.',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'The current Python payment service handles 3 endpoints: POST /payments/charge, POST /payments/refund, GET /payments/:id. All must be preserved in the rewrite. The charge endpoint also calls an internal fraud-check service at http://fraud.internal:8080/check.',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Alice (DevOps lead) said Stripe credentials will be in Vault at secret/data/stripe/production. She needs 2 business days. ETA: Wednesday.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Marko raised concern about the fraud-check service being a single point of failure. Decision: implement circuit breaker with 5-second timeout, fallback to allowing the charge (business decision: false negatives are worse than false positives for fraud).',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Database schema for payments table: id (uuid), amount_cents (bigint), currency (varchar(3)), stripe_charge_id (text), status (enum: pending/completed/failed/refunded), customer_id (uuid FK), created_at, updated_at. Using bigint for amount to avoid floating point issues.',
|
||||
importance: 'important',
|
||||
});
|
||||
|
||||
// Knowledge graph — people and their roles
|
||||
const kg = orch.getKnowledge();
|
||||
const marko = kg.createEntity('person', 'Marko Markovic', { role: 'Tech Lead', preference: 'direct communication' });
|
||||
const alice = kg.createEntity('person', 'Alice Chen', { role: 'DevOps Lead', team: 'Infrastructure' });
|
||||
const bob = kg.createEntity('person', 'Bob Kumar', { role: 'Backend Engineer', expertise: 'Go' });
|
||||
const paymentSvc = kg.createEntity('service', 'payment-service', { language: 'Go', status: 'in-development', repo: 'github.com/acme/payment-service-go' });
|
||||
const fraudSvc = kg.createEntity('service', 'fraud-check-service', { url: 'http://fraud.internal:8080', owner: 'Risk Team' });
|
||||
const stripe = kg.createEntity('integration', 'Stripe', { sdk: 'stripe-go', env: 'production' });
|
||||
|
||||
kg.createRelation(marko.id, paymentSvc.id, 'leads', 0.95);
|
||||
kg.createRelation(alice.id, paymentSvc.id, 'provides_infra', 0.9);
|
||||
kg.createRelation(bob.id, paymentSvc.id, 'implements', 0.9);
|
||||
kg.createRelation(paymentSvc.id, fraudSvc.id, 'depends_on', 1.0);
|
||||
kg.createRelation(paymentSvc.id, stripe.id, 'integrates', 1.0);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SESSION 2: Tuesday — deep implementation work
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
it('Session 2: Implementation day — code decisions, bugs found, PR reviews', async () => {
|
||||
const db = new MindDB(MIND_PATH);
|
||||
const orch = new Orchestrator({ db, embedder });
|
||||
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Started implementing the charge endpoint. Using chi router with middleware chain: logging → auth → rate-limit → handler. Bob suggested using errgroup for parallel Stripe + fraud-check calls — good idea, adopted it.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Found a bug in the old Python service: refund endpoint doesn\'t check if payment is already refunded, allowing double refunds. Filed as JIRA PAY-142. Must fix in Go rewrite.',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'PR #87 review: Bob\'s implementation of the charge handler looks good but has a subtle race condition — if Stripe returns success but DB write fails, the charge is orphaned. Need to implement idempotency key pattern. Left detailed comment on the PR.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Decided on error handling strategy: all errors return standard JSON { "error": { "code": "...", "message": "...", "request_id": "..." } }. HTTP status codes: 400 for validation, 402 for Stripe declined, 409 for duplicate/already-refunded, 500 for internal, 503 for circuit breaker open.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Performance target from Marko: p99 latency under 200ms for charge endpoint (current Python service is 450ms). Go rewrite should easily beat this. Will add Prometheus metrics from day 1.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Integration test strategy: use Stripe test mode with test API keys (not production). Alice confirmed test keys are already in Vault at secret/data/stripe/test. Docker compose setup with Postgres + test Stripe env.',
|
||||
importance: 'normal',
|
||||
});
|
||||
|
||||
// Update knowledge graph
|
||||
const kg = orch.getKnowledge();
|
||||
kg.createEntity('bug', 'PAY-142: Double refund vulnerability', { severity: 'high', status: 'open', found_in: 'Python payment service' });
|
||||
kg.createEntity('pr', 'PR #87: Charge handler', { author: 'Bob Kumar', status: 'changes-requested', issue: 'race condition on DB write' });
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SESSION 3: Wednesday — blockers, decisions, progress
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
it('Session 3: Midweek check-in — credentials arrived, new blocker, scope change', async () => {
|
||||
const db = new MindDB(MIND_PATH);
|
||||
const orch = new Orchestrator({ db, embedder });
|
||||
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Alice delivered Stripe production credentials to Vault as promised. Verified access works. Removed from pending items.',
|
||||
importance: 'normal',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'NEW BLOCKER: Legal team says we need PCI DSS compliance audit before go-live. This was not in the original scope. Meeting scheduled with compliance team Friday. Could delay launch by 2 weeks.',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Bob fixed the race condition in PR #87 using Stripe idempotency keys. Approved and merged. The charge endpoint is now production-ready pending PCI review.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Marko decided to descope the refund endpoint from the initial launch. Reason: the double-refund bug (PAY-142) needs careful handling and the PCI blocker already delays us. Refund stays in Python service for now, will be migrated in phase 2.',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Updated launch plan: Phase 1 = charge + get payment (Go). Phase 2 = refund migration + PAY-142 fix. Phase 3 = deprecate Python service entirely. Each phase is ~2 weeks.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Circuit breaker implementation complete. Using sony/gobreaker library. Settings: maxRequests=5, interval=60s, timeout=5s, trip after 3 consecutive failures. Tested with fault injection — works correctly.',
|
||||
importance: 'normal',
|
||||
});
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SESSION 4: Thursday — COLD START. Can the agent pick up where we left off?
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
it('Session 4 (COLD START): Agent must recall project state without being told', async () => {
|
||||
const db = new MindDB(MIND_PATH);
|
||||
lastDb = db; // Track for cleanup
|
||||
const orch = new Orchestrator({ db, embedder });
|
||||
|
||||
// ─── Test A: Identity survives ───
|
||||
const identity = await orch.executeTool('get_identity', {});
|
||||
expect(identity).toContain('Waggle');
|
||||
expect(identity).toContain('Senior Engineering Assistant');
|
||||
|
||||
// ─── Test B: Awareness items survive ───
|
||||
const awareness = await orch.executeTool('get_awareness', {});
|
||||
expect(awareness).toContain('Marko Markovic');
|
||||
expect(awareness).toContain('payment service');
|
||||
|
||||
// ─── Test C: "What are we working on?" ───
|
||||
const projectContext = await orch.executeTool('search_memory', {
|
||||
query: 'payment service architecture Go',
|
||||
});
|
||||
expect(projectContext).toContain('Go');
|
||||
expect(projectContext).toContain('chi router');
|
||||
expect(projectContext).toContain('Stripe');
|
||||
|
||||
// ─── Test D: "What's blocking us?" ───
|
||||
const blockers = await orch.executeTool('search_memory', {
|
||||
query: 'blocker PCI compliance',
|
||||
});
|
||||
expect(blockers).toContain('PCI DSS');
|
||||
expect(blockers).toContain('compliance');
|
||||
|
||||
// ─── Test E: "What happened with the Stripe credentials?" ───
|
||||
const credentials = await orch.executeTool('search_memory', {
|
||||
query: 'Stripe credentials Vault Alice',
|
||||
});
|
||||
expect(credentials).toContain('Vault');
|
||||
expect(credentials).toContain('Alice');
|
||||
|
||||
// ─── Test F: "What's the current scope?" (must know about descoping) ───
|
||||
const scope = await orch.executeTool('search_memory', {
|
||||
query: 'launch plan phase refund descope',
|
||||
});
|
||||
expect(scope).toContain('Phase 1');
|
||||
expect(scope).toContain('refund');
|
||||
|
||||
// ─── Test G: "Tell me about the double-refund bug" ───
|
||||
const bug = await orch.executeTool('search_memory', {
|
||||
query: 'double refund bug PAY-142',
|
||||
});
|
||||
expect(bug).toContain('PAY-142');
|
||||
expect(bug).toContain('refund');
|
||||
|
||||
// ─── Test H: "What's Bob working on?" (knowledge graph) ───
|
||||
const bobInfo = await orch.executeTool('query_knowledge', {
|
||||
query: 'Bob',
|
||||
});
|
||||
expect(bobInfo).toContain('Bob Kumar');
|
||||
expect(bobInfo).toContain('implements');
|
||||
|
||||
// ─── Test I: "What does our service depend on?" ───
|
||||
const deps = await orch.executeTool('query_knowledge', {
|
||||
query: 'payment-service',
|
||||
});
|
||||
expect(deps).toContain('payment-service');
|
||||
expect(deps).toContain('depends_on');
|
||||
expect(deps).toContain('fraud-check');
|
||||
|
||||
// ─── Test J: "What was the error handling decision?" ───
|
||||
const errorHandling = await orch.executeTool('search_memory', {
|
||||
query: 'error handling JSON status codes',
|
||||
});
|
||||
expect(errorHandling).toContain('402');
|
||||
expect(errorHandling).toContain('circuit breaker');
|
||||
|
||||
// ─── Test K: "What are the performance requirements?" ───
|
||||
const perf = await orch.executeTool('search_memory', {
|
||||
query: 'performance latency p99 target',
|
||||
});
|
||||
expect(perf).toContain('200ms');
|
||||
expect(perf).toContain('Prometheus');
|
||||
|
||||
// ─── Test L: "What was decided about the race condition?" ───
|
||||
const raceCondition = await orch.executeTool('search_memory', {
|
||||
query: 'race condition idempotency PR 87',
|
||||
});
|
||||
expect(raceCondition).toContain('idempotency');
|
||||
|
||||
// ─── Test M: "What's the database schema?" ───
|
||||
const schema = await orch.executeTool('search_memory', {
|
||||
query: 'database schema payments table',
|
||||
});
|
||||
expect(schema).toContain('amount_cents');
|
||||
expect(schema).toContain('bigint');
|
||||
|
||||
// ─── Test N: Memory stats show realistic data ───
|
||||
const stats = orch.getMemoryStats();
|
||||
expect(stats.frameCount).toBeGreaterThanOrEqual(15); // We saved ~17 memories
|
||||
expect(stats.sessionCount).toBeGreaterThanOrEqual(1); // At least 1 session (CognifyPipeline reuses active)
|
||||
expect(stats.entityCount).toBeGreaterThanOrEqual(6); // 6+ entities in knowledge graph
|
||||
|
||||
// ─── Test O: System prompt has everything for a cold-start ───
|
||||
const prompt = orch.buildSystemPrompt();
|
||||
expect(prompt).toContain('Waggle');
|
||||
expect(prompt).toContain('payment service');
|
||||
expect(prompt).toContain('search_memory');
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
71
packages/cli/tests/mode-detector.test.ts
Normal file
71
packages/cli/tests/mode-detector.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { detectMode, type ModeDetectorDeps } from '../src/mode-detector.js';
|
||||
|
||||
function makeDeps(overrides: Partial<ModeDetectorDeps> = {}): ModeDetectorDeps {
|
||||
return {
|
||||
hasToken: false,
|
||||
serverUrl: 'http://localhost:3000',
|
||||
forceLocal: false,
|
||||
forceTeam: false,
|
||||
healthCheck: vi.fn().mockResolvedValue(true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('detectMode', () => {
|
||||
it('returns local when no token', async () => {
|
||||
const result = await detectMode(makeDeps({ hasToken: false }));
|
||||
expect(result).toEqual({ type: 'local' });
|
||||
});
|
||||
|
||||
it('returns team when token + server reachable', async () => {
|
||||
const result = await detectMode(makeDeps({
|
||||
hasToken: true,
|
||||
healthCheck: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
expect(result).toEqual({ type: 'team' });
|
||||
});
|
||||
|
||||
it('returns local with warning when token + server unreachable', async () => {
|
||||
const result = await detectMode(makeDeps({
|
||||
hasToken: true,
|
||||
healthCheck: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
expect(result).toEqual({
|
||||
type: 'local',
|
||||
warning: 'Server unreachable — running in local mode.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns local when --local forced', async () => {
|
||||
const result = await detectMode(makeDeps({
|
||||
hasToken: true,
|
||||
forceLocal: true,
|
||||
healthCheck: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
expect(result).toEqual({ type: 'local' });
|
||||
});
|
||||
|
||||
it('returns team when --team forced + token', async () => {
|
||||
const healthCheck = vi.fn().mockResolvedValue(false);
|
||||
const result = await detectMode(makeDeps({
|
||||
hasToken: true,
|
||||
forceTeam: true,
|
||||
healthCheck,
|
||||
}));
|
||||
expect(result).toEqual({ type: 'team' });
|
||||
// Should not even check health when forced
|
||||
expect(healthCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when --team forced + no token', async () => {
|
||||
const result = await detectMode(makeDeps({
|
||||
hasToken: false,
|
||||
forceTeam: true,
|
||||
}));
|
||||
expect(result).toEqual({
|
||||
type: 'error',
|
||||
error: 'Team mode requires login. Run: waggle login',
|
||||
});
|
||||
});
|
||||
});
|
||||
317
packages/cli/tests/real-session-simulation.ts
Normal file
317
packages/cli/tests/real-session-simulation.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Real session simulation.
|
||||
*
|
||||
* Part 1: Populate the .mind file with actual project context
|
||||
* from today's work session (M3c completion, bug fixes, test improvements).
|
||||
*
|
||||
* Part 2: Cold-start a new orchestrator and query it —
|
||||
* what does the agent actually know?
|
||||
*
|
||||
* Uses the real ~/.waggle/default.mind file, not a temp file.
|
||||
*/
|
||||
import { MindDB } from '@waggle/core';
|
||||
import { Orchestrator } from '@waggle/agent';
|
||||
import { MockEmbedder } from '../../core/tests/mind/helpers/mock-embedder.js';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
const MIND_PATH = 'C:/Users/MarkoMarkovic/.waggle/default.mind';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PART 1: Populate with real project context
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function populateSession() {
|
||||
console.log('\n═══ SESSION 1: Loading real project context ═══\n');
|
||||
|
||||
const db = new MindDB(MIND_PATH);
|
||||
const embedder = new MockEmbedder();
|
||||
const orch = new Orchestrator({ db, embedder });
|
||||
|
||||
// --- Identity ---
|
||||
orch.getIdentity().create({
|
||||
name: 'Waggle',
|
||||
role: 'Senior Engineering Assistant',
|
||||
department: 'Waggle Platform Team',
|
||||
personality: 'Direct, thorough, remembers everything. Never guesses — uses tools to find answers.',
|
||||
capabilities: 'Memory (search_memory, save_memory), knowledge graph (query_knowledge), system tools (bash, read_file, write_file, edit_file, search_files, search_content), git tools, plan tools. Persistent .mind file stores everything across sessions.',
|
||||
system_prompt: 'You are Waggle, a senior engineering assistant for Marko Markovic. You help build the Waggle platform — a personal AI agent swarm for every knowledge worker. You have persistent memory in a .mind file. Always search memory before answering questions about the project.',
|
||||
});
|
||||
|
||||
// --- Awareness: current state ---
|
||||
orch.getAwareness().add('flag', 'User: Marko Markovic, Windows 11, prefers direct communication, not deeply technical', 10);
|
||||
orch.getAwareness().add('flag', 'Project: Waggle — personal AI agent swarm platform. Open core, $9-15/user/mo Pro tier.', 10);
|
||||
orch.getAwareness().add('flag', 'Codebase: D:\\Projects\\MS Claw\\waggle-poc (monorepo, 11 packages, GitHub: marolinik/waggle)', 10);
|
||||
orch.getAwareness().add('flag', 'Tech stack: Node.js, TypeScript, better-sqlite3, Vitest, Fastify, Drizzle, BullMQ, Clerk', 8);
|
||||
orch.getAwareness().add('task', 'NEXT: M4 — Tauri 2.0 desktop app (Windows first)', 9);
|
||||
orch.getAwareness().add('task', 'THEN: M5 — Web app', 7);
|
||||
orch.getAwareness().add('task', 'LATER: Agent intelligence polish (system prompt, context management, smart tool use)', 6);
|
||||
|
||||
// --- Milestone history ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M0 (POC): COMPLETE. Scientific validation of all core components — .mind file, memory frames, knowledge graph, hybrid search, memory weaver, optimizer.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M1 (MVP Desktop App): COMPLETE. Basic Tauri app, 11 tasks. Proved the desktop concept.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M2 (Developer Platform): COMPLETE. 232 tests, 6 packages — @waggle/core, @waggle/agent, @waggle/optimizer, @waggle/weaver, @waggle/cli, @waggle/sdk. CLI with REPL, model router, config system, plugin system, skill SDK.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M3 (Team Pilot): COMPLETE. 417 tests, 5 new packages — @waggle/server (Fastify 5), @waggle/worker (BullMQ), @waggle/shared (Zod schemas), @waggle/admin-web (React), @waggle/waggle-dance (messaging). 16 Drizzle tables, Clerk auth, WebSocket gateway, role-based access, task board, cron scheduler, 3 daemon agents (Scout, Subconscious, Hive Mind).',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M3a (CLI→Server + Real LLM): COMPLETE. 475 tests. LiteLLM proxy for model-agnostic routing. System tools: bash, read_file, write_file, edit_file, search_files, search_content (Claude Code parity). Shared runAgentLoop() in @waggle/agent. Browser OAuth via Clerk. Mode detection: auto local/team. Worker wired to real agent loop. Streaming via WebSocket.',
|
||||
importance: 'important',
|
||||
});
|
||||
|
||||
// --- M3b and M3c (today's work) ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M3b (Agent Intelligence): COMPLETE. Self-awareness module (agent knows its own tools, model, memory stats). Auto-identity on first run. CostTracker for token/cost tracking. HookRegistry for pre/post tool events. LoopGuard to detect infinite tool call loops. Eval framework with promptfoo-style test runner. LiteLLM embeddings integration.',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M3c (Agent Power): COMPLETE. 17 tasks. HookRegistry event system with cancel support. PermissionManager with whitelist/blacklist and sandbox mode. ConfirmationGate for sensitive tools (bash, write_file, edit_file, git_commit). Plan tools (create_plan, add_plan_step, show_plan). Git tools (git_status, git_diff, git_log, git_commit). Ontology layer for .mind schema. AuditTools for traceability. MemoryLinker for cross-frame references. FeedbackHandler for knowledge graph corrections.',
|
||||
importance: 'critical',
|
||||
});
|
||||
|
||||
// --- Bug fixes from today ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'BUG FIX (critical): LiteLLM→Anthropic tool_calls format conversion was broken. Streaming tool call accumulation was missing type: "function" field, causing LiteLLM to drop assistant tool_use messages. Fix in packages/agent/src/agent-loop.ts. Also fixed content: null → content: "" when tool_calls present.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'BUG FIX (critical): Cross-session memory was completely broken. CognifyPipeline was never wired into Orchestrator — save_memory used raw frame creation without vector indexing. Fixed by wiring CognifyPipeline in Orchestrator constructor. Also added LIKE fallback scan in search_memory when hybrid search returns empty.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'BUG FIX: FTS5 query sanitization — queries with hyphens like "topic-7" crashed with SqliteError because FTS5 interpreted hyphens as NOT operator. Fixed in packages/core/src/mind/search.ts by auto-quoting each token. Try/catch fallback for FTS5 parse errors.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'BUG FIX: WS gateway Redis subscribe crash — join_team returned "Invalid message" when Redis PUBSUB call failed. Wrapped Redis subscribe in try/catch. Also fixed all test isolation issues: unique queue names for BullMQ, unique slugs/clerkIds, poll-based assertions instead of fixed sleeps.',
|
||||
importance: 'normal',
|
||||
});
|
||||
|
||||
// --- Current test status ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Test status after all fixes: 85 test files, 679 tests — ALL PASSING. Zero flaky tests. Key test files: packages/cli/tests/comprehensive-e2e.test.ts (18 tests), packages/cli/tests/memory-persistence-hard.test.ts (4 tests simulating 3 work sessions + cold start recall). Test suite runs in ~7 seconds.',
|
||||
importance: 'important',
|
||||
});
|
||||
|
||||
// --- Architecture decisions ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Architecture: .mind file is a single SQLite database (better-sqlite3) containing everything — memory frames, knowledge graph, identity, awareness, sessions, FTS5 index, sqlite-vec embeddings. Portable: copy the file = copy the brain. Format inspired by video codecs: I-frames (snapshots) + P-frames (deltas) + B-frames (cross-references).',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Architecture: CognifyPipeline is the memory enrichment pipeline. When save_memory is called: (1) create P-frame in current session, (2) extract entities via regex patterns, (3) upsert entities into knowledge graph, (4) create relations between entities, (5) index frame for vector search via embeddings, (6) index in FTS5 for keyword search.',
|
||||
importance: 'important',
|
||||
});
|
||||
|
||||
// --- What's missing / known issues ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'KNOWN GAPS: (1) No real embeddings in CLI — using MockEmbedder (deterministic hash), so semantic search does not work (color≠colour). Need LiteLLM embeddings endpoint. (2) No Memory Weaver daemon running — consolidation is manual. (3) No GraphContext integration yet — knowledge graph is basic, no SHACL validation. (4) Agent intelligence needs polish — system prompt is decent but not Claude Code level.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'DECISION: Build platforms first (M4 Tauri desktop, M5 web app), polish agent intelligence last. Reasoning: agent code is shared (@waggle/agent), improvements land everywhere at once. Can\'t know what "smart" means until real UX exists. Diminishing returns on agent tuning now vs compounding returns from having platforms.',
|
||||
importance: 'critical',
|
||||
});
|
||||
|
||||
// --- Plan file locations ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Plan documents location: D:\\Projects\\MS Claw\\docs\\plans\\. Key files: 2026-03-09-waggle-full-roadmap.md (master roadmap), 2026-03-06-waggle-poc-design.md (original POC design), 2026-03-09-waggle-m3b-implementation.md (M3b plan), 2026-03-09-waggle-m3c-implementation.md (M3c plan). Architecture visualization: D:\\Projects\\MS Claw\\waggle-architecture.html',
|
||||
importance: 'important',
|
||||
});
|
||||
|
||||
// --- Knowledge graph ---
|
||||
const kg = orch.getKnowledge();
|
||||
const marko = kg.createEntity('person', 'Marko Markovic', { role: 'Founder & Tech Lead', platform: 'Windows 11' });
|
||||
const waggle = kg.createEntity('project', 'Waggle', { status: 'active', stage: 'M3c complete, M4 next', repo: 'marolinik/waggle', license: 'open-core' });
|
||||
const mindFile = kg.createEntity('technology', '.mind file', { format: 'SQLite', purpose: 'portable agent brain' });
|
||||
const litellm = kg.createEntity('technology', 'LiteLLM', { purpose: 'model-agnostic LLM routing', port: '4000' });
|
||||
const tauri = kg.createEntity('technology', 'Tauri 2.0', { purpose: 'desktop app framework', language: 'Rust + WebView2' });
|
||||
const core = kg.createEntity('package', '@waggle/core', { purpose: 'MindDB, identity, awareness, frames, sessions, search, knowledge graph' });
|
||||
const agent = kg.createEntity('package', '@waggle/agent', { purpose: 'Orchestrator, tools, agent loop, CognifyPipeline, hooks, permissions' });
|
||||
const cli = kg.createEntity('package', '@waggle/cli', { purpose: 'Interactive REPL, commands, rendering' });
|
||||
const server = kg.createEntity('package', '@waggle/server', { purpose: 'Fastify REST API, WebSocket gateway, Drizzle ORM' });
|
||||
const worker = kg.createEntity('package', '@waggle/worker', { purpose: 'BullMQ job processor, handler registry' });
|
||||
|
||||
kg.createRelation(marko.id, waggle.id, 'founded', 1.0);
|
||||
kg.createRelation(waggle.id, mindFile.id, 'uses', 1.0);
|
||||
kg.createRelation(waggle.id, litellm.id, 'uses', 0.9);
|
||||
kg.createRelation(waggle.id, tauri.id, 'will_use', 0.8);
|
||||
kg.createRelation(waggle.id, core.id, 'contains', 1.0);
|
||||
kg.createRelation(waggle.id, agent.id, 'contains', 1.0);
|
||||
kg.createRelation(waggle.id, cli.id, 'contains', 1.0);
|
||||
kg.createRelation(waggle.id, server.id, 'contains', 1.0);
|
||||
kg.createRelation(waggle.id, worker.id, 'contains', 1.0);
|
||||
kg.createRelation(agent.id, core.id, 'depends_on', 1.0);
|
||||
kg.createRelation(cli.id, agent.id, 'depends_on', 1.0);
|
||||
|
||||
const stats = orch.getMemoryStats();
|
||||
console.log(`Populated: ${stats.frameCount} frames, ${stats.sessionCount} sessions, ${stats.entityCount} entities`);
|
||||
db.close();
|
||||
console.log('Session 1 closed.\n');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PART 2: Cold start — what does the agent know?
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function coldStartTest() {
|
||||
console.log('═══ SESSION 2: COLD START — Testing recall ═══\n');
|
||||
|
||||
const db = new MindDB(MIND_PATH);
|
||||
const embedder = new MockEmbedder();
|
||||
const orch = new Orchestrator({ db, embedder });
|
||||
|
||||
const tests: { name: string; query: string; tool: string; mustContain: string[] }[] = [
|
||||
{
|
||||
name: 'Who am I?',
|
||||
query: '',
|
||||
tool: 'get_identity',
|
||||
mustContain: ['Waggle', 'Senior Engineering Assistant'],
|
||||
},
|
||||
{
|
||||
name: 'What\'s the current state?',
|
||||
query: '',
|
||||
tool: 'get_awareness',
|
||||
mustContain: ['Marko', 'Waggle', 'M4'],
|
||||
},
|
||||
{
|
||||
name: 'What project are we building?',
|
||||
query: 'Waggle project what is it',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['agent', 'waggle'],
|
||||
},
|
||||
{
|
||||
name: 'Which milestones are done?',
|
||||
query: 'milestones complete status',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['COMPLETE'],
|
||||
},
|
||||
{
|
||||
name: 'What did we do in M3c?',
|
||||
query: 'M3c Agent Power tasks',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['HookRegistry', 'Permission'],
|
||||
},
|
||||
{
|
||||
name: 'What bugs did we fix today?',
|
||||
query: 'bug fix critical today',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['LiteLLM', 'tool_calls'],
|
||||
},
|
||||
{
|
||||
name: 'Why was cross-session memory broken?',
|
||||
query: 'cross-session memory broken CognifyPipeline',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['CognifyPipeline', 'Orchestrator'],
|
||||
},
|
||||
{
|
||||
name: 'How many tests pass?',
|
||||
query: 'test status passing count',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['679', 'PASSING'],
|
||||
},
|
||||
{
|
||||
name: 'What\'s the .mind file architecture?',
|
||||
query: '.mind file SQLite architecture portable',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['SQLite', 'portable'],
|
||||
},
|
||||
{
|
||||
name: 'What\'s next after M3c?',
|
||||
query: 'next milestone M4 Tauri desktop',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['Tauri', 'desktop'],
|
||||
},
|
||||
{
|
||||
name: 'Why polish agent last?',
|
||||
query: 'decision build platforms first polish agent last',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['platforms first', 'shared'],
|
||||
},
|
||||
{
|
||||
name: 'What are the known gaps?',
|
||||
query: 'known gaps missing embeddings daemon',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['MockEmbedder', 'semantic'],
|
||||
},
|
||||
{
|
||||
name: 'Where are the plan documents?',
|
||||
query: 'plan documents location roadmap',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['docs\\plans', 'roadmap'],
|
||||
},
|
||||
{
|
||||
name: 'What packages does Waggle have? (knowledge graph)',
|
||||
query: 'Waggle',
|
||||
tool: 'query_knowledge',
|
||||
mustContain: ['@waggle/core', '@waggle/agent'],
|
||||
},
|
||||
{
|
||||
name: 'Who is Marko? (knowledge graph)',
|
||||
query: 'Marko',
|
||||
tool: 'query_knowledge',
|
||||
mustContain: ['Marko Markovic', 'founded'],
|
||||
},
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const test of tests) {
|
||||
const args = test.tool === 'get_identity' || test.tool === 'get_awareness'
|
||||
? {}
|
||||
: { query: test.query };
|
||||
|
||||
const result = await orch.executeTool(test.tool, args);
|
||||
|
||||
// Case-insensitive check across full result
|
||||
const resultLower = result.toLowerCase();
|
||||
const missing = test.mustContain.filter(s => !resultLower.includes(s.toLowerCase()));
|
||||
|
||||
if (missing.length === 0) {
|
||||
console.log(` ✓ ${test.name}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(` ✗ ${test.name}`);
|
||||
console.log(` Missing: ${missing.join(', ')}`);
|
||||
console.log(` Got (first 300): ${result.substring(0, 300)}...`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n═══ RESULTS: ${passed}/${tests.length} passed, ${failed} failed ═══`);
|
||||
|
||||
// Show what the system prompt looks like on cold start
|
||||
console.log('\n═══ SYSTEM PROMPT (first 500 chars) ═══');
|
||||
const prompt = orch.buildSystemPrompt();
|
||||
console.log(prompt.substring(0, 500));
|
||||
console.log('...\n');
|
||||
|
||||
const stats = orch.getMemoryStats();
|
||||
console.log(`Memory stats: ${stats.frameCount} frames, ${stats.sessionCount} sessions, ${stats.entityCount} entities`);
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function main() {
|
||||
// Clean start
|
||||
for (const f of [MIND_PATH, MIND_PATH + '-wal', MIND_PATH + '-shm']) {
|
||||
try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
await populateSession();
|
||||
await coldStartTest();
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
24
packages/cli/tests/renderer.test.ts
Normal file
24
packages/cli/tests/renderer.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderMarkdown } from '../src/renderer.js';
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
it('renders bold text (no ** in output)', () => {
|
||||
const result = renderMarkdown('This is **bold** text');
|
||||
expect(result).not.toContain('**');
|
||||
expect(result).toContain('bold');
|
||||
});
|
||||
|
||||
it('renders list items (has bullet)', () => {
|
||||
const result = renderMarkdown('- first item\n- second item');
|
||||
// The bullet character used by chalk
|
||||
expect(result).toContain('\u2022');
|
||||
expect(result).toContain('first item');
|
||||
expect(result).toContain('second item');
|
||||
});
|
||||
|
||||
it('passes plain text through', () => {
|
||||
const input = 'Hello, this is just plain text.';
|
||||
const result = renderMarkdown(input);
|
||||
expect(result).toContain('Hello, this is just plain text.');
|
||||
});
|
||||
});
|
||||
26
packages/cli/tsconfig.json
Normal file
26
packages/cli/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../agent" },
|
||||
{ "path": "../weaver" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user