Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .agents/skills/send-user-message/scripts/send_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,7 @@ function sendMessage(argv: string[]): void {
channel,
sender,
senderId,
message: message.length > 4000
? message.substring(0, 3900) + '\n\n[Message truncated...]'
: message,
message,
originalMessage: '',
timestamp,
messageId,
Expand Down
35 changes: 29 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,42 @@ node_modules/
# TypeScript build output
dist/

# TinyClaw runtime files
.tinyclaw
# Environment variables / secrets
.env
.env.*

.wwebjs_cache
*.log
# Sensitive runtime data
/settings.json
/pairing.json

# Environment variables
.env
# Runtime directories (queue, logs, downloaded files, events)
/queue/
/logs/
/files/
/events/

# Plugins (user-installed, not part of source)
/plugins/

# Agent state
/.agents/

# WhatsApp session data
.wwebjs_cache/
/whatsapp-session/
/channels/

# Temp / misc runtime
*.log
.update_check
/heartbeat.md

# OS files
.DS_Store
Thumbs.db

# Editor
.vscode/
.idea/
*.swp
*~
25 changes: 21 additions & 4 deletions src/channels/telegram-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ interface ResponseData {
timestamp: number;
messageId: string;
files?: string[];
metadata?: { parseMode?: string; [key: string]: unknown };
}

function sanitizeFileName(fileName: string): string {
Expand Down Expand Up @@ -520,15 +521,31 @@ async function checkOutgoingQueue(): Promise<void> {
// Split message if needed (Telegram 4096 char limit)
if (responseText) {
const chunks = splitMessage(responseText);
const parseMode = responseData.metadata?.parseMode as TelegramBot.ParseMode | undefined;

const sendChunk = async (text: string, opts: TelegramBot.SendMessageOptions): Promise<void> => {
try {
await bot.sendMessage(targetChatId, text, opts);
} catch (sendErr) {
if (parseMode && opts.parse_mode) {
log('WARN', `MarkdownV2 send failed, falling back to plain text: ${(sendErr as Error).message}`);
delete opts.parse_mode;
await bot.sendMessage(targetChatId, text, opts);
} else {
throw sendErr;
}
}
};

if (chunks.length > 0) {
await bot.sendMessage(targetChatId, chunks[0]!, pending
const opts: TelegramBot.SendMessageOptions = pending
? { reply_to_message_id: pending.messageId }
: {},
);
: {};
if (parseMode) opts.parse_mode = parseMode;
await sendChunk(chunks[0]!, opts);
}
for (let i = 1; i < chunks.length; i++) {
await bot.sendMessage(targetChatId, chunks[i]!);
await sendChunk(chunks[i]!, parseMode ? { parse_mode: parseMode } : {});
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/lib/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import { ensureAgentDirectory, updateAgentTeammates } from './agent-setup';

export async function runCommand(command: string, args: string[], cwd?: string): Promise<string> {
return new Promise((resolve, reject) => {
const env = { ...process.env };
delete env.CLAUDECODE;

const child = spawn(command, args, {
cwd: cwd || SCRIPT_DIR,
stdio: ['ignore', 'pipe', 'pipe'],
env,
});

let stdout = '';
Expand Down
2 changes: 2 additions & 0 deletions src/lib/logging.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import { LOG_FILE, EVENTS_DIR } from './config';
import { broadcastEvent } from './plugins';

export function log(level: string, message: string): void {
const timestamp = new Date().toISOString();
Expand All @@ -21,6 +22,7 @@ export function emitEvent(type: string, data: Record<string, unknown>): void {
const event = { type, timestamp: Date.now(), ...data };
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
fs.writeFileSync(path.join(EVENTS_DIR, filename), JSON.stringify(event) + '\n');
broadcastEvent(event);
} catch {
// Visualizer events are best-effort; never break the queue processor
}
Expand Down
217 changes: 217 additions & 0 deletions src/lib/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* Plugin System for TinyClaw
*
* Plugins auto-discover from .tinyclaw/plugins/ folder.
* Each plugin exports an activate() function and/or a hooks object from index.ts.
*/

import fs from 'fs';
import path from 'path';
import { TINYCLAW_HOME } from './config';
import { log } from './logging';

// Types
export interface PluginEvent {
type: string;
timestamp: number;
[key: string]: unknown;
}

export interface HookContext {
channel: string;
sender: string;
messageId: string;
originalMessage: string;
}

export interface HookMetadata {
parseMode?: string;
[key: string]: unknown;
}

export interface HookResult {
text: string;
metadata: HookMetadata;
}

export interface Hooks {
transformOutgoing?(message: string, ctx: HookContext): string | HookResult | Promise<string | HookResult>;
transformIncoming?(message: string, ctx: HookContext): string | HookResult | Promise<string | HookResult>;
}

export interface PluginContext {
on(eventType: string | '*', handler: (event: PluginEvent) => void): void;
log(level: string, message: string): void;
getTinyClawHome(): string;
}

interface LoadedPlugin {
name: string;
hooks?: Hooks;
}

// Internal state
const loadedPlugins: LoadedPlugin[] = [];
const eventHandlers = new Map<string, Array<(event: PluginEvent) => void>>();

/**
* Create the plugin context passed to activate() functions.
*/
function createPluginContext(pluginName: string): PluginContext {
return {
on(eventType: string, handler: (event: PluginEvent) => void): void {
const handlers = eventHandlers.get(eventType) || [];
handlers.push(handler);
eventHandlers.set(eventType, handlers);
},
log(level: string, message: string): void {
log(level, `[plugin:${pluginName}] ${message}`);
},
getTinyClawHome(): string {
return TINYCLAW_HOME;
},
};
}

/**
* Load all plugins from .tinyclaw/plugins/.
* Each plugin directory should have an index.ts/index.js that exports:
* - activate(ctx: PluginContext): void (optional)
* - hooks: Hooks (optional)
*/
export async function loadPlugins(): Promise<void> {
const pluginsDir = path.join(TINYCLAW_HOME, 'plugins');

if (!fs.existsSync(pluginsDir)) {
log('DEBUG', 'No plugins directory found');
return;
}

const entries = fs.readdirSync(pluginsDir, { withFileTypes: true });

for (const entry of entries) {
if (!entry.isDirectory()) continue;

const pluginName = entry.name;
const pluginDir = path.join(pluginsDir, pluginName);

// Try to load index.js or index.ts (compiled)
const indexJs = path.join(pluginDir, 'index.js');
const indexTs = path.join(pluginDir, 'index.ts');

let indexPath: string | null = null;
if (fs.existsSync(indexJs)) {
indexPath = indexJs;
} else if (fs.existsSync(indexTs)) {
indexPath = indexTs;
}

if (!indexPath) {
log('WARN', `Plugin '${pluginName}' has no index.js or index.ts, skipping`);
continue;
}

try {
// Dynamic import
const pluginModule = await import(indexPath);
const plugin: LoadedPlugin = { name: pluginName };

// Call activate() if present
if (typeof pluginModule.activate === 'function') {
const ctx = createPluginContext(pluginName);
await pluginModule.activate(ctx);
}

// Store hooks if present
if (pluginModule.hooks) {
plugin.hooks = pluginModule.hooks;
}

loadedPlugins.push(plugin);
log('INFO', `Loaded plugin: ${pluginName}`);
} catch (error) {
log('ERROR', `Failed to load plugin '${pluginName}': ${(error as Error).message}`);
}
}

if (loadedPlugins.length > 0) {
log('INFO', `${loadedPlugins.length} plugin(s) loaded`);
}
}

/**
* Run all transformOutgoing hooks on a message.
*/
export async function runOutgoingHooks(message: string, ctx: HookContext): Promise<HookResult> {
let text = message;
let metadata: HookMetadata = {};

for (const plugin of loadedPlugins) {
if (plugin.hooks?.transformOutgoing) {
try {
const result = await plugin.hooks.transformOutgoing(text, ctx);
if (typeof result === 'string') {
text = result;
} else {
text = result.text;
metadata = { ...metadata, ...result.metadata };
}
} catch (error) {
log('ERROR', `Plugin '${plugin.name}' transformOutgoing error: ${(error as Error).message}`);
}
}
}

return { text, metadata };
}

/**
* Run all transformIncoming hooks on a message.
*/
export async function runIncomingHooks(message: string, ctx: HookContext): Promise<HookResult> {
let text = message;
let metadata: HookMetadata = {};

for (const plugin of loadedPlugins) {
if (plugin.hooks?.transformIncoming) {
try {
const result = await plugin.hooks.transformIncoming(text, ctx);
if (typeof result === 'string') {
text = result;
} else {
text = result.text;
metadata = { ...metadata, ...result.metadata };
}
} catch (error) {
log('ERROR', `Plugin '${plugin.name}' transformIncoming error: ${(error as Error).message}`);
}
}
}

return { text, metadata };
}

/**
* Broadcast an event to all registered handlers.
*/
export function broadcastEvent(event: PluginEvent): void {
// Call specific event type handlers
const typeHandlers = eventHandlers.get(event.type) || [];
for (const handler of typeHandlers) {
try {
handler(event);
} catch (error) {
log('ERROR', `Plugin event handler error: ${(error as Error).message}`);
}
}

// Call wildcard handlers
const wildcardHandlers = eventHandlers.get('*') || [];
for (const handler of wildcardHandlers) {
try {
handler(event);
} catch (error) {
log('ERROR', `Plugin wildcard handler error: ${(error as Error).message}`);
}
}
}
1 change: 1 addition & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export interface ResponseData {
messageId: string;
agent?: string; // which agent handled this
files?: string[];
metadata?: Record<string, unknown>;
}

export interface QueueFile {
Expand Down
Loading