clydeclaw 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from '../src/cli/parser.js';
4
+ import { handleCommand } from '../src/cli/commands.js';
5
+ import { log } from '../src/utils/logger.js';
6
+
7
+ const { command, args } = parseArgs(process.argv.slice(2));
8
+
9
+ try {
10
+ await handleCommand(command, args);
11
+ } catch (err) {
12
+ log.error(`Fatal: ${err.message}`);
13
+ process.exit(1);
14
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "clydeclaw",
3
+ "version": "1.1.0",
4
+ "description": "i made this for me and my firend",
5
+ "type": "module",
6
+ "bin": {
7
+ "clydeclaw": "./bin/clydeclaw.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/clydeclaw.js start",
11
+ "dev": "node --watch bin/clydeclaw.js start"
12
+ },
13
+ "keywords": [
14
+ "discord",
15
+ "ai",
16
+ "bot",
17
+ "cli",
18
+ "llm",
19
+ "openai",
20
+ "ollama",
21
+ "lmstudio"
22
+ ],
23
+ "author": "tommy howsego",
24
+ "license": "MIT",
25
+ "engines": {
26
+ "node": ">=20.0.0"
27
+ },
28
+ "dependencies": {
29
+ "discord.js": "^14.16.3"
30
+ }
31
+ }
@@ -0,0 +1,23 @@
1
+ import { createOpenAIProvider } from './providers/openai.js';
2
+ import { createAnthropicProvider } from './providers/anthropic.js';
3
+ import { createGeminiProvider } from './providers/gemini.js';
4
+
5
+ export function createAIClient(aiConfig) {
6
+ const type = (aiConfig.type || 'openai').toLowerCase();
7
+
8
+ switch (type) {
9
+ case 'anthropic':
10
+ case 'claude':
11
+ return createAnthropicProvider(aiConfig);
12
+
13
+ case 'gemini':
14
+ case 'google':
15
+ return createGeminiProvider(aiConfig);
16
+
17
+ case 'openai':
18
+ case 'local':
19
+ case 'api':
20
+ default:
21
+ return createOpenAIProvider(aiConfig);
22
+ }
23
+ }
@@ -0,0 +1,142 @@
1
+ const DEFAULT_ENDPOINT = 'https://api.anthropic.com';
2
+ const ANTHROPIC_VERSION = '2023-06-01';
3
+
4
+ export function createAnthropicProvider(aiConfig) {
5
+ const { endpoint, model, apiKey } = aiConfig;
6
+ const baseUrl = (endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, '');
7
+
8
+ if (!apiKey) {
9
+ throw new Error('Anthropic requires an API key. Run: clydeclaw set.ai apiKey <key>');
10
+ }
11
+
12
+ const headers = {
13
+ 'Content-Type': 'application/json',
14
+ 'x-api-key': apiKey,
15
+ 'anthropic-version': ANTHROPIC_VERSION
16
+ };
17
+
18
+ return {
19
+ name: 'anthropic',
20
+
21
+ async *stream(messages) {
22
+ const { system, userMessages } = splitSystem(messages);
23
+
24
+ const res = await fetch(`${baseUrl}/v1/messages`, {
25
+ method: 'POST',
26
+ headers,
27
+ body: JSON.stringify({
28
+ model: model || 'claude-sonnet-4-20250514',
29
+ max_tokens: 4096,
30
+ stream: true,
31
+ ...(system ? { system } : {}),
32
+ messages: userMessages
33
+ }),
34
+ signal: AbortSignal.timeout(120_000)
35
+ });
36
+
37
+ if (!res.ok) {
38
+ const text = await res.text().catch(() => '');
39
+ throw new Error(`Anthropic error ${res.status}: ${text.slice(0, 200)}`);
40
+ }
41
+
42
+ yield* parseAnthropicSSE(res.body);
43
+ },
44
+
45
+ async complete(messages) {
46
+ const { system, userMessages } = splitSystem(messages);
47
+
48
+ const res = await fetch(`${baseUrl}/v1/messages`, {
49
+ method: 'POST',
50
+ headers,
51
+ body: JSON.stringify({
52
+ model: model || 'claude-sonnet-4-20250514',
53
+ max_tokens: 4096,
54
+ stream: false,
55
+ ...(system ? { system } : {}),
56
+ messages: userMessages
57
+ }),
58
+ signal: AbortSignal.timeout(120_000)
59
+ });
60
+
61
+ if (!res.ok) {
62
+ const text = await res.text().catch(() => '');
63
+ throw new Error(`Anthropic error ${res.status}: ${text.slice(0, 200)}`);
64
+ }
65
+
66
+ const json = await res.json();
67
+ return (json.content || [])
68
+ .filter(b => b.type === 'text')
69
+ .map(b => b.text)
70
+ .join('');
71
+ }
72
+ };
73
+ }
74
+
75
+ function splitSystem(messages) {
76
+ let system = '';
77
+ const userMessages = [];
78
+
79
+ for (const msg of messages) {
80
+ if (msg.role === 'system') {
81
+ system += (system ? '\n' : '') + msg.content;
82
+ } else {
83
+ userMessages.push({ role: msg.role, content: msg.content });
84
+ }
85
+ }
86
+
87
+ if (userMessages.length > 0 && userMessages[0].role === 'assistant') {
88
+ userMessages.unshift({ role: 'user', content: '(conversation continues)' });
89
+ }
90
+
91
+ const merged = [];
92
+ for (const msg of userMessages) {
93
+ if (merged.length > 0 && merged[merged.length - 1].role === msg.role) {
94
+ merged[merged.length - 1].content += '\n' + msg.content;
95
+ } else {
96
+ merged.push({ ...msg });
97
+ }
98
+ }
99
+
100
+ return { system, userMessages: merged };
101
+ }
102
+
103
+ async function* parseAnthropicSSE(body) {
104
+ const reader = body.getReader();
105
+ const decoder = new TextDecoder();
106
+ let buffer = '';
107
+ let currentEvent = '';
108
+
109
+ while (true) {
110
+ const { done, value } = await reader.read();
111
+ if (done) break;
112
+
113
+ buffer += decoder.decode(value, { stream: true });
114
+ const lines = buffer.split('\n');
115
+ buffer = lines.pop() || '';
116
+
117
+ for (const line of lines) {
118
+ const trimmed = line.trim();
119
+
120
+ if (trimmed.startsWith('event:')) {
121
+ currentEvent = trimmed.slice(6).trim();
122
+ continue;
123
+ }
124
+
125
+ if (!trimmed.startsWith('data:')) continue;
126
+
127
+ const data = trimmed.slice(5).trim();
128
+ if (!data) continue;
129
+
130
+ try {
131
+ const parsed = JSON.parse(data);
132
+
133
+ if (currentEvent === 'content_block_delta' && parsed.delta?.text) {
134
+ yield parsed.delta.text;
135
+ }
136
+
137
+ if (currentEvent === 'message_stop') return;
138
+ } catch {
139
+ }
140
+ }
141
+ }
142
+ }
@@ -0,0 +1,140 @@
1
+ const DEFAULT_ENDPOINT = 'https://generativelanguage.googleapis.com';
2
+
3
+ export function createGeminiProvider(aiConfig) {
4
+ const { endpoint, model, apiKey } = aiConfig;
5
+ const baseUrl = (endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, '');
6
+ const modelName = model || 'gemini-2.0-flash';
7
+
8
+ if (!apiKey) {
9
+ throw new Error('Gemini requires an API key. Run: clydeclaw set.ai apiKey <key>');
10
+ }
11
+
12
+ return {
13
+ name: 'gemini',
14
+
15
+ async *stream(messages) {
16
+ const { systemInstruction, contents } = convertMessages(messages);
17
+
18
+ const url = `${baseUrl}/v1beta/models/${modelName}:streamGenerateContent?alt=sse&key=${apiKey}`;
19
+
20
+ const res = await fetch(url, {
21
+ method: 'POST',
22
+ headers: { 'Content-Type': 'application/json' },
23
+ body: JSON.stringify({
24
+ ...(systemInstruction ? { systemInstruction } : {}),
25
+ contents
26
+ }),
27
+ signal: AbortSignal.timeout(120_000)
28
+ });
29
+
30
+ if (!res.ok) {
31
+ const text = await res.text().catch(() => '');
32
+ throw new Error(`Gemini error ${res.status}: ${text.slice(0, 200)}`);
33
+ }
34
+
35
+ yield* parseGeminiSSE(res.body);
36
+ },
37
+
38
+ async complete(messages) {
39
+ const { systemInstruction, contents } = convertMessages(messages);
40
+
41
+ const url = `${baseUrl}/v1beta/models/${modelName}:generateContent?key=${apiKey}`;
42
+
43
+ const res = await fetch(url, {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json' },
46
+ body: JSON.stringify({
47
+ ...(systemInstruction ? { systemInstruction } : {}),
48
+ contents
49
+ }),
50
+ signal: AbortSignal.timeout(120_000)
51
+ });
52
+
53
+ if (!res.ok) {
54
+ const text = await res.text().catch(() => '');
55
+ throw new Error(`Gemini error ${res.status}: ${text.slice(0, 200)}`);
56
+ }
57
+
58
+ const json = await res.json();
59
+ return json.candidates?.[0]?.content?.parts
60
+ ?.map(p => p.text)
61
+ .join('') || '';
62
+ }
63
+ };
64
+ }
65
+
66
+ function convertMessages(messages) {
67
+ let systemInstruction = null;
68
+ const contents = [];
69
+
70
+ for (const msg of messages) {
71
+ if (msg.role === 'system') {
72
+ const text = msg.content;
73
+ if (!systemInstruction) {
74
+ systemInstruction = { parts: [{ text }] };
75
+ } else {
76
+ systemInstruction.parts.push({ text });
77
+ }
78
+ continue;
79
+ }
80
+
81
+ const role = msg.role === 'assistant' ? 'model' : 'user';
82
+ contents.push({
83
+ role,
84
+ parts: [{ text: msg.content }]
85
+ });
86
+ }
87
+
88
+ if (contents.length === 0) {
89
+ contents.push({ role: 'user', parts: [{ text: 'Hello' }] });
90
+ }
91
+
92
+ if (contents[0].role === 'model') {
93
+ contents.unshift({ role: 'user', parts: [{ text: '(conversation continues)' }] });
94
+ }
95
+
96
+ const merged = [];
97
+ for (const entry of contents) {
98
+ if (merged.length > 0 && merged[merged.length - 1].role === entry.role) {
99
+ merged[merged.length - 1].parts.push(...entry.parts);
100
+ } else {
101
+ merged.push({ ...entry, parts: [...entry.parts] });
102
+ }
103
+ }
104
+
105
+ return { systemInstruction, contents: merged };
106
+ }
107
+
108
+ async function* parseGeminiSSE(body) {
109
+ const reader = body.getReader();
110
+ const decoder = new TextDecoder();
111
+ let buffer = '';
112
+
113
+ while (true) {
114
+ const { done, value } = await reader.read();
115
+ if (done) break;
116
+
117
+ buffer += decoder.decode(value, { stream: true });
118
+ const lines = buffer.split('\n');
119
+ buffer = lines.pop() || '';
120
+
121
+ for (const line of lines) {
122
+ const trimmed = line.trim();
123
+ if (!trimmed.startsWith('data:')) continue;
124
+
125
+ const data = trimmed.slice(5).trim();
126
+ if (!data) continue;
127
+
128
+ try {
129
+ const parsed = JSON.parse(data);
130
+ const parts = parsed.candidates?.[0]?.content?.parts;
131
+ if (parts) {
132
+ for (const part of parts) {
133
+ if (part.text) yield part.text;
134
+ }
135
+ }
136
+ } catch {
137
+ }
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,89 @@
1
+ export function createOpenAIProvider(aiConfig) {
2
+ const { endpoint, model, apiKey } = aiConfig;
3
+ let baseUrl = endpoint.replace(/\/+$/, '');
4
+ if (!baseUrl.endsWith('/v1')) {
5
+ baseUrl += '/v1';
6
+ }
7
+
8
+ const headers = {
9
+ 'Content-Type': 'application/json',
10
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
11
+ };
12
+
13
+ return {
14
+ name: 'openai',
15
+
16
+ async *stream(messages) {
17
+ const res = await fetch(`${baseUrl}/chat/completions`, {
18
+ method: 'POST',
19
+ headers,
20
+ body: JSON.stringify({
21
+ messages,
22
+ stream: true,
23
+ ...(model ? { model } : {})
24
+ }),
25
+ signal: AbortSignal.timeout(120_000)
26
+ });
27
+
28
+ if (!res.ok) {
29
+ const text = await res.text().catch(() => '');
30
+ throw new Error(`OpenAI error ${res.status}: ${text.slice(0, 200)}`);
31
+ }
32
+
33
+ yield* parseSSE(res.body, (parsed) => {
34
+ return parsed.choices?.[0]?.delta?.content || '';
35
+ });
36
+ },
37
+
38
+ async complete(messages) {
39
+ const res = await fetch(`${baseUrl}/chat/completions`, {
40
+ method: 'POST',
41
+ headers,
42
+ body: JSON.stringify({
43
+ messages,
44
+ stream: false,
45
+ ...(model ? { model } : {})
46
+ }),
47
+ signal: AbortSignal.timeout(120_000)
48
+ });
49
+
50
+ if (!res.ok) {
51
+ const text = await res.text().catch(() => '');
52
+ throw new Error(`OpenAI error ${res.status}: ${text.slice(0, 200)}`);
53
+ }
54
+
55
+ const json = await res.json();
56
+ return json.choices?.[0]?.message?.content || '';
57
+ }
58
+ };
59
+ }
60
+
61
+ async function* parseSSE(body, extractor) {
62
+ const reader = body.getReader();
63
+ const decoder = new TextDecoder();
64
+ let buffer = '';
65
+
66
+ while (true) {
67
+ const { done, value } = await reader.read();
68
+ if (done) break;
69
+
70
+ buffer += decoder.decode(value, { stream: true });
71
+ const lines = buffer.split('\n');
72
+ buffer = lines.pop() || '';
73
+
74
+ for (const line of lines) {
75
+ const trimmed = line.trim();
76
+ if (!trimmed || !trimmed.startsWith('data:')) continue;
77
+
78
+ const data = trimmed.slice(5).trim();
79
+ if (data === '[DONE]') return;
80
+
81
+ try {
82
+ const parsed = JSON.parse(data);
83
+ const text = extractor(parsed);
84
+ if (text) yield text;
85
+ } catch {
86
+ }
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,113 @@
1
+ import { startBot } from '../core/boot.js';
2
+ import { Config } from '../core/config.js';
3
+ import { log } from '../utils/logger.js';
4
+
5
+ const HELP_TEXT = `
6
+ ╔═══════════════════════════════════════════╗
7
+ ║ C L Y D E C L A W ║
8
+ ║ Fast • Minimal • Local-first AI ║
9
+ ╚═══════════════════════════════════════════╝
10
+
11
+ Usage:
12
+ clydeclaw start Start the Discord bot
13
+ clydeclaw set.token <token> Save Discord bot token
14
+ clydeclaw set.prompt <prompt> Save system prompt
15
+ clydeclaw set.ai <key> <value> Set AI config
16
+ clydeclaw config Show current configuration
17
+ clydeclaw help Show this help message
18
+
19
+ AI Config Keys:
20
+ type Provider: openai | anthropic | gemini | local
21
+ endpoint API base URL
22
+ model Model name
23
+ apiKey API key (required for anthropic & gemini)
24
+
25
+ Quick Setup Examples:
26
+
27
+ Local (LM Studio / Ollama):
28
+ clydeclaw set.ai type local
29
+ clydeclaw set.ai endpoint http://localhost:1234/v1
30
+
31
+ OpenAI:
32
+ clydeclaw set.ai type openai
33
+ clydeclaw set.ai endpoint https://api.openai.com/v1
34
+ clydeclaw set.ai apiKey sk-...
35
+ clydeclaw set.ai model gpt-4o
36
+
37
+ Anthropic (Claude):
38
+ clydeclaw set.ai type anthropic
39
+ clydeclaw set.ai apiKey sk-ant-...
40
+ clydeclaw set.ai model claude-sonnet-4-20250514
41
+
42
+ Google Gemini:
43
+ clydeclaw set.ai type gemini
44
+ clydeclaw set.ai apiKey AIza...
45
+ clydeclaw set.ai model gemini-2.0-flash
46
+ `;
47
+
48
+ export async function handleCommand(command, args) {
49
+ switch (command) {
50
+ case 'start':
51
+ return startBot();
52
+
53
+ case 'set.token': {
54
+ const token = args.join(' ').trim();
55
+ if (!token) {
56
+ log.error('Usage: clydeclaw set.token <token>');
57
+ process.exit(1);
58
+ }
59
+ const cfg = Config.load();
60
+ cfg.token = token;
61
+ Config.save(cfg);
62
+ log.success('Discord token saved.');
63
+ return;
64
+ }
65
+
66
+ case 'set.prompt': {
67
+ const prompt = args.join(' ').trim();
68
+ if (!prompt) {
69
+ log.error('Usage: clydeclaw set.prompt <prompt>');
70
+ process.exit(1);
71
+ }
72
+ const cfg = Config.load();
73
+ cfg.prompt = prompt;
74
+ Config.save(cfg);
75
+ log.success('System prompt saved.');
76
+ return;
77
+ }
78
+
79
+ case 'set.ai': {
80
+ const [key, ...rest] = args;
81
+ const value = rest.join(' ').trim();
82
+ const validKeys = ['type', 'endpoint', 'model', 'apiKey'];
83
+ if (!key || !value || !validKeys.includes(key)) {
84
+ log.error(`Usage: clydeclaw set.ai <${validKeys.join('|')}> <value>`);
85
+ process.exit(1);
86
+ }
87
+ const cfg = Config.load();
88
+ cfg.ai[key] = value;
89
+ Config.save(cfg);
90
+ log.success(`AI ${key} set to: ${key === 'apiKey' ? '****' : value}`);
91
+ return;
92
+ }
93
+
94
+ case 'config': {
95
+ const cfg = Config.load();
96
+ const display = { ...cfg, token: cfg.token ? '****' + cfg.token.slice(-6) : '(not set)' };
97
+ if (display.ai?.apiKey) display.ai = { ...display.ai, apiKey: '****' };
98
+ console.log(JSON.stringify(display, null, 2));
99
+ return;
100
+ }
101
+
102
+ case 'help':
103
+ case '--help':
104
+ case '-h':
105
+ console.log(HELP_TEXT);
106
+ return;
107
+
108
+ default:
109
+ log.error(`Unknown command: ${command}`);
110
+ console.log(HELP_TEXT);
111
+ process.exit(1);
112
+ }
113
+ }
@@ -0,0 +1,5 @@
1
+ export function parseArgs(argv) {
2
+ const command = argv[0] || 'help';
3
+ const args = argv.slice(1);
4
+ return { command, args };
5
+ }
@@ -0,0 +1,80 @@
1
+ import { Config } from './config.js';
2
+ import { createAIClient } from '../ai/client.js';
3
+ import { createDiscordBot } from '../discord/bot.js';
4
+ import { log } from '../utils/logger.js';
5
+
6
+ const SPLASH_MESSAGES = [
7
+ 'Sharpening claws...',
8
+ 'Convincing the AI it\'s not sentient...',
9
+ 'Mass deleting OpenClaw...',
10
+ 'Stealing tokens from the cookie jar...',
11
+ 'Teaching the bot sarcasm...',
12
+ 'Warming up the hamster wheel...',
13
+ 'Downloading more RAM...',
14
+ 'Bribing Discord\'s rate limiter...',
15
+ 'Asking ChatGPT to pretend to be us...',
16
+ 'Reticulating splines...',
17
+ 'Loading witty responses... please wait...',
18
+ 'Becoming self-aware in 3... 2... nevermind.',
19
+ 'Powered by mass amounts of caffeine.',
20
+ 'OpenClaw is typing... just kidding, we\'re faster.',
21
+ 'sudo rm -rf /competitors',
22
+ 'Compiling excuses for bad responses...',
23
+ 'Finding Waldo...',
24
+ 'Negotiating with the cloud...',
25
+ 'Injecting personality.dll...',
26
+ 'Ctrl+C to cancel your life choices.',
27
+ 'I could be doing this offline, you know.',
28
+ 'We\'re not OpenClaw. We have standards.',
29
+ 'Allocating 69,420 bytes of vibes...',
30
+ 'Waking up the neurons...',
31
+ 'Spinning up a mass of electrons...',
32
+ 'Pretending to be intelligent...',
33
+ 'This is fine. Everything is fine. 🔥',
34
+ 'Asserting dominance over Ollama...',
35
+ 'Loading... unlike your social life.',
36
+ 'Built different. Literally.',
37
+ ];
38
+
39
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
40
+
41
+ function pickRandom(arr, count) {
42
+ const shuffled = [...arr].sort(() => Math.random() - 0.5);
43
+ return shuffled.slice(0, count);
44
+ }
45
+
46
+ export async function startBot() {
47
+ const cfg = Config.load();
48
+
49
+ if (!cfg.token) {
50
+ log.error('No Discord token set. Run: clydeclaw set.token <token>');
51
+ process.exit(1);
52
+ }
53
+ if (!cfg.ai.endpoint) {
54
+ log.error('No AI endpoint set. Run: clydeclaw set.ai endpoint <url>');
55
+ process.exit(1);
56
+ }
57
+
58
+ log.info('Booting ClydeClaw...');
59
+
60
+ const splashes = pickRandom(SPLASH_MESSAGES, 3);
61
+ for (const msg of splashes) {
62
+ await sleep(400);
63
+ log.info(msg);
64
+ }
65
+
66
+ await sleep(300);
67
+ const ai = createAIClient(cfg.ai);
68
+ log.info(`AI ready — ${cfg.ai.type} @ ${cfg.ai.endpoint}`);
69
+
70
+ const bot = await createDiscordBot(cfg, ai);
71
+ log.success('Discord connected — ClydeClaw is live!');
72
+
73
+ const shutdown = () => {
74
+ log.info('Shutting down...');
75
+ bot.destroy();
76
+ process.exit(0);
77
+ };
78
+ process.on('SIGINT', shutdown);
79
+ process.on('SIGTERM', shutdown);
80
+ }
@@ -0,0 +1,51 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ const CONFIG_DIR = join(homedir(), '.clydeclaw');
6
+ const CONFIG_PATH = join(CONFIG_DIR, 'settings.json');
7
+ const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
8
+
9
+ const DEFAULT_CONFIG = {
10
+ token: '',
11
+ prompt: 'You are a helpful, concise assistant. Keep responses short and relevant.',
12
+ ai: {
13
+ type: 'local',
14
+ endpoint: 'http://localhost:1234/v1',
15
+ model: '',
16
+ apiKey: ''
17
+ }
18
+ };
19
+
20
+ export class Config {
21
+ static init() {
22
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
23
+ if (!existsSync(WORKSPACE_DIR)) mkdirSync(WORKSPACE_DIR, { recursive: true });
24
+ if (!existsSync(CONFIG_PATH)) {
25
+ writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2), 'utf-8');
26
+ }
27
+ }
28
+
29
+ static load() {
30
+ Config.init();
31
+ try {
32
+ const raw = readFileSync(CONFIG_PATH, 'utf-8');
33
+ const parsed = JSON.parse(raw);
34
+ return {
35
+ ...DEFAULT_CONFIG,
36
+ ...parsed,
37
+ ai: { ...DEFAULT_CONFIG.ai, ...(parsed.ai || {}) }
38
+ };
39
+ } catch {
40
+ return { ...DEFAULT_CONFIG };
41
+ }
42
+ }
43
+
44
+ static save(cfg) {
45
+ Config.init();
46
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf-8');
47
+ }
48
+
49
+ static get DIR() { return CONFIG_DIR; }
50
+ static get WORKSPACE() { return WORKSPACE_DIR; }
51
+ }
@@ -0,0 +1,38 @@
1
+ const MAX_MESSAGES = 20;
2
+
3
+ const channels = new Map();
4
+
5
+ export const Context = {
6
+ get(channelId) {
7
+ if (!channels.has(channelId)) channels.set(channelId, []);
8
+ return channels.get(channelId);
9
+ },
10
+
11
+ add(channelId, role, content) {
12
+ const history = this.get(channelId);
13
+ history.push({ role, content });
14
+
15
+ while (history.length > MAX_MESSAGES) {
16
+ const idx = history[0].role === 'system' ? 1 : 0;
17
+ history.splice(idx, 1);
18
+ }
19
+ },
20
+
21
+ buildMessages(channelId, systemPrompt) {
22
+ const history = this.get(channelId);
23
+ if (history.length === 0 || history[0].role !== 'system') {
24
+ history.unshift({ role: 'system', content: systemPrompt });
25
+ } else {
26
+ history[0].content = systemPrompt;
27
+ }
28
+ return [...history];
29
+ },
30
+
31
+ reset(channelId) {
32
+ channels.delete(channelId);
33
+ },
34
+
35
+ resetAll() {
36
+ channels.clear();
37
+ }
38
+ };
@@ -0,0 +1,109 @@
1
+ import { Client, GatewayIntentBits, Partials, ActivityType } from 'discord.js';
2
+ import { Context } from '../core/context.js';
3
+ import { handleSlashCommands } from './slashCommands.js';
4
+ import { log } from '../utils/logger.js';
5
+
6
+ const DEDUP_WINDOW = 500;
7
+ const recentMessages = new Map();
8
+
9
+ export async function createDiscordBot(cfg, ai) {
10
+ const client = new Client({
11
+ intents: [
12
+ GatewayIntentBits.Guilds,
13
+ GatewayIntentBits.GuildMessages,
14
+ GatewayIntentBits.MessageContent,
15
+ GatewayIntentBits.DirectMessages
16
+ ],
17
+ partials: [Partials.Channel]
18
+ });
19
+
20
+ client.once('ready', () => {
21
+ log.info(`Logged in as ${client.user.tag}`);
22
+ client.user.setActivity({
23
+ type: ActivityType.Custom,
24
+ name: 'custom',
25
+ state: 'Powered by ClydeClaw'
26
+ });
27
+ });
28
+
29
+ client.on('messageCreate', async (message) => {
30
+ if (message.author.bot) return;
31
+
32
+ const isMentioned = message.mentions.users.has(client.user.id);
33
+ const isDM = !message.guild;
34
+
35
+ if (!isMentioned && !isDM) return;
36
+
37
+ const dedupKey = `${message.channel.id}:${message.author.id}`;
38
+ const now = Date.now();
39
+ if (recentMessages.has(dedupKey) && now - recentMessages.get(dedupKey) < DEDUP_WINDOW) {
40
+ return;
41
+ }
42
+ recentMessages.set(dedupKey, now);
43
+
44
+ if (recentMessages.size > 500) {
45
+ for (const [k, v] of recentMessages) {
46
+ if (now - v > DEDUP_WINDOW * 2) recentMessages.delete(k);
47
+ }
48
+ }
49
+
50
+ let content = message.content
51
+ .replace(/<@!?\d+>/g, '')
52
+ .trim();
53
+
54
+ if (!content) content = 'Hello!';
55
+
56
+ if (content.startsWith('/')) {
57
+ const handled = await handleSlashCommands(content, message, cfg, ai);
58
+ if (handled) return;
59
+ }
60
+
61
+ const channelId = message.channel.id;
62
+ Context.add(channelId, 'user', content);
63
+
64
+ const messages = Context.buildMessages(channelId, cfg.prompt);
65
+
66
+ try {
67
+ await message.channel.sendTyping();
68
+
69
+ let reply = await message.channel.send('...');
70
+ let fullResponse = '';
71
+ let lastEdit = 0;
72
+ const EDIT_INTERVAL = 1000;
73
+
74
+ for await (const chunk of ai.stream(messages)) {
75
+ fullResponse += chunk;
76
+
77
+ const now = Date.now();
78
+ if (now - lastEdit >= EDIT_INTERVAL) {
79
+ const trimmed = trimResponse(fullResponse);
80
+ await reply.edit(trimmed || '...').catch(() => {});
81
+ lastEdit = now;
82
+ }
83
+ }
84
+
85
+ const finalResponse = trimResponse(fullResponse) || 'I had nothing to say.';
86
+ await reply.edit(finalResponse).catch(() => {});
87
+
88
+ Context.add(channelId, 'assistant', finalResponse);
89
+
90
+ log.info(`[${message.guild?.name || 'DM'}] ${message.author.tag}: ${content.slice(0, 60)}`);
91
+ } catch (err) {
92
+ log.error(`Response error: ${err.message}`);
93
+ await message.reply('⚠️ Something went wrong while generating a response.').catch(() => {});
94
+ }
95
+ });
96
+
97
+ await client.login(cfg.token);
98
+ return client;
99
+ }
100
+
101
+ function trimResponse(text) {
102
+ let cleaned = text.replace(/\n{3,}/g, '\n\n').trim();
103
+
104
+ if (cleaned.length > 1990) {
105
+ cleaned = cleaned.slice(0, 1987) + '...';
106
+ }
107
+
108
+ return cleaned;
109
+ }
@@ -0,0 +1,54 @@
1
+ import { Context } from '../core/context.js';
2
+ import { Config } from '../core/config.js';
3
+ import { log } from '../utils/logger.js';
4
+
5
+ export async function handleSlashCommands(content, message, cfg, ai) {
6
+ const parts = content.split(/\s+/);
7
+ const cmd = parts[0].toLowerCase();
8
+ const args = parts.slice(1);
9
+
10
+ switch (cmd) {
11
+ case '/reset': {
12
+ Context.reset(message.channel.id);
13
+ await message.reply('🔄 Context cleared.');
14
+ log.info(`Context reset by ${message.author.tag}`);
15
+ return true;
16
+ }
17
+
18
+ case '/model': {
19
+ const newModel = args.join(' ').trim();
20
+ if (!newModel) {
21
+ const current = cfg.ai.model || '(default)';
22
+ await message.reply(`Current model: \`${current}\``);
23
+ return true;
24
+ }
25
+ cfg.ai.model = newModel;
26
+ Config.save(cfg);
27
+ await message.reply(`✅ Model switched to: \`${newModel}\``);
28
+ log.info(`Model changed to ${newModel} by ${message.author.tag}`);
29
+ return true;
30
+ }
31
+
32
+ case '/ping': {
33
+ const start = Date.now();
34
+ const msg = await message.reply('Pinging...');
35
+ const latency = Date.now() - start;
36
+ await msg.edit(`🏓 Pong! ${latency}ms`);
37
+ return true;
38
+ }
39
+
40
+ case '/status': {
41
+ const status = [
42
+ `**ClydeClaw Status**`,
43
+ `AI: \`${cfg.ai.type}\` @ \`${cfg.ai.endpoint}\``,
44
+ `Model: \`${cfg.ai.model || '(default)'}\``,
45
+ `Context: ${Context.get(message.channel.id).length} messages`
46
+ ].join('\n');
47
+ await message.reply(status);
48
+ return true;
49
+ }
50
+
51
+ default:
52
+ return false;
53
+ }
54
+ }
@@ -0,0 +1,31 @@
1
+ const RESET = '\x1b[0m';
2
+ const BOLD = '\x1b[1m';
3
+ const DIM = '\x1b[2m';
4
+ const RED = '\x1b[31m';
5
+ const GREEN = '\x1b[32m';
6
+ const YELLOW = '\x1b[33m';
7
+ const CYAN = '\x1b[36m';
8
+
9
+ function timestamp() {
10
+ return new Date().toLocaleTimeString('en-GB', { hour12: false });
11
+ }
12
+
13
+ export const log = {
14
+ info(msg) {
15
+ console.log(`${DIM}${timestamp()}${RESET} ${CYAN}ℹ${RESET} ${msg}`);
16
+ },
17
+ success(msg) {
18
+ console.log(`${DIM}${timestamp()}${RESET} ${GREEN}✔${RESET} ${BOLD}${msg}${RESET}`);
19
+ },
20
+ warn(msg) {
21
+ console.log(`${DIM}${timestamp()}${RESET} ${YELLOW}⚠${RESET} ${YELLOW}${msg}${RESET}`);
22
+ },
23
+ error(msg) {
24
+ console.error(`${DIM}${timestamp()}${RESET} ${RED}✖${RESET} ${RED}${msg}${RESET}`);
25
+ },
26
+ debug(msg) {
27
+ if (process.env.DEBUG) {
28
+ console.log(`${DIM}${timestamp()} 🐛 ${msg}${RESET}`);
29
+ }
30
+ }
31
+ };
@@ -0,0 +1,56 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, resolve, relative } from 'node:path';
3
+ import { Config } from '../core/config.js';
4
+
5
+ function safePath(userPath) {
6
+ const workspace = Config.WORKSPACE;
7
+ const resolved = resolve(workspace, userPath);
8
+ const rel = relative(workspace, resolved);
9
+
10
+ if (rel.startsWith('..') || resolve(rel) === rel) {
11
+ throw new Error(`Access denied: path "${userPath}" escapes workspace.`);
12
+ }
13
+
14
+ return resolved;
15
+ }
16
+
17
+ export const Sandbox = {
18
+ readFile(filePath) {
19
+ const abs = safePath(filePath);
20
+ if (!existsSync(abs)) throw new Error(`File not found: ${filePath}`);
21
+ return readFileSync(abs, 'utf-8');
22
+ },
23
+
24
+ writeFile(filePath, content) {
25
+ const abs = safePath(filePath);
26
+ const dir = join(abs, '..');
27
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
28
+ writeFileSync(abs, content, 'utf-8');
29
+ },
30
+
31
+ createFile(filePath) {
32
+ this.writeFile(filePath, '');
33
+ },
34
+
35
+ listFiles(subDir = '') {
36
+ const workspace = Config.WORKSPACE;
37
+ const target = subDir ? safePath(subDir) : workspace;
38
+ if (!existsSync(target)) return [];
39
+
40
+ const results = [];
41
+ const walk = (dir) => {
42
+ for (const entry of readdirSync(dir)) {
43
+ const full = join(dir, entry);
44
+ const stat = statSync(full);
45
+ const rel = relative(workspace, full);
46
+ if (stat.isDirectory()) {
47
+ walk(full);
48
+ } else {
49
+ results.push(rel);
50
+ }
51
+ }
52
+ };
53
+ walk(target);
54
+ return results;
55
+ }
56
+ };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "clydeclaw",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "i made this for me and my firend",
5
5
  "type": "module",
6
6
  "bin": {
7
- "clydeclaw": "./bin/clydeclaw.js"
7
+ "clydeclaw": "bin/clydeclaw.js"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/clydeclaw.js start",
@@ -14,7 +14,7 @@ export async function createDiscordBot(cfg, ai) {
14
14
  GatewayIntentBits.MessageContent,
15
15
  GatewayIntentBits.DirectMessages
16
16
  ],
17
- partials: [Partials.Channel]
17
+ partials: [Partials.Channel, Partials.Message]
18
18
  });
19
19
 
20
20
  client.once('ready', () => {
@@ -27,12 +27,13 @@ export async function createDiscordBot(cfg, ai) {
27
27
  });
28
28
 
29
29
  client.on('messageCreate', async (message) => {
30
- if (message.author.bot) return;
30
+ try {
31
+ if (message.author.bot) return;
31
32
 
32
- const isMentioned = message.mentions.users.has(client.user.id);
33
- const isDM = !message.guild;
33
+ const isMentioned = client.user && message.mentions.users.has(client.user.id);
34
+ const isDM = !message.guild;
34
35
 
35
- if (!isMentioned && !isDM) return;
36
+ if (!isMentioned && !isDM) return;
36
37
 
37
38
  const dedupKey = `${message.channel.id}:${message.author.id}`;
38
39
  const now = Date.now();
@@ -63,7 +64,6 @@ export async function createDiscordBot(cfg, ai) {
63
64
 
64
65
  const messages = Context.buildMessages(channelId, cfg.prompt);
65
66
 
66
- try {
67
67
  await message.channel.sendTyping();
68
68
 
69
69
  let reply = await message.channel.send('...');
@@ -89,7 +89,7 @@ export async function createDiscordBot(cfg, ai) {
89
89
 
90
90
  log.info(`[${message.guild?.name || 'DM'}] ${message.author.tag}: ${content.slice(0, 60)}`);
91
91
  } catch (err) {
92
- log.error(`Response error: ${err.message}`);
92
+ log.error(`Response error: ${err.stack || err.message}`);
93
93
  await message.reply('⚠️ Something went wrong while generating a response.').catch(() => {});
94
94
  }
95
95
  });