groove-dev 0.20.0 → 0.21.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/node_modules/@groove-dev/daemon/package.json +4 -0
- package/node_modules/@groove-dev/daemon/src/api.js +86 -0
- package/node_modules/@groove-dev/daemon/src/gateways/base.js +87 -0
- package/node_modules/@groove-dev/daemon/src/gateways/discord.js +220 -0
- package/node_modules/@groove-dev/daemon/src/gateways/formatter.js +201 -0
- package/node_modules/@groove-dev/daemon/src/gateways/manager.js +695 -0
- package/node_modules/@groove-dev/daemon/src/gateways/slack.js +165 -0
- package/node_modules/@groove-dev/daemon/src/gateways/telegram.js +265 -0
- package/node_modules/@groove-dev/daemon/src/index.js +4 -0
- package/node_modules/@groove-dev/daemon/src/validate.js +55 -0
- package/node_modules/@groove-dev/gui/.groove/codebase-index.json +1 -1
- package/node_modules/@groove-dev/gui/.groove/daemon.host +1 -0
- package/node_modules/@groove-dev/gui/.groove/daemon.pid +1 -0
- package/node_modules/@groove-dev/gui/.groove/timeline.json +2944 -0
- package/node_modules/@groove-dev/gui/AGENTS_REGISTRY.md +9 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CNqM3_F2.js +552 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-ChDhUvQR.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/src/stores/groove.js +7 -0
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +353 -3
- package/package.json +1 -1
- package/packages/daemon/package.json +4 -0
- package/packages/daemon/src/api.js +86 -0
- package/packages/daemon/src/gateways/base.js +87 -0
- package/packages/daemon/src/gateways/discord.js +220 -0
- package/packages/daemon/src/gateways/formatter.js +201 -0
- package/packages/daemon/src/gateways/manager.js +695 -0
- package/packages/daemon/src/gateways/slack.js +165 -0
- package/packages/daemon/src/gateways/telegram.js +265 -0
- package/packages/daemon/src/index.js +4 -0
- package/packages/daemon/src/validate.js +55 -0
- package/packages/gui/dist/assets/index-CNqM3_F2.js +552 -0
- package/packages/gui/dist/assets/index-ChDhUvQR.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/src/stores/groove.js +7 -0
- package/packages/gui/src/views/settings.jsx +353 -3
- package/node_modules/@groove-dev/gui/dist/assets/index-B8ZmjJeV.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-DKov-d0e.js +0 -537
- package/packages/gui/dist/assets/index-B8ZmjJeV.css +0 -1
- package/packages/gui/dist/assets/index-DKov-d0e.js +0 -537
|
@@ -838,6 +838,92 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
838
838
|
res.json({ id: agent.id, integrations });
|
|
839
839
|
});
|
|
840
840
|
|
|
841
|
+
// --- Gateways (Telegram, Discord, Slack) ---
|
|
842
|
+
|
|
843
|
+
app.get('/api/gateways', (req, res) => {
|
|
844
|
+
res.json(daemon.gateways.list());
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
app.post('/api/gateways', async (req, res) => {
|
|
848
|
+
try {
|
|
849
|
+
const result = await daemon.gateways.create(req.body || {});
|
|
850
|
+
res.json(result);
|
|
851
|
+
} catch (err) {
|
|
852
|
+
res.status(400).json({ error: err.message });
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
app.get('/api/gateways/:id', (req, res) => {
|
|
857
|
+
const gw = daemon.gateways.get(req.params.id);
|
|
858
|
+
if (!gw) return res.status(404).json({ error: 'Gateway not found' });
|
|
859
|
+
res.json(gw);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
app.patch('/api/gateways/:id', async (req, res) => {
|
|
863
|
+
try {
|
|
864
|
+
const result = await daemon.gateways.update(req.params.id, req.body || {});
|
|
865
|
+
res.json(result);
|
|
866
|
+
} catch (err) {
|
|
867
|
+
res.status(400).json({ error: err.message });
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
app.delete('/api/gateways/:id', async (req, res) => {
|
|
872
|
+
try {
|
|
873
|
+
await daemon.gateways.delete(req.params.id);
|
|
874
|
+
res.json({ ok: true });
|
|
875
|
+
} catch (err) {
|
|
876
|
+
res.status(400).json({ error: err.message });
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
app.post('/api/gateways/:id/test', async (req, res) => {
|
|
881
|
+
try {
|
|
882
|
+
const result = await daemon.gateways.test(req.params.id);
|
|
883
|
+
res.json(result);
|
|
884
|
+
} catch (err) {
|
|
885
|
+
res.status(400).json({ error: err.message });
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
app.post('/api/gateways/:id/connect', async (req, res) => {
|
|
890
|
+
try {
|
|
891
|
+
const result = await daemon.gateways.connect(req.params.id);
|
|
892
|
+
res.json(result);
|
|
893
|
+
} catch (err) {
|
|
894
|
+
res.status(400).json({ error: err.message });
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
app.post('/api/gateways/:id/disconnect', async (req, res) => {
|
|
899
|
+
try {
|
|
900
|
+
const result = await daemon.gateways.disconnect(req.params.id);
|
|
901
|
+
res.json(result);
|
|
902
|
+
} catch (err) {
|
|
903
|
+
res.status(400).json({ error: err.message });
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
app.post('/api/gateways/:id/credentials', (req, res) => {
|
|
908
|
+
try {
|
|
909
|
+
const { key, value } = req.body || {};
|
|
910
|
+
if (!key || !value) return res.status(400).json({ error: 'key and value are required' });
|
|
911
|
+
daemon.gateways.setCredential(req.params.id, key, value);
|
|
912
|
+
res.json({ ok: true });
|
|
913
|
+
} catch (err) {
|
|
914
|
+
res.status(400).json({ error: err.message });
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
app.delete('/api/gateways/:id/credentials/:key', (req, res) => {
|
|
919
|
+
try {
|
|
920
|
+
daemon.gateways.deleteCredential(req.params.id, req.params.key);
|
|
921
|
+
res.json({ ok: true });
|
|
922
|
+
} catch (err) {
|
|
923
|
+
res.status(400).json({ error: err.message });
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
|
|
841
927
|
// --- Schedules ---
|
|
842
928
|
|
|
843
929
|
app.get('/api/schedules', (req, res) => {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// GROOVE — Base Gateway Interface
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
export class BaseGateway {
|
|
5
|
+
static type = 'base';
|
|
6
|
+
static displayName = 'Base Gateway';
|
|
7
|
+
static description = '';
|
|
8
|
+
static credentialKeys = []; // [{ key, label, required, help }]
|
|
9
|
+
|
|
10
|
+
constructor(daemon, config) {
|
|
11
|
+
this.daemon = daemon;
|
|
12
|
+
this.config = config; // { id, type, enabled, chatId, allowedUsers, notifications }
|
|
13
|
+
this.connected = false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Connect to the messaging platform. Called by GatewayManager.start().
|
|
18
|
+
*/
|
|
19
|
+
async connect() {
|
|
20
|
+
throw new Error('Gateway must implement connect()');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Gracefully disconnect. Called by GatewayManager.stop().
|
|
25
|
+
*/
|
|
26
|
+
async disconnect() {
|
|
27
|
+
throw new Error('Gateway must implement disconnect()');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Send a message to the configured chat/channel.
|
|
32
|
+
* @param {string} text — formatted message text
|
|
33
|
+
* @param {object} [options] — platform-specific options (replyMarkup, embeds, blocks, etc.)
|
|
34
|
+
*/
|
|
35
|
+
async send(text, options) {
|
|
36
|
+
throw new Error('Gateway must implement send()');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Process an inbound command from chat. Checks authorization, then delegates
|
|
41
|
+
* to GatewayManager.routeCommand() for actual execution.
|
|
42
|
+
* @param {string} command — command name (without leading /)
|
|
43
|
+
* @param {string[]} args — command arguments
|
|
44
|
+
* @param {string} userId — platform-specific user ID
|
|
45
|
+
* @returns {{ text: string, options?: object } | null}
|
|
46
|
+
*/
|
|
47
|
+
async handleCommand(command, args, userId) {
|
|
48
|
+
if (!this._isAuthorized(userId)) {
|
|
49
|
+
return { text: 'Unauthorized. Your user ID is not in the gateway allowlist.' };
|
|
50
|
+
}
|
|
51
|
+
return this.daemon.gateways.routeCommand(this, command, args, userId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a user is authorized to send commands.
|
|
56
|
+
* Empty allowlist = open access (for personal bots).
|
|
57
|
+
*/
|
|
58
|
+
_isAuthorized(userId) {
|
|
59
|
+
const allow = this.config.allowedUsers || [];
|
|
60
|
+
if (allow.length === 0) return true;
|
|
61
|
+
return allow.includes(String(userId));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get the credential value for this gateway from CredentialStore.
|
|
66
|
+
*/
|
|
67
|
+
_getCredential(key) {
|
|
68
|
+
return this.daemon.credentials.getKey(`gateway:${this.config.id}:${key}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Return current gateway status for API responses.
|
|
73
|
+
*/
|
|
74
|
+
getStatus() {
|
|
75
|
+
return {
|
|
76
|
+
id: this.config.id,
|
|
77
|
+
type: this.constructor.type,
|
|
78
|
+
displayName: this.constructor.displayName,
|
|
79
|
+
connected: this.connected,
|
|
80
|
+
enabled: this.config.enabled,
|
|
81
|
+
chatId: this.config.chatId || null,
|
|
82
|
+
notifications: this.config.notifications || { preset: 'critical' },
|
|
83
|
+
commandPermission: this.config.commandPermission || 'full',
|
|
84
|
+
allowedUsers: (this.config.allowedUsers || []).length,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// GROOVE — Discord Gateway (discord.js)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { BaseGateway } from './base.js';
|
|
5
|
+
import { truncate, statusEmoji, formatTokens, formatDuration } from './formatter.js';
|
|
6
|
+
|
|
7
|
+
// Embed colors (decimal)
|
|
8
|
+
const COLORS = {
|
|
9
|
+
success: 0x2ecc71,
|
|
10
|
+
danger: 0xe74c3c,
|
|
11
|
+
warning: 0xf39c12,
|
|
12
|
+
info: 0x3498db,
|
|
13
|
+
accent: 0x5865f2, // Discord blurple
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class DiscordGateway extends BaseGateway {
|
|
17
|
+
static type = 'discord';
|
|
18
|
+
static displayName = 'Discord';
|
|
19
|
+
static description = 'Discord bot for notifications and agent commands';
|
|
20
|
+
static credentialKeys = [
|
|
21
|
+
{ key: 'bot_token', label: 'Bot Token', required: true, help: 'Discord Developer Portal \u2192 Bot \u2192 Token' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
constructor(daemon, config) {
|
|
25
|
+
super(daemon, config);
|
|
26
|
+
this.client = null;
|
|
27
|
+
this.channel = null;
|
|
28
|
+
this._djs = null; // cached discord.js module
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async connect() {
|
|
32
|
+
const token = this._getCredential('bot_token');
|
|
33
|
+
if (!token) throw new Error('Discord bot token not configured');
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
this._djs = await import('discord.js');
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error('Discord gateway requires discord.js. Install with: npm i discord.js');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { Client, GatewayIntentBits } = this._djs;
|
|
42
|
+
|
|
43
|
+
this.client = new Client({
|
|
44
|
+
intents: [
|
|
45
|
+
GatewayIntentBits.Guilds,
|
|
46
|
+
GatewayIntentBits.GuildMessages,
|
|
47
|
+
GatewayIntentBits.MessageContent,
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Handle incoming messages (commands)
|
|
52
|
+
this.client.on('messageCreate', async (msg) => {
|
|
53
|
+
if (msg.author.bot) return;
|
|
54
|
+
if (!msg.content.startsWith('/')) return;
|
|
55
|
+
|
|
56
|
+
const userId = msg.author.id;
|
|
57
|
+
const [command, ...args] = msg.content.slice(1).split(/\s+/);
|
|
58
|
+
|
|
59
|
+
// Auto-capture channelId
|
|
60
|
+
if (!this.config.chatId) {
|
|
61
|
+
this.config.chatId = msg.channel.id;
|
|
62
|
+
this.daemon.gateways._save(this.config.id);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const response = await this.handleCommand(command, args, userId);
|
|
66
|
+
if (response) {
|
|
67
|
+
await msg.channel.send(this._buildReply(response));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Handle button interactions (approve/reject)
|
|
72
|
+
this.client.on('interactionCreate', async (interaction) => {
|
|
73
|
+
if (!interaction.isButton()) return;
|
|
74
|
+
|
|
75
|
+
const userId = interaction.user.id;
|
|
76
|
+
if (!this._isAuthorized(userId)) {
|
|
77
|
+
await interaction.reply({ content: 'Unauthorized.', ephemeral: true });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const [action, ...rest] = interaction.customId.split(':');
|
|
82
|
+
const approvalId = rest.join(':');
|
|
83
|
+
if (!approvalId) {
|
|
84
|
+
await interaction.reply({ content: 'Invalid action.', ephemeral: true });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
let responseText;
|
|
90
|
+
if (action === 'approve') {
|
|
91
|
+
this.daemon.supervisor.approve(approvalId);
|
|
92
|
+
responseText = `\u2705 Approved: ${approvalId}`;
|
|
93
|
+
} else if (action === 'reject') {
|
|
94
|
+
this.daemon.supervisor.reject(approvalId);
|
|
95
|
+
responseText = `\u274c Rejected: ${approvalId}`;
|
|
96
|
+
} else {
|
|
97
|
+
await interaction.reply({ content: 'Unknown action.', ephemeral: true });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Update the original message — remove buttons, add resolution
|
|
102
|
+
await interaction.update({
|
|
103
|
+
components: [],
|
|
104
|
+
embeds: [
|
|
105
|
+
...(interaction.message.embeds || []),
|
|
106
|
+
this._embed({ description: responseText, color: action === 'approve' ? COLORS.success : COLORS.danger }),
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
await interaction.reply({ content: `Error: ${err.message}`, ephemeral: true }).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await this.client.login(token);
|
|
115
|
+
|
|
116
|
+
// Resolve target channel
|
|
117
|
+
if (this.config.chatId) {
|
|
118
|
+
try {
|
|
119
|
+
this.channel = await this.client.channels.fetch(this.config.chatId);
|
|
120
|
+
} catch { /* channel will be set on first message */ }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.connected = true;
|
|
124
|
+
console.log(`[Groove:Discord] Connected as ${this.client.user.tag}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async disconnect() {
|
|
128
|
+
if (this.client) {
|
|
129
|
+
this.client.destroy();
|
|
130
|
+
this.client = null;
|
|
131
|
+
}
|
|
132
|
+
this.channel = null;
|
|
133
|
+
this.connected = false;
|
|
134
|
+
this._djs = null;
|
|
135
|
+
console.log('[Groove:Discord] Disconnected');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async send(text, options = {}) {
|
|
139
|
+
const ch = await this._resolveChannel();
|
|
140
|
+
if (!ch) return;
|
|
141
|
+
|
|
142
|
+
const payload = {};
|
|
143
|
+
|
|
144
|
+
// Build embed for rich notifications
|
|
145
|
+
if (options.approvalId) {
|
|
146
|
+
// Approval request — embed with action buttons
|
|
147
|
+
payload.embeds = [this._embed({
|
|
148
|
+
title: '\ud83d\udea8 Approval Required',
|
|
149
|
+
description: text,
|
|
150
|
+
color: COLORS.warning,
|
|
151
|
+
})];
|
|
152
|
+
payload.components = [this._approvalButtons(options.approvalId)];
|
|
153
|
+
} else if (options.embed) {
|
|
154
|
+
payload.embeds = [options.embed];
|
|
155
|
+
} else {
|
|
156
|
+
// Plain text — truncate to Discord's 2000 char limit
|
|
157
|
+
payload.content = truncate(text, 2000);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await ch.send(payload);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
getStatus() {
|
|
164
|
+
return {
|
|
165
|
+
...super.getStatus(),
|
|
166
|
+
botTag: this.client?.user?.tag || null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// -------------------------------------------------------------------
|
|
171
|
+
// Discord-Specific Helpers
|
|
172
|
+
// -------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
async _resolveChannel() {
|
|
175
|
+
if (!this.channel && this.config.chatId && this.client) {
|
|
176
|
+
try {
|
|
177
|
+
this.channel = await this.client.channels.fetch(this.config.chatId);
|
|
178
|
+
} catch { return null; }
|
|
179
|
+
}
|
|
180
|
+
return this.channel;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build a discord.js EmbedBuilder-compatible plain object.
|
|
185
|
+
*/
|
|
186
|
+
_embed({ title, description, color, fields, footer }) {
|
|
187
|
+
const embed = { color: color || COLORS.accent };
|
|
188
|
+
if (title) embed.title = title;
|
|
189
|
+
if (description) embed.description = truncate(description, 4096);
|
|
190
|
+
if (fields) embed.fields = fields;
|
|
191
|
+
if (footer) embed.footer = { text: footer };
|
|
192
|
+
embed.timestamp = new Date().toISOString();
|
|
193
|
+
return embed;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Build an ActionRow with Approve/Reject buttons.
|
|
198
|
+
*/
|
|
199
|
+
_approvalButtons(approvalId) {
|
|
200
|
+
if (!this._djs) return {};
|
|
201
|
+
const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = this._djs;
|
|
202
|
+
return new ActionRowBuilder().addComponents(
|
|
203
|
+
new ButtonBuilder().setCustomId(`approve:${approvalId}`).setLabel('Approve').setStyle(ButtonStyle.Success).setEmoji('\u2705'),
|
|
204
|
+
new ButtonBuilder().setCustomId(`reject:${approvalId}`).setLabel('Reject').setStyle(ButtonStyle.Danger).setEmoji('\u274c'),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Build a reply payload from a command response.
|
|
210
|
+
*/
|
|
211
|
+
_buildReply(response) {
|
|
212
|
+
if (!response) return {};
|
|
213
|
+
// Wrap command responses in a code block for readability
|
|
214
|
+
const text = response.text || '';
|
|
215
|
+
if (text.length > 1900) {
|
|
216
|
+
return { content: '```\n' + truncate(text, 1900) + '\n```' };
|
|
217
|
+
}
|
|
218
|
+
return { content: '```\n' + text + '\n```' };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// GROOVE — Gateway Message Formatter
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Truncate text to a maximum length, adding ellipsis if needed.
|
|
6
|
+
*/
|
|
7
|
+
export function truncate(text, max = 2000) {
|
|
8
|
+
if (!text || text.length <= max) return text || '';
|
|
9
|
+
return text.slice(0, max - 3) + '...';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Format a duration in ms to human-readable string.
|
|
14
|
+
*/
|
|
15
|
+
export function formatDuration(ms) {
|
|
16
|
+
if (!ms || ms < 0) return '0s';
|
|
17
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
18
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
|
19
|
+
const h = Math.floor(ms / 3_600_000);
|
|
20
|
+
const m = Math.round((ms % 3_600_000) / 60_000);
|
|
21
|
+
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Format token count with K/M suffix.
|
|
26
|
+
*/
|
|
27
|
+
export function formatTokens(n) {
|
|
28
|
+
if (!n || n < 0) return '0';
|
|
29
|
+
if (n < 1000) return String(n);
|
|
30
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
|
31
|
+
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Format USD cost.
|
|
36
|
+
*/
|
|
37
|
+
export function formatCost(usd) {
|
|
38
|
+
if (!usd || usd < 0) return '$0.00';
|
|
39
|
+
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
|
40
|
+
return `$${usd.toFixed(2)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const STATUS_EMOJI = {
|
|
44
|
+
running: '\u{1f7e2}', // green circle
|
|
45
|
+
completed: '\u2705', // check mark
|
|
46
|
+
crashed: '\u{1f534}', // red circle
|
|
47
|
+
killed: '\u26d4', // no entry
|
|
48
|
+
starting: '\u{1f7e1}', // yellow circle
|
|
49
|
+
stopped: '\u26ab', // black circle
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get emoji for agent status.
|
|
54
|
+
*/
|
|
55
|
+
export function statusEmoji(status) {
|
|
56
|
+
return STATUS_EMOJI[status] || '\u2753'; // question mark fallback
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Convert a daemon broadcast event into a human-readable one-liner summary.
|
|
61
|
+
* Returns null if the event shouldn't generate a notification.
|
|
62
|
+
*/
|
|
63
|
+
export function eventToSummary(event) {
|
|
64
|
+
switch (event.type) {
|
|
65
|
+
case 'agent:exit': {
|
|
66
|
+
const status = event.status || 'unknown';
|
|
67
|
+
const emoji = statusEmoji(status);
|
|
68
|
+
const id = event.agentId ? ` (${event.agentId})` : '';
|
|
69
|
+
return `${emoji} Agent${id} ${status}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
case 'approval:request': {
|
|
73
|
+
const d = event.data || {};
|
|
74
|
+
const name = d.agentName || d.agentId || 'unknown';
|
|
75
|
+
const desc = d.action?.description || 'action pending';
|
|
76
|
+
return `\u{1f6a8} Approval needed — ${name}: ${truncate(desc, 200)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
case 'approval:resolved': {
|
|
80
|
+
const d = event.data || {};
|
|
81
|
+
const status = d.status || 'resolved';
|
|
82
|
+
const name = d.agentName || d.agentId || 'unknown';
|
|
83
|
+
return `${status === 'approved' ? '\u2705' : '\u274c'} Approval ${status} — ${name}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case 'conflict:detected': {
|
|
87
|
+
const d = event.data || event;
|
|
88
|
+
return `\u26a0\ufe0f Scope conflict: ${d.agentName || 'agent'} tried to modify ${d.filePath || 'file'} (owned by ${d.ownerName || 'another agent'})`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case 'rotation:start':
|
|
92
|
+
return `\u{1f504} Rotating ${event.agentName || 'agent'}...`;
|
|
93
|
+
|
|
94
|
+
case 'rotation:complete': {
|
|
95
|
+
const saved = event.tokensSaved ? ` (saved ${formatTokens(event.tokensSaved)} tokens)` : '';
|
|
96
|
+
return `\u{1f504} Rotated ${event.agentName || 'agent'}${saved}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
case 'rotation:failed':
|
|
100
|
+
return `\u274c Rotation failed: ${truncate(event.error, 200)}`;
|
|
101
|
+
|
|
102
|
+
case 'phase2:spawned':
|
|
103
|
+
return `\u{1f195} QC agent spawned: ${event.name || 'qc'}`;
|
|
104
|
+
|
|
105
|
+
case 'schedule:execute':
|
|
106
|
+
return `\u23f0 Scheduled agent spawned (schedule: ${event.scheduleId || 'unknown'})`;
|
|
107
|
+
|
|
108
|
+
case 'qc:activated':
|
|
109
|
+
return `\u{1f6e1}\ufe0f QC activated — ${event.agentCount || '4+'} agents running`;
|
|
110
|
+
|
|
111
|
+
case 'journalist:cycle': {
|
|
112
|
+
const d = event.data || {};
|
|
113
|
+
const summary = d.lastSynthesis || d.summary;
|
|
114
|
+
if (summary) return `\u{1f4f0} Journalist: ${truncate(summary, 500)}`;
|
|
115
|
+
return `\u{1f4f0} Journalist cycle #${d.cycleCount || d.cycle || '?'}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case 'team:created':
|
|
119
|
+
return `\u{1f4c1} Team created: ${event.team?.name || 'unnamed'}`;
|
|
120
|
+
|
|
121
|
+
case 'team:deleted':
|
|
122
|
+
return `\u{1f5d1}\ufe0f Team deleted (agents moved to default)`;
|
|
123
|
+
|
|
124
|
+
default:
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Format an agent list for display in chat.
|
|
131
|
+
* Returns a multi-line string.
|
|
132
|
+
*/
|
|
133
|
+
export function agentListText(agents) {
|
|
134
|
+
if (!agents || agents.length === 0) return 'No agents running.';
|
|
135
|
+
|
|
136
|
+
const lines = agents.map((a) => {
|
|
137
|
+
const emoji = statusEmoji(a.status);
|
|
138
|
+
const tokens = a.tokensUsed ? ` | ${formatTokens(a.tokensUsed)} tokens` : '';
|
|
139
|
+
const ctx = a.contextUsage ? ` | ctx ${Math.round(a.contextUsage * 100)}%` : '';
|
|
140
|
+
return `${emoji} ${a.name || a.id} (${a.role})${tokens}${ctx}`;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return lines.join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Format a daemon status summary for chat.
|
|
148
|
+
*/
|
|
149
|
+
export function statusText(agents, uptime) {
|
|
150
|
+
const running = agents.filter((a) => a.status === 'running' || a.status === 'starting');
|
|
151
|
+
const completed = agents.filter((a) => a.status === 'completed');
|
|
152
|
+
const crashed = agents.filter((a) => a.status === 'crashed');
|
|
153
|
+
|
|
154
|
+
const lines = [
|
|
155
|
+
`Groove Daemon — ${formatDuration(uptime)} uptime`,
|
|
156
|
+
`Agents: ${running.length} running, ${completed.length} completed, ${crashed.length} crashed`,
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
if (running.length > 0) {
|
|
160
|
+
lines.push('');
|
|
161
|
+
lines.push('Active:');
|
|
162
|
+
for (const a of running) {
|
|
163
|
+
const tokens = a.tokensUsed ? ` | ${formatTokens(a.tokensUsed)}` : '';
|
|
164
|
+
lines.push(` ${statusEmoji(a.status)} ${a.name || a.id} (${a.role})${tokens}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return lines.join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Format a list of approvals for chat.
|
|
173
|
+
*/
|
|
174
|
+
export function approvalsText(approvals) {
|
|
175
|
+
if (!approvals || approvals.length === 0) return 'No pending approvals.';
|
|
176
|
+
|
|
177
|
+
return approvals.map((a) => {
|
|
178
|
+
const desc = a.action?.description || 'action pending';
|
|
179
|
+
return `\u{1f6a8} [${a.id}] ${a.agentName || a.agentId}: ${truncate(desc, 150)}`;
|
|
180
|
+
}).join('\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Format a list of teams for chat.
|
|
185
|
+
*/
|
|
186
|
+
export function teamsText(teams) {
|
|
187
|
+
if (!teams || teams.length === 0) return 'No teams.';
|
|
188
|
+
return teams.map((t) => `\u{1f4c1} ${t.name}${t.isDefault ? ' (default)' : ''} — ${t.agentCount || 0} agents`).join('\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Format a list of schedules for chat.
|
|
193
|
+
*/
|
|
194
|
+
export function schedulesText(schedules) {
|
|
195
|
+
if (!schedules || schedules.length === 0) return 'No schedules.';
|
|
196
|
+
return schedules.map((s) => {
|
|
197
|
+
const status = s.enabled ? '\u2705' : '\u26ab';
|
|
198
|
+
const running = s.isRunning ? ' (running)' : '';
|
|
199
|
+
return `${status} ${s.name} — ${s.cronDescription || s.cron}${running}`;
|
|
200
|
+
}).join('\n');
|
|
201
|
+
}
|