agentcord 0.1.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.
- package/.env.example +23 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/bin/agentcord.js +21 -0
- package/package.json +60 -0
- package/src/agents.ts +90 -0
- package/src/bot.ts +193 -0
- package/src/button-handler.ts +153 -0
- package/src/cli.ts +50 -0
- package/src/command-handlers.ts +623 -0
- package/src/commands.ts +166 -0
- package/src/config.ts +45 -0
- package/src/index.ts +18 -0
- package/src/message-handler.ts +60 -0
- package/src/output-handler.ts +515 -0
- package/src/persistence.ts +33 -0
- package/src/project-manager.ts +165 -0
- package/src/session-manager.ts +407 -0
- package/src/setup.ts +381 -0
- package/src/shell-handler.ts +91 -0
- package/src/types.ts +80 -0
- package/src/utils.ts +112 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EmbedBuilder,
|
|
3
|
+
ActionRowBuilder,
|
|
4
|
+
ButtonBuilder,
|
|
5
|
+
ButtonStyle,
|
|
6
|
+
StringSelectMenuBuilder,
|
|
7
|
+
type TextChannel,
|
|
8
|
+
type Message,
|
|
9
|
+
} from 'discord.js';
|
|
10
|
+
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
11
|
+
import { splitMessage, truncate, detectNumberedOptions, detectYesNoPrompt } from './utils.ts';
|
|
12
|
+
import type { ExpandableContent } from './types.ts';
|
|
13
|
+
|
|
14
|
+
// In-memory store for expandable content (with TTL cleanup)
|
|
15
|
+
const expandableStore = new Map<string, ExpandableContent>();
|
|
16
|
+
let expandCounter = 0;
|
|
17
|
+
|
|
18
|
+
// Clean up expired expandable content every 5 minutes
|
|
19
|
+
setInterval(() => {
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const TTL = 10 * 60 * 1000; // 10 minutes
|
|
22
|
+
for (const [key, val] of expandableStore) {
|
|
23
|
+
if (now - val.createdAt > TTL) expandableStore.delete(key);
|
|
24
|
+
}
|
|
25
|
+
}, 5 * 60 * 1000);
|
|
26
|
+
|
|
27
|
+
export function getExpandableContent(id: string): string | undefined {
|
|
28
|
+
return expandableStore.get(id)?.content;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function storeExpandable(content: string): string {
|
|
32
|
+
const id = `exp_${++expandCounter}`;
|
|
33
|
+
expandableStore.set(id, { content, createdAt: Date.now() });
|
|
34
|
+
return id;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeStopButton(sessionId: string): ActionRowBuilder<ButtonBuilder> {
|
|
38
|
+
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
39
|
+
new ButtonBuilder()
|
|
40
|
+
.setCustomId(`stop:${sessionId}`)
|
|
41
|
+
.setLabel('Stop')
|
|
42
|
+
.setStyle(ButtonStyle.Danger),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeCompletionButtons(sessionId: string): ActionRowBuilder<ButtonBuilder> {
|
|
47
|
+
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
48
|
+
new ButtonBuilder()
|
|
49
|
+
.setCustomId(`continue:${sessionId}`)
|
|
50
|
+
.setLabel('Continue')
|
|
51
|
+
.setStyle(ButtonStyle.Primary),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeOptionButtons(sessionId: string, options: string[]): ActionRowBuilder<ButtonBuilder>[] {
|
|
56
|
+
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
|
57
|
+
const maxOptions = Math.min(options.length, 10);
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < maxOptions; i += 5) {
|
|
60
|
+
const row = new ActionRowBuilder<ButtonBuilder>();
|
|
61
|
+
const chunk = options.slice(i, i + 5);
|
|
62
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
63
|
+
row.addComponents(
|
|
64
|
+
new ButtonBuilder()
|
|
65
|
+
.setCustomId(`option:${sessionId}:${i + j}`)
|
|
66
|
+
.setLabel(truncate(chunk[j], 80))
|
|
67
|
+
.setStyle(ButtonStyle.Secondary),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
rows.push(row);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function makeYesNoButtons(sessionId: string): ActionRowBuilder<ButtonBuilder> {
|
|
77
|
+
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
78
|
+
new ButtonBuilder()
|
|
79
|
+
.setCustomId(`confirm:${sessionId}:yes`)
|
|
80
|
+
.setLabel('Yes')
|
|
81
|
+
.setStyle(ButtonStyle.Success),
|
|
82
|
+
new ButtonBuilder()
|
|
83
|
+
.setCustomId(`confirm:${sessionId}:no`)
|
|
84
|
+
.setLabel('No')
|
|
85
|
+
.setStyle(ButtonStyle.Danger),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Serialized message editor — ensures only one Discord API call is in-flight
|
|
91
|
+
* at a time, preventing duplicate messages from race conditions.
|
|
92
|
+
*/
|
|
93
|
+
class MessageStreamer {
|
|
94
|
+
private channel: TextChannel;
|
|
95
|
+
private sessionId: string;
|
|
96
|
+
private currentMessage: Message | null = null;
|
|
97
|
+
private currentText = '';
|
|
98
|
+
private dirty = false;
|
|
99
|
+
private flushing = false;
|
|
100
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
101
|
+
private readonly INTERVAL = 400; // ms between edits
|
|
102
|
+
|
|
103
|
+
constructor(channel: TextChannel, sessionId: string) {
|
|
104
|
+
this.channel = channel;
|
|
105
|
+
this.sessionId = sessionId;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
append(text: string): void {
|
|
109
|
+
this.currentText += text;
|
|
110
|
+
this.dirty = true;
|
|
111
|
+
this.scheduleFlush();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private scheduleFlush(): void {
|
|
115
|
+
if (this.timer || this.flushing) return;
|
|
116
|
+
this.timer = setTimeout(() => {
|
|
117
|
+
this.timer = null;
|
|
118
|
+
this.flush();
|
|
119
|
+
}, this.INTERVAL);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async flush(): Promise<void> {
|
|
123
|
+
if (this.flushing || !this.dirty) return;
|
|
124
|
+
this.flushing = true;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
// Snapshot what we need to send
|
|
128
|
+
const text = this.currentText;
|
|
129
|
+
this.dirty = false;
|
|
130
|
+
|
|
131
|
+
const chunks = splitMessage(text);
|
|
132
|
+
const lastChunk = chunks[chunks.length - 1];
|
|
133
|
+
|
|
134
|
+
// If text overflows into multiple chunks, finalize earlier ones
|
|
135
|
+
if (chunks.length > 1 && this.currentMessage) {
|
|
136
|
+
try {
|
|
137
|
+
await this.currentMessage.edit({ content: chunks[0], components: [] });
|
|
138
|
+
} catch { /* deleted */ }
|
|
139
|
+
this.currentMessage = null;
|
|
140
|
+
|
|
141
|
+
for (let i = 1; i < chunks.length - 1; i++) {
|
|
142
|
+
await this.channel.send(chunks[i]);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Edit or create the live message with the last chunk
|
|
147
|
+
if (this.currentMessage) {
|
|
148
|
+
try {
|
|
149
|
+
await this.currentMessage.edit({
|
|
150
|
+
content: lastChunk,
|
|
151
|
+
components: [makeStopButton(this.sessionId)],
|
|
152
|
+
});
|
|
153
|
+
} catch { /* deleted */ }
|
|
154
|
+
} else {
|
|
155
|
+
this.currentMessage = await this.channel.send({
|
|
156
|
+
content: lastChunk,
|
|
157
|
+
components: [makeStopButton(this.sessionId)],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
this.flushing = false;
|
|
162
|
+
// If more text arrived while we were flushing, schedule again
|
|
163
|
+
if (this.dirty) {
|
|
164
|
+
this.scheduleFlush();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Flush remaining text and remove the stop button */
|
|
170
|
+
async finalize(): Promise<void> {
|
|
171
|
+
if (this.timer) {
|
|
172
|
+
clearTimeout(this.timer);
|
|
173
|
+
this.timer = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Wait for any in-flight flush to finish
|
|
177
|
+
while (this.flushing) {
|
|
178
|
+
await new Promise(r => setTimeout(r, 50));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Do a final flush if there's pending text
|
|
182
|
+
if (this.dirty) {
|
|
183
|
+
this.dirty = false;
|
|
184
|
+
const text = this.currentText;
|
|
185
|
+
const chunks = splitMessage(text);
|
|
186
|
+
const lastChunk = chunks[chunks.length - 1];
|
|
187
|
+
|
|
188
|
+
if (chunks.length > 1 && this.currentMessage) {
|
|
189
|
+
try {
|
|
190
|
+
await this.currentMessage.edit({ content: chunks[0], components: [] });
|
|
191
|
+
} catch { /* deleted */ }
|
|
192
|
+
this.currentMessage = null;
|
|
193
|
+
for (let i = 1; i < chunks.length - 1; i++) {
|
|
194
|
+
await this.channel.send(chunks[i]);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (this.currentMessage) {
|
|
199
|
+
try {
|
|
200
|
+
await this.currentMessage.edit({ content: lastChunk, components: [] });
|
|
201
|
+
} catch { /* deleted */ }
|
|
202
|
+
} else if (lastChunk) {
|
|
203
|
+
this.currentMessage = await this.channel.send({ content: lastChunk });
|
|
204
|
+
}
|
|
205
|
+
} else if (this.currentMessage) {
|
|
206
|
+
// Just remove the stop button
|
|
207
|
+
try {
|
|
208
|
+
await this.currentMessage.edit({
|
|
209
|
+
content: this.currentMessage.content || '',
|
|
210
|
+
components: [],
|
|
211
|
+
});
|
|
212
|
+
} catch { /* deleted */ }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this.currentMessage = null;
|
|
216
|
+
this.currentText = '';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
getText(): string {
|
|
220
|
+
return this.currentText;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
destroy(): void {
|
|
224
|
+
if (this.timer) {
|
|
225
|
+
clearTimeout(this.timer);
|
|
226
|
+
this.timer = null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Tools that ask for user input — always shown regardless of verbose mode
|
|
232
|
+
const USER_FACING_TOOLS = new Set([
|
|
233
|
+
'AskUserQuestion',
|
|
234
|
+
'EnterPlanMode',
|
|
235
|
+
'ExitPlanMode',
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
// Task management tools — rendered as a visual board
|
|
239
|
+
const TASK_TOOLS = new Set([
|
|
240
|
+
'TaskCreate',
|
|
241
|
+
'TaskUpdate',
|
|
242
|
+
'TaskList',
|
|
243
|
+
'TaskGet',
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
const STATUS_EMOJI: Record<string, string> = {
|
|
247
|
+
pending: '\u2B1C', // white square
|
|
248
|
+
in_progress: '\uD83D\uDD04', // arrows
|
|
249
|
+
completed: '\u2705', // check
|
|
250
|
+
deleted: '\uD83D\uDDD1\uFE0F', // wastebasket
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
function renderTaskToolEmbed(toolName: string, toolInput: string): EmbedBuilder | null {
|
|
254
|
+
try {
|
|
255
|
+
const data = JSON.parse(toolInput);
|
|
256
|
+
|
|
257
|
+
if (toolName === 'TaskCreate') {
|
|
258
|
+
const embed = new EmbedBuilder()
|
|
259
|
+
.setColor(0x3498db)
|
|
260
|
+
.setTitle('\uD83D\uDCCB New Task')
|
|
261
|
+
.setDescription(`**${data.subject || 'Untitled'}**`);
|
|
262
|
+
if (data.description) {
|
|
263
|
+
embed.addFields({ name: 'Details', value: truncate(data.description, 300) });
|
|
264
|
+
}
|
|
265
|
+
return embed;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (toolName === 'TaskUpdate') {
|
|
269
|
+
const emoji = STATUS_EMOJI[data.status] || '\uD83D\uDCCB';
|
|
270
|
+
const parts: string[] = [];
|
|
271
|
+
if (data.status) parts.push(`${emoji} **${data.status}**`);
|
|
272
|
+
if (data.subject) parts.push(data.subject);
|
|
273
|
+
return new EmbedBuilder()
|
|
274
|
+
.setColor(data.status === 'completed' ? 0x2ecc71 : 0xf39c12)
|
|
275
|
+
.setTitle(`Task #${data.taskId || '?'} Updated`)
|
|
276
|
+
.setDescription(parts.join(' — ') || 'Updated');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return null;
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function renderTaskListEmbed(resultText: string): EmbedBuilder | null {
|
|
286
|
+
if (!resultText.trim()) return null;
|
|
287
|
+
|
|
288
|
+
// Replace status keywords with emojis for visual clarity
|
|
289
|
+
let formatted = resultText;
|
|
290
|
+
for (const [status, emoji] of Object.entries(STATUS_EMOJI)) {
|
|
291
|
+
formatted = formatted.replaceAll(status, `${emoji} ${status}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return new EmbedBuilder()
|
|
295
|
+
.setColor(0x9b59b6)
|
|
296
|
+
.setTitle('\uD83D\uDCCB Task Board')
|
|
297
|
+
.setDescription(truncate(formatted, 4000));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function handleOutputStream(
|
|
301
|
+
stream: AsyncGenerator<SDKMessage>,
|
|
302
|
+
channel: TextChannel,
|
|
303
|
+
sessionId: string,
|
|
304
|
+
verbose = false,
|
|
305
|
+
): Promise<void> {
|
|
306
|
+
const streamer = new MessageStreamer(channel, sessionId);
|
|
307
|
+
let currentToolName: string | null = null;
|
|
308
|
+
let currentToolInput = '';
|
|
309
|
+
let lastFinishedToolName: string | null = null;
|
|
310
|
+
|
|
311
|
+
// Show "typing..." indicator while the agent is working
|
|
312
|
+
channel.sendTyping().catch(() => {});
|
|
313
|
+
const typingInterval = setInterval(() => {
|
|
314
|
+
channel.sendTyping().catch(() => {});
|
|
315
|
+
}, 8000);
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
for await (const message of stream) {
|
|
319
|
+
if (message.type === 'stream_event') {
|
|
320
|
+
const event = (message as any).event;
|
|
321
|
+
|
|
322
|
+
if (event?.type === 'content_block_start') {
|
|
323
|
+
if (event.content_block?.type === 'tool_use') {
|
|
324
|
+
await streamer.finalize();
|
|
325
|
+
currentToolName = event.content_block.name || 'tool';
|
|
326
|
+
currentToolInput = '';
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (event?.type === 'content_block_delta') {
|
|
331
|
+
if (event.delta?.type === 'text_delta' && event.delta.text) {
|
|
332
|
+
streamer.append(event.delta.text);
|
|
333
|
+
}
|
|
334
|
+
if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
|
|
335
|
+
currentToolInput += event.delta.partial_json;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (event?.type === 'content_block_stop') {
|
|
340
|
+
if (currentToolName) {
|
|
341
|
+
const isUserFacing = USER_FACING_TOOLS.has(currentToolName);
|
|
342
|
+
const isTaskTool = TASK_TOOLS.has(currentToolName);
|
|
343
|
+
const showTool = verbose || isUserFacing || isTaskTool;
|
|
344
|
+
|
|
345
|
+
if (showTool) {
|
|
346
|
+
// Task tools get a special visual render
|
|
347
|
+
const taskEmbed = isTaskTool
|
|
348
|
+
? renderTaskToolEmbed(currentToolName, currentToolInput)
|
|
349
|
+
: null;
|
|
350
|
+
|
|
351
|
+
if (taskEmbed) {
|
|
352
|
+
await channel.send({
|
|
353
|
+
embeds: [taskEmbed],
|
|
354
|
+
components: [makeStopButton(sessionId)],
|
|
355
|
+
});
|
|
356
|
+
} else if (!isTaskTool) {
|
|
357
|
+
// Regular tool or user-facing tool — show raw JSON
|
|
358
|
+
const toolInput = currentToolInput;
|
|
359
|
+
const displayInput = toolInput.length > 1000
|
|
360
|
+
? truncate(toolInput, 1000)
|
|
361
|
+
: toolInput;
|
|
362
|
+
|
|
363
|
+
const embed = new EmbedBuilder()
|
|
364
|
+
.setColor(isUserFacing ? 0xf39c12 : 0x3498db)
|
|
365
|
+
.setTitle(isUserFacing
|
|
366
|
+
? `Waiting for input: ${currentToolName}`
|
|
367
|
+
: `Tool: ${currentToolName}`)
|
|
368
|
+
.setDescription(`\`\`\`json\n${displayInput}\n\`\`\``);
|
|
369
|
+
|
|
370
|
+
const components: ActionRowBuilder<ButtonBuilder>[] = [makeStopButton(sessionId)];
|
|
371
|
+
|
|
372
|
+
if (toolInput.length > 1000) {
|
|
373
|
+
const contentId = storeExpandable(toolInput);
|
|
374
|
+
components.unshift(
|
|
375
|
+
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
376
|
+
new ButtonBuilder()
|
|
377
|
+
.setCustomId(`expand:${contentId}`)
|
|
378
|
+
.setLabel('Show Full Input')
|
|
379
|
+
.setStyle(ButtonStyle.Secondary),
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
await channel.send({ embeds: [embed], components });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
lastFinishedToolName = currentToolName;
|
|
389
|
+
currentToolName = null;
|
|
390
|
+
currentToolInput = '';
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (message.type === 'user') {
|
|
396
|
+
const showResult = verbose || (lastFinishedToolName !== null && TASK_TOOLS.has(lastFinishedToolName));
|
|
397
|
+
if (!showResult) continue;
|
|
398
|
+
|
|
399
|
+
await streamer.finalize();
|
|
400
|
+
|
|
401
|
+
const content = (message as any).message?.content;
|
|
402
|
+
let resultText = '';
|
|
403
|
+
if (Array.isArray(content)) {
|
|
404
|
+
for (const block of content) {
|
|
405
|
+
if (block.type === 'tool_result' && block.content) {
|
|
406
|
+
if (typeof block.content === 'string') {
|
|
407
|
+
resultText += block.content;
|
|
408
|
+
} else if (Array.isArray(block.content)) {
|
|
409
|
+
for (const sub of block.content) {
|
|
410
|
+
if (sub.type === 'text') resultText += sub.text;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (resultText) {
|
|
418
|
+
// TaskList/TaskGet results get a visual board embed
|
|
419
|
+
const isTaskResult = lastFinishedToolName !== null && TASK_TOOLS.has(lastFinishedToolName);
|
|
420
|
+
if (isTaskResult && !verbose) {
|
|
421
|
+
const boardEmbed = renderTaskListEmbed(resultText);
|
|
422
|
+
if (boardEmbed) {
|
|
423
|
+
await channel.send({
|
|
424
|
+
embeds: [boardEmbed],
|
|
425
|
+
components: [makeStopButton(sessionId)],
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
const displayResult = resultText.length > 1000
|
|
430
|
+
? truncate(resultText, 1000)
|
|
431
|
+
: resultText;
|
|
432
|
+
|
|
433
|
+
const embed = new EmbedBuilder()
|
|
434
|
+
.setColor(0x1abc9c)
|
|
435
|
+
.setTitle('Tool Result')
|
|
436
|
+
.setDescription(`\`\`\`\n${displayResult}\n\`\`\``);
|
|
437
|
+
|
|
438
|
+
const components: ActionRowBuilder<ButtonBuilder>[] = [makeStopButton(sessionId)];
|
|
439
|
+
|
|
440
|
+
if (resultText.length > 1000) {
|
|
441
|
+
const contentId = storeExpandable(resultText);
|
|
442
|
+
components.unshift(
|
|
443
|
+
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
444
|
+
new ButtonBuilder()
|
|
445
|
+
.setCustomId(`expand:${contentId}`)
|
|
446
|
+
.setLabel('Show Full Output')
|
|
447
|
+
.setStyle(ButtonStyle.Secondary),
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
await channel.send({ embeds: [embed], components });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (message.type === 'result') {
|
|
458
|
+
const lastText = streamer.getText();
|
|
459
|
+
await streamer.finalize();
|
|
460
|
+
|
|
461
|
+
const result = message as any;
|
|
462
|
+
const isSuccess = result.subtype === 'success';
|
|
463
|
+
const cost = result.total_cost_usd?.toFixed(4) || '0.0000';
|
|
464
|
+
const duration = result.duration_ms
|
|
465
|
+
? `${(result.duration_ms / 1000).toFixed(1)}s`
|
|
466
|
+
: 'unknown';
|
|
467
|
+
const turns = result.num_turns || 0;
|
|
468
|
+
|
|
469
|
+
const embed = new EmbedBuilder()
|
|
470
|
+
.setColor(isSuccess ? 0x2ecc71 : 0xe74c3c)
|
|
471
|
+
.setTitle(isSuccess ? 'Completed' : 'Error')
|
|
472
|
+
.addFields(
|
|
473
|
+
{ name: 'Cost', value: `$${cost}`, inline: true },
|
|
474
|
+
{ name: 'Duration', value: duration, inline: true },
|
|
475
|
+
{ name: 'Turns', value: `${turns}`, inline: true },
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
if (result.session_id) {
|
|
479
|
+
embed.setFooter({ text: `Session: ${result.session_id}` });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (!isSuccess && result.errors?.length) {
|
|
483
|
+
embed.setDescription(result.errors.join('\n'));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const components: (ActionRowBuilder<ButtonBuilder> | ActionRowBuilder<StringSelectMenuBuilder>)[] = [];
|
|
487
|
+
|
|
488
|
+
const checkText = lastText || (result.result as string) || '';
|
|
489
|
+
const options = detectNumberedOptions(checkText);
|
|
490
|
+
if (options) {
|
|
491
|
+
components.push(...makeOptionButtons(sessionId, options));
|
|
492
|
+
} else if (detectYesNoPrompt(checkText)) {
|
|
493
|
+
components.push(makeYesNoButtons(sessionId));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
components.push(makeCompletionButtons(sessionId));
|
|
497
|
+
|
|
498
|
+
await channel.send({ embeds: [embed], components });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
} catch (err: unknown) {
|
|
502
|
+
await streamer.finalize();
|
|
503
|
+
|
|
504
|
+
if ((err as Error).name !== 'AbortError') {
|
|
505
|
+
const embed = new EmbedBuilder()
|
|
506
|
+
.setColor(0xe74c3c)
|
|
507
|
+
.setTitle('Error')
|
|
508
|
+
.setDescription(`\`\`\`\n${(err as Error).message}\n\`\`\``);
|
|
509
|
+
await channel.send({ embeds: [embed] });
|
|
510
|
+
}
|
|
511
|
+
} finally {
|
|
512
|
+
clearInterval(typingInterval);
|
|
513
|
+
streamer.destroy();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DATA_DIR = join(process.cwd(), '.discord-friends');
|
|
6
|
+
|
|
7
|
+
export class Store<T> {
|
|
8
|
+
private filePath: string;
|
|
9
|
+
|
|
10
|
+
constructor(filename: string) {
|
|
11
|
+
this.filePath = join(DATA_DIR, filename);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async read(): Promise<T | null> {
|
|
15
|
+
try {
|
|
16
|
+
const data = await readFile(this.filePath, 'utf-8');
|
|
17
|
+
return JSON.parse(data) as T;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async write(data: T): Promise<void> {
|
|
24
|
+
const dir = dirname(this.filePath);
|
|
25
|
+
if (!existsSync(dir)) {
|
|
26
|
+
await mkdir(dir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const tmpPath = this.filePath + '.tmp';
|
|
30
|
+
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
31
|
+
await rename(tmpPath, this.filePath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { Store } from './persistence.ts';
|
|
5
|
+
import type { Project, McpServer } from './types.ts';
|
|
6
|
+
|
|
7
|
+
const projectStore = new Store<Record<string, Project>>('projects.json');
|
|
8
|
+
|
|
9
|
+
let projects: Record<string, Project> = {};
|
|
10
|
+
|
|
11
|
+
export async function loadProjects(): Promise<void> {
|
|
12
|
+
projects = (await projectStore.read()) || {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function saveProjects(): Promise<void> {
|
|
16
|
+
await projectStore.write(projects);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getProject(name: string): Project | undefined {
|
|
20
|
+
return projects[name];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getAllProjects(): Record<string, Project> {
|
|
24
|
+
return { ...projects };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getOrCreateProject(name: string, directory: string, categoryId: string): Project {
|
|
28
|
+
if (!projects[name]) {
|
|
29
|
+
projects[name] = {
|
|
30
|
+
name,
|
|
31
|
+
directory,
|
|
32
|
+
categoryId,
|
|
33
|
+
skills: {},
|
|
34
|
+
mcpServers: [],
|
|
35
|
+
};
|
|
36
|
+
saveProjects();
|
|
37
|
+
}
|
|
38
|
+
return projects[name];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function updateProjectCategory(name: string, categoryId: string, logChannelId?: string): void {
|
|
42
|
+
const project = projects[name];
|
|
43
|
+
if (project) {
|
|
44
|
+
project.categoryId = categoryId;
|
|
45
|
+
if (logChannelId) project.logChannelId = logChannelId;
|
|
46
|
+
saveProjects();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Personality
|
|
51
|
+
|
|
52
|
+
export function setPersonality(projectName: string, prompt: string): boolean {
|
|
53
|
+
const project = projects[projectName];
|
|
54
|
+
if (!project) return false;
|
|
55
|
+
project.personality = prompt;
|
|
56
|
+
saveProjects();
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getPersonality(projectName: string): string | undefined {
|
|
61
|
+
return projects[projectName]?.personality;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function clearPersonality(projectName: string): boolean {
|
|
65
|
+
const project = projects[projectName];
|
|
66
|
+
if (!project) return false;
|
|
67
|
+
delete project.personality;
|
|
68
|
+
saveProjects();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Skills
|
|
73
|
+
|
|
74
|
+
export function addSkill(projectName: string, name: string, prompt: string): boolean {
|
|
75
|
+
const project = projects[projectName];
|
|
76
|
+
if (!project) return false;
|
|
77
|
+
project.skills[name] = prompt;
|
|
78
|
+
saveProjects();
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function removeSkill(projectName: string, name: string): boolean {
|
|
83
|
+
const project = projects[projectName];
|
|
84
|
+
if (!project || !project.skills[name]) return false;
|
|
85
|
+
delete project.skills[name];
|
|
86
|
+
saveProjects();
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getSkills(projectName: string): Record<string, string> {
|
|
91
|
+
return projects[projectName]?.skills || {};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function executeSkill(projectName: string, skillName: string, input?: string): string | null {
|
|
95
|
+
const project = projects[projectName];
|
|
96
|
+
if (!project) return null;
|
|
97
|
+
const template = project.skills[skillName];
|
|
98
|
+
if (!template) return null;
|
|
99
|
+
return input ? template.replace(/\{input\}/g, input) : template.replace(/\{input\}/g, '');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// MCP Servers
|
|
103
|
+
|
|
104
|
+
export async function addMcpServer(projectDir: string, projectName: string, server: McpServer): Promise<boolean> {
|
|
105
|
+
const project = projects[projectName];
|
|
106
|
+
if (!project) return false;
|
|
107
|
+
|
|
108
|
+
// Update in-memory
|
|
109
|
+
const existing = project.mcpServers.findIndex(s => s.name === server.name);
|
|
110
|
+
if (existing >= 0) {
|
|
111
|
+
project.mcpServers[existing] = server;
|
|
112
|
+
} else {
|
|
113
|
+
project.mcpServers.push(server);
|
|
114
|
+
}
|
|
115
|
+
await saveProjects();
|
|
116
|
+
|
|
117
|
+
// Write to project's .mcp.json so Claude Code picks it up natively
|
|
118
|
+
await writeMcpJson(projectDir, project.mcpServers);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function removeMcpServer(projectDir: string, projectName: string, name: string): Promise<boolean> {
|
|
123
|
+
const project = projects[projectName];
|
|
124
|
+
if (!project) return false;
|
|
125
|
+
|
|
126
|
+
const idx = project.mcpServers.findIndex(s => s.name === name);
|
|
127
|
+
if (idx < 0) return false;
|
|
128
|
+
|
|
129
|
+
project.mcpServers.splice(idx, 1);
|
|
130
|
+
await saveProjects();
|
|
131
|
+
await writeMcpJson(projectDir, project.mcpServers);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function listMcpServers(projectName: string): McpServer[] {
|
|
136
|
+
return projects[projectName]?.mcpServers || [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function writeMcpJson(projectDir: string, servers: McpServer[]): Promise<void> {
|
|
140
|
+
const mcpPath = join(projectDir, '.mcp.json');
|
|
141
|
+
|
|
142
|
+
// Read existing .mcp.json if it exists (preserve non-bot entries)
|
|
143
|
+
let mcpConfig: Record<string, unknown> = {};
|
|
144
|
+
try {
|
|
145
|
+
if (existsSync(mcpPath)) {
|
|
146
|
+
const existing = await readFile(mcpPath, 'utf-8');
|
|
147
|
+
mcpConfig = JSON.parse(existing);
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Start fresh
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Build mcpServers section
|
|
154
|
+
const mcpServers: Record<string, { command: string; args?: string[]; env?: Record<string, string> }> = {};
|
|
155
|
+
for (const server of servers) {
|
|
156
|
+
mcpServers[server.name] = {
|
|
157
|
+
command: server.command,
|
|
158
|
+
...(server.args?.length ? { args: server.args } : {}),
|
|
159
|
+
...(server.env ? { env: server.env } : {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
mcpConfig.mcpServers = mcpServers;
|
|
164
|
+
await writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
|
|
165
|
+
}
|