#!/usr/bin/env node
/**
* Keet Command-line Agent
*
* This script provides a minimal CLI agent for Keet that can:
* - Join a room
* - Set its name
* - Respond to user messages using Ollama
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { parseArgs } from 'node:util';
import fetch from 'node-fetch';
// Get the directory name of the current module
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Configure logging
const logLevels = {
INFO: 'INFO',
ERROR: 'ERROR',
WARNING: 'WARNING',
DEBUG: 'DEBUG'
};
function log(level, message) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${level}] ${message}`);
}
// More detailed logging for verbose mode
function verboseLog(message, args) {
if (args && args.verbose) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [VERBOSE] ${message}`);
}
}
// Import Keet dependencies or mock implementations
let KeetBackend, KeetCoreApi;
let KEET_AVAILABLE = false;
let useMock = false;
async function importDependencies() {
try {
// Try to import the real Keet dependencies
const backendModule = await import('@holepunchto/keet-backend');
const coreApiModule = await import('@holepunchto/keet-core-api');
// Try to get the exports
KeetBackend = backendModule.default || backendModule;
KeetCoreApi = coreApiModule.default || coreApiModule;
KEET_AVAILABLE = true;
log(logLevels.INFO, 'Successfully imported Keet dependencies');
} catch (error) {
log(logLevels.WARNING, `Failed to import Keet dependencies: ${error.message}`);
log(logLevels.WARNING, 'Using mock implementation');
// Create a simple mock implementation
class MockKeetBackend {
constructor(storagePath, options) {
this.storagePath = storagePath;
this.options = options;
this.api = {
core: {
getIdentity: async () => {
try {
const identityPath = path.join(storagePath, 'identity.json');
if (fs.existsSync(identityPath)) {
return JSON.parse(fs.readFileSync(identityPath, 'utf8'));
}
return null;
} catch (error) {
return null;
}
},
generateIdentity: async () => {
const identity = {
publicKey: `mock-${Date.now()}`,
displayName: 'Mock User'
};
const identityPath = path.join(storagePath, 'identity.json');
fs.writeFileSync(identityPath, JSON.stringify(identity));
return identity;
},
getRecentRooms: async () => {
try {
const roomsPath = path.join(storagePath, 'rooms.json');
if (fs.existsSync(roomsPath)) {
return JSON.parse(fs.readFileSync(roomsPath, 'utf8'));
}
return [];
} catch (error) {
return [];
}
},
pairRoom: async (roomKey) => {
const rooms = await this.api.core.getRecentRooms();
const newRoom = {
roomId: roomKey,
title: `Room ${roomKey.substring(0, 8)}`,
lastMessage: null,
timestamp: Date.now()
};
rooms.push(newRoom);
const roomsPath = path.join(storagePath, 'rooms.json');
fs.writeFileSync(roomsPath, JSON.stringify(rooms));
return true;
},
getChatMessages: async (roomId) => {
try {
const messagesPath = path.join(storagePath, `messages_${roomId}.json`);
if (fs.existsSync(messagesPath)) {
return JSON.parse(fs.readFileSync(messagesPath, 'utf8'));
}
return [];
} catch (error) {
return [];
}
},
addChatMessage: async (roomId, text) => {
const messages = await this.api.core.getChatMessages(roomId);
const identity = await this.api.core.getIdentity();
const newMessage = {
id: `msg-${Date.now()}`,
timestamp: Date.now(),
author: identity.publicKey,
chat: {
text: text
}
};
messages.push(newMessage);
const messagesPath = path.join(storagePath, `messages_${roomId}.json`);
fs.writeFileSync(messagesPath, JSON.stringify(messages));
return true;
},
setDisplayName: async (name) => {
const identity = await this.api.core.getIdentity();
if (identity) {
identity.displayName = name;
const identityPath = path.join(storagePath, 'identity.json');
fs.writeFileSync(identityPath, JSON.stringify(identity));
}
return true;
}
}
};
}
async ready() {
// Create necessary directories
if (!fs.existsSync(this.storagePath)) {
fs.mkdirSync(this.storagePath, { recursive: true });
}
// Initialize rooms file if it doesn't exist
const roomsPath = path.join(this.storagePath, 'rooms.json');
if (!fs.existsSync(roomsPath)) {
fs.writeFileSync(roomsPath, JSON.stringify([]));
}
return true;
}
async close() {
return true;
}
}
KeetBackend = MockKeetBackend;
KEET_AVAILABLE = true;
useMock = true;
}
}
/**
* Parse command-line arguments.
*/
function parseArguments() {
const options = {
storage: { type: 'string' },
'room-key': { type: 'string' },
name: { type: 'string', default: 'KeetBot' },
model: { type: 'string', default: 'llama3.1:latest' },
'ollama-host': { type: 'string', default: 'localhost:11434' },
prompt: { type: 'string' },
mock: { type: 'boolean', default: false },
verbose: { type: 'boolean', default: false },
'link-device': { type: 'string' }, // Name of the device to link
'confirm-device': { type: 'string' }, // Device ID to confirm
'mobile-device': { type: 'boolean', default: false } // Whether the linked device is mobile
};
const { values } = parseArgs({ options });
// Force mock mode if specified
if (values.mock) {
useMock = true;
}
return values;
}
/**
* Ollama client for interacting with the Ollama API.
*/
class OllamaClient {
constructor(host = 'localhost:11434', model = 'llama3.1:latest', systemPrompt = null) {
this.host = host;
this.model = model;
this.systemPrompt = systemPrompt;
this.baseUrl = `http://${host}`;
// User context tracking
this.userContexts = new Map();
this.maxContextLength = 10; // Maximum number of messages to keep in context
}
/**
* Call Ollama chat API to generate a response.
*/
async chat(message, userId, roomId) {
try {
const contextKey = `${userId}:${roomId}`;
const messages = [];
// Add system prompt if provided
if (this.systemPrompt) {
messages.push({
role: 'system',
content: this.systemPrompt
});
}
// Add conversation context if available
if (this.userContexts.has(contextKey)) {
for (const ctxMsg of this.userContexts.get(contextKey)) {
messages.push(ctxMsg);
}
}
// Add user message
const userMessage = {
role: 'user',
content: message
};
messages.push(userMessage);
// Store user message in context
if (!this.userContexts.has(contextKey)) {
this.userContexts.set(contextKey, []);
}
this.userContexts.get(contextKey).push(userMessage);
// Trim context if it gets too long
if (this.userContexts.get(contextKey).length > this.maxContextLength) {
this.userContexts.set(
contextKey,
this.userContexts.get(contextKey).slice(-this.maxContextLength)
);
}
// Make API request to Ollama
const response = await fetch(`${this.baseUrl}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: this.model,
messages: messages,
stream: false
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ollama API error (${response.status}): ${errorText}`);
}
const data = await response.json();
const messageContent = data.message.content;
// Store assistant response in context
this.userContexts.get(contextKey).push({
role: 'assistant',
content: messageContent
});
// Trim context if it gets too long
if (this.userContexts.get(contextKey).length > this.maxContextLength) {
this.userContexts.set(
contextKey,
this.userContexts.get(contextKey).slice(-this.maxContextLength)
);
}
return messageContent;
} catch (error) {
log(logLevels.ERROR, `Error calling Ollama API: ${error.message}`);
throw error;
}
}
/**
* List available models from Ollama.
*/
async listModels() {
try {
const response = await fetch(`${this.baseUrl}/api/tags`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ollama API error (${response.status}): ${errorText}`);
}
const data = await response.json();
return data.models.map(model => model.name);
} catch (error) {
log(logLevels.ERROR, `Error listing Ollama models: ${error.message}`);
throw error;
}
}
/**
* Change the model being used.
*/
changeModel(model) {
this.model = model;
log(logLevels.INFO, `Changed Ollama model to: ${model}`);
}
/**
* Clear context for a specific user/room.
*/
clearContext(userId, roomId) {
const contextKey = `${userId}:${roomId}`;
this.userContexts.delete(contextKey);
log(logLevels.INFO, `Cleared context for ${userId} in room ${roomId}`);
}
}
/**
* Process new messages and respond using Ollama.
*/
async function processMessages(backend, roomId, lastProcessedTimestamp, ollamaClient, verbose) {
try {
// Get messages from the room
verboseLog(`Fetching messages from room ${roomId}...`, { verbose });
const messages = await backend.api.core.getChatMessages(roomId);
verboseLog(`Retrieved ${messages.length} total messages from room`, { verbose });
// Get identity for filtering
const identity = await backend.api.core.getIdentity();
verboseLog(`Current identity: ${identity ? identity.publicKey : 'undefined'}`, { verbose });
// Filter messages that are newer than the last processed timestamp
const newMessages = messages.filter(msg => {
// Skip our own messages
if (identity && identity.publicKey && msg.author === identity.publicKey) {
verboseLog(`Skipping own message with ID: ${typeof msg.id === 'object' ? JSON.stringify(msg.id) : msg.id}`, { verbose });
return false;
}
// Check if the message is newer than the last processed timestamp
const isNewer = msg.timestamp > lastProcessedTimestamp;
if (isNewer) {
verboseLog(`Found new message with ID: ${typeof msg.id === 'object' ? JSON.stringify(msg.id) : msg.id}, timestamp: ${new Date(msg.timestamp).toISOString()}`, { verbose });
}
return isNewer;
});
verboseLog(`Found ${newMessages.length} new messages to process`, { verbose });
if (newMessages.length > 0) {
// Update the last processed timestamp
const latestTimestamp = Math.max(...newMessages.map(msg => msg.timestamp));
verboseLog(`Updating last processed timestamp to: ${new Date(latestTimestamp).toISOString()}`, { verbose });
// Process each new message
for (const message of newMessages) {
if (message.chat && message.chat.text) {
const authorId = message.author ? message.author.substring(0, 8) : 'unknown';
log(logLevels.INFO, `New message from ${authorId}: ${message.chat.text}`);
try {
// Generate a response using Ollama
log(logLevels.INFO, `Generating response using Ollama (model: ${ollamaClient.model})...`);
const response = await ollamaClient.chat(message.chat.text, message.author || 'unknown-user', roomId);
// Send the response back to the room
log(logLevels.INFO, `Sending response to room ${roomId}: ${response}`);
verboseLog(`Attempting to send message to room ${roomId}...`, { verbose });
await backend.api.core.addChatMessage(roomId, response);
log(logLevels.INFO, `✓ Message successfully sent to room`);
} catch (error) {
log(logLevels.ERROR, `Failed to generate or send response: ${error.message}`);
}
}
}
return latestTimestamp;
} else {
verboseLog(`No new messages to process`, { verbose });
}
return lastProcessedTimestamp;
} catch (error) {
log(logLevels.ERROR, `Error processing messages: ${error.message}`);
return lastProcessedTimestamp;
}
}
/**
* Main function.
*/
async function main() {
// Parse command-line arguments
const args = parseArguments();
// Set verbose logging if requested
if (args.verbose) {
console.log('Verbose logging enabled');
}
// Import dependencies
await importDependencies();
if (!KEET_AVAILABLE) {
log(logLevels.ERROR, 'Keet dependencies not available');
return 1;
}
// Create a storage directory if it doesn't exist
const storagePath = args.storage || path.join(__dirname, '..', 'test-keet-storage');
if (!fs.existsSync(storagePath)) {
fs.mkdirSync(storagePath, { recursive: true });
}
// Initialize Keet backend
log(logLevels.INFO, 'Initializing Keet backend...');
const backend = new KeetBackend(storagePath, {
experimental: false,
devMirrors: false
});
// Wait for backend to be ready
await backend.ready();
// Check if we have an identity
log(logLevels.INFO, 'Checking identity...');
let identity = await backend.api.core.getIdentity();
// Ensure the identity has anonymous set to false
if (identity && identity.anonymous !== false) {
log(logLevels.INFO, 'Setting anonymous to false in identity...');
identity.anonymous = false;
// If we're using a mock implementation, manually update the identity file
if (useMock) {
const identityPath = path.join(storagePath, 'identity.json');
fs.writeFileSync(identityPath, JSON.stringify(identity));
log(logLevels.INFO, 'Updated identity file with anonymous=false');
}
}
// If we don't have an identity, create one
if (!identity) {
log(logLevels.INFO, 'No identity found, generating a new one...');
await backend.api.core.generateIdentity();
identity = await backend.api.core.getIdentity();
// If we're using a mock implementation, we need to manually set up the identity
if (useMock) {
log(logLevels.INFO, 'Using mock implementation, manually setting up identity...');
// Create a proper identity structure
const identityPath = path.join(storagePath, 'identity.json');
const mockIdentity = {
anonymous: false, // Set to false for a real identity
memberId: `real-${Date.now()}`,
timestamp: Date.now(),
displayName: args.name || 'KeetBot',
avatar: null,
devices: [{
deviceId: `device-${Date.now()}`,
isMobile: false,
name: 'Primary Device',
confirmed: true
}],
local: {
deviceId: `device-${Date.now()}`
}
};
// Make sure the local deviceId matches the first device in the devices array
mockIdentity.local.deviceId = mockIdentity.devices[0].deviceId;
// Write the identity to the file
fs.writeFileSync(identityPath, JSON.stringify(mockIdentity));
// Update our identity reference
identity = mockIdentity;
log(logLevels.INFO, `Created new identity with ID: ${identity.memberId}`);
}
}
// Set the display name if provided
if (args.name && identity) {
log(logLevels.INFO, `Setting display name to: ${args.name}`);
if (backend.api.core.setDisplayName) {
await backend.api.core.setDisplayName(args.name);
} else {
log(logLevels.WARNING, 'setDisplayName method not available');
// If we're using a mock implementation, manually update the display name
if (useMock) {
const identityPath = path.join(storagePath, 'identity.json');
identity.displayName = args.name;
fs.writeFileSync(identityPath, JSON.stringify(identity));
log(logLevels.INFO, `Manually updated display name to: ${args.name}`);
}
}
}
// Add a function to link a new device to the identity
async function linkDevice(deviceName = 'New Device', isMobile = false) {
try {
// Get the current identity
const identity = await backend.api.core.getIdentity();
if (!identity) {
log(logLevels.ERROR, 'No identity found, cannot link device');
return null;
}
// If we're using the real Keet implementation
if (!useMock) {
// Check if there's a linkDevice method available
if (backend.api.core.linkDevice) {
log(logLevels.INFO, `Linking new device: ${deviceName}`);
const result = await backend.api.core.linkDevice(deviceName, isMobile);
log(logLevels.INFO, `Device linked successfully: ${JSON.stringify(result)}`);
return result;
} else {
log(logLevels.WARNING, 'linkDevice method not available');
return null;
}
} else {
// For mock implementation, manually add a device to the identity
const identityPath = path.join(storagePath, 'identity.json');
// Create a new device
const newDevice = {
deviceId: `device-${Date.now()}`,
isMobile: isMobile,
name: deviceName,
confirmed: false // New devices start as unconfirmed
};
// Add the device to the identity
identity.devices.push(newDevice);
// Write the updated identity to the file
fs.writeFileSync(identityPath, JSON.stringify(identity));
log(logLevels.INFO, `Manually added new device: ${newDevice.deviceId}`);
return newDevice;
}
} catch (error) {
log(logLevels.ERROR, `Error linking device: ${error.message}`);
return null;
}
}
// Add a function to confirm a linked device
async function confirmDevice(deviceId) {
try {
// Get the current identity
const identity = await backend.api.core.getIdentity();
if (!identity) {
log(logLevels.ERROR, 'No identity found, cannot confirm device');
return false;
}
// If we're using the real Keet implementation
if (!useMock) {
// Check if there's a confirmDevice method available
if (backend.api.core.confirmDevice) {
log(logLevels.INFO, `Confirming device: ${deviceId}`);
const result = await backend.api.core.confirmDevice(deviceId);
log(logLevels.INFO, `Device confirmed successfully`);
return result;
} else {
log(logLevels.WARNING, 'confirmDevice method not available');
return false;
}
} else {
// For mock implementation, manually confirm the device
const identityPath = path.join(storagePath, 'identity.json');
// Find the device in the identity
const deviceIndex = identity.devices.findIndex(device => device.deviceId === deviceId);
if (deviceIndex === -1) {
log(logLevels.ERROR, `Device not found: ${deviceId}`);
return false;
}
// Confirm the device
identity.devices[deviceIndex].confirmed = true;
// Write the updated identity to the file
fs.writeFileSync(identityPath, JSON.stringify(identity));
log(logLevels.INFO, `Manually confirmed device: ${deviceId}`);
return true;
}
} catch (error) {
log(logLevels.ERROR, `Error confirming device: ${error.message}`);
return false;
}
}
log(logLevels.INFO, `Current identity: ${JSON.stringify(identity)}`);
// Handle device linking if requested
if (args['link-device']) {
log(logLevels.INFO, `Linking new device: ${args['link-device']}`);
const newDevice = await linkDevice(args['link-device'], args['mobile-device']);
if (newDevice) {
log(logLevels.INFO, `Successfully linked new device: ${JSON.stringify(newDevice)}`);
log(logLevels.INFO, `Device ID: ${newDevice.deviceId}`);
log(logLevels.INFO, `To confirm this device, run with --confirm-device=${newDevice.deviceId}`);
// Exit after linking the device
return 0;
} else {
log(logLevels.ERROR, 'Failed to link device');
return 1;
}
}
// Handle device confirmation if requested
if (args['confirm-device']) {
log(logLevels.INFO, `Confirming device: ${args['confirm-device']}`);
const confirmed = await confirmDevice(args['confirm-device']);
if (confirmed) {
log(logLevels.INFO, `Successfully confirmed device: ${args['confirm-device']}`);
// Exit after confirming the device
return 0;
} else {
log(logLevels.ERROR, `Failed to confirm device: ${args['confirm-device']}`);
return 1;
}
}
// Initialize Ollama client
log(logLevels.INFO, `Initializing Ollama client with host: ${args['ollama-host']}, model: ${args.model}`);
const ollamaClient = new OllamaClient(args['ollama-host'], args.model, args.prompt);
// Join the room if a room key is provided
let roomId;
if (args['room-key']) {
const roomKey = args['room-key'];
log(logLevels.INFO, `Joining room with key: ${roomKey}`);
// Extract the room key from the invite link if needed
let normalizedRoomKey = roomKey;
if (roomKey.startsWith('pear://keet/')) {
normalizedRoomKey = roomKey.replace('pear://keet/', '');
verboseLog(`Extracted room key from pear link: ${normalizedRoomKey}`, args);
}
// Check if there's a pairRoom method available
if (backend.api.core.pairRoom) {
log(logLevels.INFO, 'Using pairRoom method to join the room...');
verboseLog(`Attempting to pair with room using key: ${normalizedRoomKey.substring(0, 16)}...`, args);
try {
verboseLog(`Starting room pairing process...`, args);
await backend.api.core.pairRoom(normalizedRoomKey);
log(logLevels.INFO, '✓ Successfully paired with the room');
roomId = normalizedRoomKey;
// Verify the room is in recent rooms after pairing
const recentRooms = await backend.api.core.getRecentRooms();
const roomFound = recentRooms.some(room => room.roomId === normalizedRoomKey);
if (roomFound) {
verboseLog(`Room verified in recent rooms list`, args);
} else {
log(logLevels.WARNING, `Room paired but not found in recent rooms list - this may cause issues`);
}
} catch (error) {
log(logLevels.ERROR, `Failed to pair with room: ${error.message}`);
verboseLog(`Detailed error: ${error.stack || 'No stack trace available'}`, args);
return 1;
}
} else {
log(logLevels.ERROR, 'pairRoom method not available');
verboseLog(`Available API methods: ${Object.keys(backend.api.core).join(', ')}`, args);
return 1;
}
} else {
// Get recent rooms
log(logLevels.INFO, 'No room key provided, checking recent rooms...');
const recentRooms = await backend.api.core.getRecentRooms();
verboseLog(`Found ${recentRooms.length} recent rooms`, args);
if (recentRooms.length > 0) {
roomId = recentRooms[0].roomId;
log(logLevels.INFO, `Using the most recent room: ${roomId.substring(0, 16)}...`);
verboseLog(`Room details: ${JSON.stringify(recentRooms[0])}`, args);
} else {
log(logLevels.ERROR, 'No room key provided and no recent rooms found');
return 1;
}
}
// Main loop to process messages
log(logLevels.INFO, `Starting message processing loop for room: ${roomId}`);
log(logLevels.INFO, `Using Ollama model: ${args.model}`);
// Get the current timestamp to avoid processing old messages
let lastProcessedTimestamp = Date.now();
try {
// Send an initial message to the room
const initialMessage = `Hello! I'm ${args.name}, a Keet bot powered by Ollama. I'm here to assist you.`;
log(logLevels.INFO, `Sending initial greeting message to room ${roomId.substring(0, 16)}...`);
verboseLog(`Full message: "${initialMessage}"`, args);
try {
verboseLog(`Attempting to send initial message...`, args);
await backend.api.core.addChatMessage(roomId, initialMessage);
log(logLevels.INFO, '✓ Initial greeting message sent successfully');
} catch (sendError) {
log(logLevels.ERROR, `Failed to send initial message: ${sendError.message}`);
verboseLog(`Detailed error: ${sendError.stack || 'No stack trace available'}`, args);
}
// Main loop
log(logLevels.INFO, `Starting main message processing loop`);
verboseLog(`Message polling interval: 2000ms`, args);
while (true) {
verboseLog(`Checking for new messages in room ${roomId.substring(0, 16)}...`, args);
// Process new messages
lastProcessedTimestamp = await processMessages(
backend,
roomId,
lastProcessedTimestamp,
ollamaClient,
args.verbose
);
// Wait a bit before checking for new messages
verboseLog(`Waiting 2000ms before next message check...`, args);
await new Promise(resolve => setTimeout(resolve, 2000));
}
} catch (error) {
log(logLevels.ERROR, `Error in main loop: ${error.message}`);
verboseLog(`Detailed error: ${error.stack || 'No stack trace available'}`, args);
// Close the backend
log(logLevels.INFO, 'Closing backend...');
await backend.close();
return 1;
}
}
// Run the main function if this file is executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
try {
const exitCode = await main();
process.exit(exitCode);
} catch (error) {
log(logLevels.ERROR, `Unhandled error: ${error.message}`);
log(logLevels.ERROR, error.stack);
process.exit(1);
}
}
export { main };