loopwork 0.3.0 → 0.3.1
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/bin/loopwork +0 -0
- package/package.json +48 -4
- package/src/backends/github.ts +6 -3
- package/src/backends/json.ts +28 -10
- package/src/commands/run.ts +2 -2
- package/src/contracts/config.ts +3 -75
- package/src/contracts/index.ts +0 -6
- package/src/core/cli.ts +25 -16
- package/src/core/state.ts +10 -4
- package/src/core/utils.ts +10 -4
- package/src/monitor/index.ts +56 -34
- package/src/plugins/index.ts +9 -131
- package/examples/README.md +0 -70
- package/examples/basic-json-backend/.specs/tasks/TASK-001.md +0 -22
- package/examples/basic-json-backend/.specs/tasks/TASK-002.md +0 -23
- package/examples/basic-json-backend/.specs/tasks/TASK-003.md +0 -37
- package/examples/basic-json-backend/.specs/tasks/tasks.json +0 -19
- package/examples/basic-json-backend/README.md +0 -32
- package/examples/basic-json-backend/TESTING.md +0 -184
- package/examples/basic-json-backend/hello.test.ts +0 -9
- package/examples/basic-json-backend/hello.ts +0 -3
- package/examples/basic-json-backend/loopwork.config.js +0 -35
- package/examples/basic-json-backend/math.test.ts +0 -29
- package/examples/basic-json-backend/math.ts +0 -3
- package/examples/basic-json-backend/package.json +0 -15
- package/examples/basic-json-backend/quick-start.sh +0 -80
- package/loopwork.config.ts +0 -164
- package/src/plugins/asana.ts +0 -192
- package/src/plugins/cost-tracking.ts +0 -402
- package/src/plugins/discord.ts +0 -269
- package/src/plugins/everhour.ts +0 -335
- package/src/plugins/telegram/bot.ts +0 -517
- package/src/plugins/telegram/index.ts +0 -6
- package/src/plugins/telegram/notifications.ts +0 -198
- package/src/plugins/todoist.ts +0 -261
- package/test/backends.test.ts +0 -929
- package/test/cli.test.ts +0 -145
- package/test/config.test.ts +0 -90
- package/test/e2e.test.ts +0 -458
- package/test/github-tasks.test.ts +0 -191
- package/test/loopwork-config-types.test.ts +0 -288
- package/test/monitor.test.ts +0 -123
- package/test/plugins.test.ts +0 -1175
- package/test/state.test.ts +0 -295
- package/test/utils.test.ts +0 -60
- package/tsconfig.json +0 -20
|
@@ -1,402 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cost Tracking for Loopwork
|
|
3
|
-
*
|
|
4
|
-
* Tracks token usage and costs for AI CLI tools (Claude, OpenCode, Gemini)
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import fs from 'fs'
|
|
8
|
-
import path from 'path'
|
|
9
|
-
import type { LoopworkPlugin, PluginTask, LoopStats } from '../contracts'
|
|
10
|
-
|
|
11
|
-
// ============================================================================
|
|
12
|
-
// Token Pricing (per 1M tokens, in USD)
|
|
13
|
-
// ============================================================================
|
|
14
|
-
|
|
15
|
-
export interface ModelPricing {
|
|
16
|
-
inputPer1M: number
|
|
17
|
-
outputPer1M: number
|
|
18
|
-
cacheReadPer1M?: number
|
|
19
|
-
cacheWritePer1M?: number
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
23
|
-
// Claude models
|
|
24
|
-
'claude-3-opus': { inputPer1M: 15.00, outputPer1M: 75.00 },
|
|
25
|
-
'claude-3-sonnet': { inputPer1M: 3.00, outputPer1M: 15.00 },
|
|
26
|
-
'claude-3-haiku': { inputPer1M: 0.25, outputPer1M: 1.25 },
|
|
27
|
-
'claude-3.5-sonnet': { inputPer1M: 3.00, outputPer1M: 15.00 },
|
|
28
|
-
'claude-3.5-haiku': { inputPer1M: 0.80, outputPer1M: 4.00 },
|
|
29
|
-
'claude-opus-4': { inputPer1M: 15.00, outputPer1M: 75.00 },
|
|
30
|
-
'claude-sonnet-4': { inputPer1M: 3.00, outputPer1M: 15.00 },
|
|
31
|
-
|
|
32
|
-
// OpenAI models (for opencode)
|
|
33
|
-
'gpt-4': { inputPer1M: 30.00, outputPer1M: 60.00 },
|
|
34
|
-
'gpt-4-turbo': { inputPer1M: 10.00, outputPer1M: 30.00 },
|
|
35
|
-
'gpt-4o': { inputPer1M: 2.50, outputPer1M: 10.00 },
|
|
36
|
-
'gpt-4o-mini': { inputPer1M: 0.15, outputPer1M: 0.60 },
|
|
37
|
-
'o1': { inputPer1M: 15.00, outputPer1M: 60.00 },
|
|
38
|
-
'o1-mini': { inputPer1M: 3.00, outputPer1M: 12.00 },
|
|
39
|
-
|
|
40
|
-
// Google models (for gemini)
|
|
41
|
-
'gemini-1.5-pro': { inputPer1M: 1.25, outputPer1M: 5.00 },
|
|
42
|
-
'gemini-1.5-flash': { inputPer1M: 0.075, outputPer1M: 0.30 },
|
|
43
|
-
'gemini-2.0-flash': { inputPer1M: 0.10, outputPer1M: 0.40 },
|
|
44
|
-
|
|
45
|
-
// Default fallback
|
|
46
|
-
'default': { inputPer1M: 3.00, outputPer1M: 15.00 },
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ============================================================================
|
|
50
|
-
// Usage Types
|
|
51
|
-
// ============================================================================
|
|
52
|
-
|
|
53
|
-
export interface TokenUsage {
|
|
54
|
-
inputTokens: number
|
|
55
|
-
outputTokens: number
|
|
56
|
-
cacheReadTokens?: number
|
|
57
|
-
cacheWriteTokens?: number
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export interface UsageEntry {
|
|
61
|
-
taskId: string
|
|
62
|
-
model: string
|
|
63
|
-
usage: TokenUsage
|
|
64
|
-
cost: number
|
|
65
|
-
timestamp: Date
|
|
66
|
-
duration?: number // in seconds
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface UsageSummary {
|
|
70
|
-
totalInputTokens: number
|
|
71
|
-
totalOutputTokens: number
|
|
72
|
-
totalCacheReadTokens: number
|
|
73
|
-
totalCacheWriteTokens: number
|
|
74
|
-
totalCost: number
|
|
75
|
-
taskCount: number
|
|
76
|
-
entries: UsageEntry[]
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface DailySummary extends UsageSummary {
|
|
80
|
-
date: string // YYYY-MM-DD
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ============================================================================
|
|
84
|
-
// Cost Tracker
|
|
85
|
-
// ============================================================================
|
|
86
|
-
|
|
87
|
-
export class CostTracker {
|
|
88
|
-
private entries: UsageEntry[] = []
|
|
89
|
-
private storageFile: string
|
|
90
|
-
private namespace: string
|
|
91
|
-
|
|
92
|
-
constructor(projectRoot: string, namespace = 'default') {
|
|
93
|
-
this.namespace = namespace
|
|
94
|
-
const suffix = namespace === 'default' ? '' : `-${namespace}`
|
|
95
|
-
this.storageFile = path.join(projectRoot, `.loopwork-cost-tracking${suffix}.json`)
|
|
96
|
-
this.load()
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Calculate cost for token usage
|
|
101
|
-
*/
|
|
102
|
-
calculateCost(model: string, usage: TokenUsage): number {
|
|
103
|
-
const pricing = MODEL_PRICING[model] || MODEL_PRICING['default']
|
|
104
|
-
|
|
105
|
-
let cost = 0
|
|
106
|
-
cost += (usage.inputTokens / 1_000_000) * pricing.inputPer1M
|
|
107
|
-
cost += (usage.outputTokens / 1_000_000) * pricing.outputPer1M
|
|
108
|
-
|
|
109
|
-
if (usage.cacheReadTokens && pricing.cacheReadPer1M) {
|
|
110
|
-
cost += (usage.cacheReadTokens / 1_000_000) * pricing.cacheReadPer1M
|
|
111
|
-
}
|
|
112
|
-
if (usage.cacheWriteTokens && pricing.cacheWritePer1M) {
|
|
113
|
-
cost += (usage.cacheWriteTokens / 1_000_000) * pricing.cacheWritePer1M
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return cost
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Record token usage for a task
|
|
121
|
-
*/
|
|
122
|
-
record(taskId: string, model: string, usage: TokenUsage, duration?: number): UsageEntry {
|
|
123
|
-
const cost = this.calculateCost(model, usage)
|
|
124
|
-
|
|
125
|
-
const entry: UsageEntry = {
|
|
126
|
-
taskId,
|
|
127
|
-
model,
|
|
128
|
-
usage,
|
|
129
|
-
cost,
|
|
130
|
-
timestamp: new Date(),
|
|
131
|
-
duration,
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
this.entries.push(entry)
|
|
135
|
-
this.save()
|
|
136
|
-
|
|
137
|
-
return entry
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Parse token usage from CLI output
|
|
142
|
-
* Supports various CLI output formats
|
|
143
|
-
*/
|
|
144
|
-
parseUsageFromOutput(output: string): TokenUsage | null {
|
|
145
|
-
// Claude Code format: "Tokens: 1234 input, 567 output"
|
|
146
|
-
const claudeMatch = output.match(/Tokens:\s*(\d+)\s*input,\s*(\d+)\s*output/i)
|
|
147
|
-
if (claudeMatch) {
|
|
148
|
-
return {
|
|
149
|
-
inputTokens: parseInt(claudeMatch[1], 10),
|
|
150
|
-
outputTokens: parseInt(claudeMatch[2], 10),
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// OpenCode format: "Usage: 1234 prompt tokens, 567 completion tokens"
|
|
155
|
-
const openCodeMatch = output.match(/Usage:\s*(\d+)\s*prompt\s*tokens?,\s*(\d+)\s*completion\s*tokens?/i)
|
|
156
|
-
if (openCodeMatch) {
|
|
157
|
-
return {
|
|
158
|
-
inputTokens: parseInt(openCodeMatch[1], 10),
|
|
159
|
-
outputTokens: parseInt(openCodeMatch[2], 10),
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Generic format: "input_tokens: 1234, output_tokens: 567"
|
|
164
|
-
const genericMatch = output.match(/input[_\s]tokens?:\s*(\d+).*output[_\s]tokens?:\s*(\d+)/i)
|
|
165
|
-
if (genericMatch) {
|
|
166
|
-
return {
|
|
167
|
-
inputTokens: parseInt(genericMatch[1], 10),
|
|
168
|
-
outputTokens: parseInt(genericMatch[2], 10),
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// JSON format in output
|
|
173
|
-
const jsonMatch = output.match(/\{[^}]*"input[_\s]?tokens?":\s*(\d+)[^}]*"output[_\s]?tokens?":\s*(\d+)[^}]*\}/i)
|
|
174
|
-
if (jsonMatch) {
|
|
175
|
-
return {
|
|
176
|
-
inputTokens: parseInt(jsonMatch[1], 10),
|
|
177
|
-
outputTokens: parseInt(jsonMatch[2], 10),
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return null
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Get summary for a specific task
|
|
186
|
-
*/
|
|
187
|
-
getTaskSummary(taskId: string): UsageSummary {
|
|
188
|
-
const taskEntries = this.entries.filter(e => e.taskId === taskId)
|
|
189
|
-
return this.summarize(taskEntries)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Get summary for today
|
|
194
|
-
*/
|
|
195
|
-
getTodaySummary(): DailySummary {
|
|
196
|
-
const today = new Date().toISOString().split('T')[0]
|
|
197
|
-
const todayEntries = this.entries.filter(e =>
|
|
198
|
-
e.timestamp.toISOString().split('T')[0] === today
|
|
199
|
-
)
|
|
200
|
-
|
|
201
|
-
return {
|
|
202
|
-
date: today,
|
|
203
|
-
...this.summarize(todayEntries),
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Get summary for a date range
|
|
209
|
-
*/
|
|
210
|
-
getRangeSummary(startDate: Date, endDate: Date): UsageSummary {
|
|
211
|
-
const rangeEntries = this.entries.filter(e =>
|
|
212
|
-
e.timestamp >= startDate && e.timestamp <= endDate
|
|
213
|
-
)
|
|
214
|
-
return this.summarize(rangeEntries)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Get all-time summary
|
|
219
|
-
*/
|
|
220
|
-
getAllTimeSummary(): UsageSummary {
|
|
221
|
-
return this.summarize(this.entries)
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Get daily summaries for the last N days
|
|
226
|
-
*/
|
|
227
|
-
getDailySummaries(days = 7): DailySummary[] {
|
|
228
|
-
const summaries: DailySummary[] = []
|
|
229
|
-
|
|
230
|
-
for (let i = 0; i < days; i++) {
|
|
231
|
-
const date = new Date()
|
|
232
|
-
date.setDate(date.getDate() - i)
|
|
233
|
-
const dateStr = date.toISOString().split('T')[0]
|
|
234
|
-
|
|
235
|
-
const dayEntries = this.entries.filter(e =>
|
|
236
|
-
e.timestamp.toISOString().split('T')[0] === dateStr
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
summaries.push({
|
|
240
|
-
date: dateStr,
|
|
241
|
-
...this.summarize(dayEntries),
|
|
242
|
-
})
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return summaries
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Get cost breakdown by model
|
|
250
|
-
*/
|
|
251
|
-
getCostByModel(): Record<string, { cost: number; tokens: TokenUsage }> {
|
|
252
|
-
const byModel: Record<string, { cost: number; tokens: TokenUsage }> = {}
|
|
253
|
-
|
|
254
|
-
for (const entry of this.entries) {
|
|
255
|
-
if (!byModel[entry.model]) {
|
|
256
|
-
byModel[entry.model] = {
|
|
257
|
-
cost: 0,
|
|
258
|
-
tokens: { inputTokens: 0, outputTokens: 0 },
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
byModel[entry.model].cost += entry.cost
|
|
263
|
-
byModel[entry.model].tokens.inputTokens += entry.usage.inputTokens
|
|
264
|
-
byModel[entry.model].tokens.outputTokens += entry.usage.outputTokens
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return byModel
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Clear all entries
|
|
272
|
-
*/
|
|
273
|
-
clear(): void {
|
|
274
|
-
this.entries = []
|
|
275
|
-
this.save()
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
private summarize(entries: UsageEntry[]): UsageSummary {
|
|
279
|
-
return entries.reduce(
|
|
280
|
-
(acc, entry) => ({
|
|
281
|
-
totalInputTokens: acc.totalInputTokens + entry.usage.inputTokens,
|
|
282
|
-
totalOutputTokens: acc.totalOutputTokens + entry.usage.outputTokens,
|
|
283
|
-
totalCacheReadTokens: acc.totalCacheReadTokens + (entry.usage.cacheReadTokens || 0),
|
|
284
|
-
totalCacheWriteTokens: acc.totalCacheWriteTokens + (entry.usage.cacheWriteTokens || 0),
|
|
285
|
-
totalCost: acc.totalCost + entry.cost,
|
|
286
|
-
taskCount: acc.taskCount + 1,
|
|
287
|
-
entries: [...acc.entries, entry],
|
|
288
|
-
}),
|
|
289
|
-
{
|
|
290
|
-
totalInputTokens: 0,
|
|
291
|
-
totalOutputTokens: 0,
|
|
292
|
-
totalCacheReadTokens: 0,
|
|
293
|
-
totalCacheWriteTokens: 0,
|
|
294
|
-
totalCost: 0,
|
|
295
|
-
taskCount: 0,
|
|
296
|
-
entries: [] as UsageEntry[],
|
|
297
|
-
}
|
|
298
|
-
)
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
private load(): void {
|
|
302
|
-
try {
|
|
303
|
-
if (fs.existsSync(this.storageFile)) {
|
|
304
|
-
const content = fs.readFileSync(this.storageFile, 'utf-8')
|
|
305
|
-
const data = JSON.parse(content)
|
|
306
|
-
this.entries = data.entries.map((e: any) => ({
|
|
307
|
-
...e,
|
|
308
|
-
timestamp: new Date(e.timestamp),
|
|
309
|
-
}))
|
|
310
|
-
}
|
|
311
|
-
} catch {
|
|
312
|
-
this.entries = []
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
private save(): void {
|
|
317
|
-
try {
|
|
318
|
-
const data = { entries: this.entries, namespace: this.namespace }
|
|
319
|
-
fs.writeFileSync(this.storageFile, JSON.stringify(data, null, 2))
|
|
320
|
-
} catch {
|
|
321
|
-
// Ignore save errors
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// ============================================================================
|
|
327
|
-
// Cost Tracking Hook Plugin
|
|
328
|
-
// ============================================================================
|
|
329
|
-
|
|
330
|
-
export function createCostTrackingPlugin(
|
|
331
|
-
projectRoot: string,
|
|
332
|
-
namespace = 'default',
|
|
333
|
-
defaultModel = 'claude-3.5-sonnet'
|
|
334
|
-
): LoopworkPlugin {
|
|
335
|
-
const tracker = new CostTracker(projectRoot, namespace)
|
|
336
|
-
|
|
337
|
-
return {
|
|
338
|
-
name: 'cost-tracking',
|
|
339
|
-
|
|
340
|
-
async onTaskStart(task: PluginTask) {
|
|
341
|
-
// Track task start time (stored in tracker)
|
|
342
|
-
},
|
|
343
|
-
|
|
344
|
-
async onTaskComplete(task: PluginTask, result: { output?: string; duration: number }) {
|
|
345
|
-
const output = (result as any).output || ''
|
|
346
|
-
const usage = tracker.parseUsageFromOutput(output)
|
|
347
|
-
|
|
348
|
-
if (usage) {
|
|
349
|
-
tracker.record(task.id, defaultModel, usage, result.duration)
|
|
350
|
-
}
|
|
351
|
-
},
|
|
352
|
-
|
|
353
|
-
async onLoopEnd(stats: LoopStats) {
|
|
354
|
-
const summary = tracker.getTodaySummary()
|
|
355
|
-
console.log(`\n📊 Cost Summary for today:`)
|
|
356
|
-
console.log(` Tasks: ${summary.taskCount}`)
|
|
357
|
-
console.log(` Tokens: ${summary.totalInputTokens.toLocaleString()} in / ${summary.totalOutputTokens.toLocaleString()} out`)
|
|
358
|
-
console.log(` Cost: $${summary.totalCost.toFixed(4)}`)
|
|
359
|
-
},
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// ============================================================================
|
|
364
|
-
// Formatting Helpers
|
|
365
|
-
// ============================================================================
|
|
366
|
-
|
|
367
|
-
export function formatCost(cost: number): string {
|
|
368
|
-
if (cost < 0.01) {
|
|
369
|
-
return `$${cost.toFixed(4)}`
|
|
370
|
-
} else if (cost < 1) {
|
|
371
|
-
return `$${cost.toFixed(3)}`
|
|
372
|
-
} else {
|
|
373
|
-
return `$${cost.toFixed(2)}`
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
export function formatTokens(tokens: number): string {
|
|
378
|
-
if (tokens >= 1_000_000) {
|
|
379
|
-
return `${(tokens / 1_000_000).toFixed(2)}M`
|
|
380
|
-
} else if (tokens >= 1_000) {
|
|
381
|
-
return `${(tokens / 1_000).toFixed(1)}K`
|
|
382
|
-
}
|
|
383
|
-
return tokens.toString()
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
export function formatUsageSummary(summary: UsageSummary): string {
|
|
387
|
-
const lines = [
|
|
388
|
-
`Tasks: ${summary.taskCount}`,
|
|
389
|
-
`Input tokens: ${formatTokens(summary.totalInputTokens)}`,
|
|
390
|
-
`Output tokens: ${formatTokens(summary.totalOutputTokens)}`,
|
|
391
|
-
`Total cost: ${formatCost(summary.totalCost)}`,
|
|
392
|
-
]
|
|
393
|
-
|
|
394
|
-
if (summary.totalCacheReadTokens > 0) {
|
|
395
|
-
lines.push(`Cache read: ${formatTokens(summary.totalCacheReadTokens)}`)
|
|
396
|
-
}
|
|
397
|
-
if (summary.totalCacheWriteTokens > 0) {
|
|
398
|
-
lines.push(`Cache write: ${formatTokens(summary.totalCacheWriteTokens)}`)
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
return lines.join('\n')
|
|
402
|
-
}
|
package/src/plugins/discord.ts
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Discord Plugin for Loopwork
|
|
3
|
-
*
|
|
4
|
-
* Sends notifications to Discord channels via webhooks.
|
|
5
|
-
*
|
|
6
|
-
* Setup:
|
|
7
|
-
* 1. Create a webhook in Discord: Server Settings > Integrations > Webhooks
|
|
8
|
-
* 2. Copy the webhook URL
|
|
9
|
-
* 3. Set DISCORD_WEBHOOK_URL env var
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { LoopworkPlugin, PluginTask } from '../contracts'
|
|
13
|
-
|
|
14
|
-
export interface DiscordConfig {
|
|
15
|
-
webhookUrl?: string
|
|
16
|
-
/** Username to display for bot messages */
|
|
17
|
-
username?: string
|
|
18
|
-
/** Avatar URL for bot messages */
|
|
19
|
-
avatarUrl?: string
|
|
20
|
-
/** Send notification when task starts */
|
|
21
|
-
notifyOnStart?: boolean
|
|
22
|
-
/** Send notification when task completes */
|
|
23
|
-
notifyOnComplete?: boolean
|
|
24
|
-
/** Send notification when task fails */
|
|
25
|
-
notifyOnFail?: boolean
|
|
26
|
-
/** Send summary when loop ends */
|
|
27
|
-
notifyOnLoopEnd?: boolean
|
|
28
|
-
/** Mention role/user on failures (e.g., "<@&123456>" for role, "<@123456>" for user) */
|
|
29
|
-
mentionOnFail?: string
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface DiscordEmbed {
|
|
33
|
-
title?: string
|
|
34
|
-
description?: string
|
|
35
|
-
color?: number
|
|
36
|
-
fields?: Array<{ name: string; value: string; inline?: boolean }>
|
|
37
|
-
timestamp?: string
|
|
38
|
-
footer?: { text: string }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface DiscordWebhookPayload {
|
|
42
|
-
content?: string
|
|
43
|
-
username?: string
|
|
44
|
-
avatar_url?: string
|
|
45
|
-
embeds?: DiscordEmbed[]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Discord embed colors
|
|
49
|
-
const COLORS = {
|
|
50
|
-
blue: 0x3498db, // info/start
|
|
51
|
-
green: 0x2ecc71, // success
|
|
52
|
-
red: 0xe74c3c, // error
|
|
53
|
-
yellow: 0xf1c40f, // warning
|
|
54
|
-
purple: 0x9b59b6, // summary
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export class DiscordClient {
|
|
58
|
-
private webhookUrl: string
|
|
59
|
-
private username?: string
|
|
60
|
-
private avatarUrl?: string
|
|
61
|
-
|
|
62
|
-
constructor(webhookUrl: string, options?: { username?: string; avatarUrl?: string }) {
|
|
63
|
-
this.webhookUrl = webhookUrl
|
|
64
|
-
this.username = options?.username
|
|
65
|
-
this.avatarUrl = options?.avatarUrl
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Send a message to Discord
|
|
70
|
-
*/
|
|
71
|
-
async send(payload: DiscordWebhookPayload): Promise<void> {
|
|
72
|
-
const body: DiscordWebhookPayload = {
|
|
73
|
-
...payload,
|
|
74
|
-
username: payload.username || this.username,
|
|
75
|
-
avatar_url: payload.avatar_url || this.avatarUrl,
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const response = await fetch(this.webhookUrl, {
|
|
79
|
-
method: 'POST',
|
|
80
|
-
headers: { 'Content-Type': 'application/json' },
|
|
81
|
-
body: JSON.stringify(body),
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
if (!response.ok) {
|
|
85
|
-
const error = await response.text()
|
|
86
|
-
throw new Error(`Discord webhook error: ${response.status} - ${error}`)
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Send a simple text message
|
|
92
|
-
*/
|
|
93
|
-
async sendText(content: string): Promise<void> {
|
|
94
|
-
await this.send({ content })
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Send an embed message
|
|
99
|
-
*/
|
|
100
|
-
async sendEmbed(embed: DiscordEmbed): Promise<void> {
|
|
101
|
-
await this.send({ embeds: [embed] })
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Send task started notification
|
|
106
|
-
*/
|
|
107
|
-
async notifyTaskStart(task: PluginTask): Promise<void> {
|
|
108
|
-
await this.sendEmbed({
|
|
109
|
-
title: `🔄 Task Started`,
|
|
110
|
-
description: `**${task.id}**: ${task.title}`,
|
|
111
|
-
color: COLORS.blue,
|
|
112
|
-
timestamp: new Date().toISOString(),
|
|
113
|
-
})
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Send task completed notification
|
|
118
|
-
*/
|
|
119
|
-
async notifyTaskComplete(task: PluginTask, duration: number): Promise<void> {
|
|
120
|
-
await this.sendEmbed({
|
|
121
|
-
title: `✅ Task Completed`,
|
|
122
|
-
description: `**${task.id}**: ${task.title}`,
|
|
123
|
-
color: COLORS.green,
|
|
124
|
-
fields: [
|
|
125
|
-
{ name: 'Duration', value: formatDuration(duration), inline: true },
|
|
126
|
-
],
|
|
127
|
-
timestamp: new Date().toISOString(),
|
|
128
|
-
})
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Send task failed notification
|
|
133
|
-
*/
|
|
134
|
-
async notifyTaskFailed(task: PluginTask, error: string, mention?: string): Promise<void> {
|
|
135
|
-
await this.send({
|
|
136
|
-
content: mention ? `${mention} Task failed!` : undefined,
|
|
137
|
-
embeds: [{
|
|
138
|
-
title: `❌ Task Failed`,
|
|
139
|
-
description: `**${task.id}**: ${task.title}`,
|
|
140
|
-
color: COLORS.red,
|
|
141
|
-
fields: [
|
|
142
|
-
{ name: 'Error', value: error.slice(0, 1000) },
|
|
143
|
-
],
|
|
144
|
-
timestamp: new Date().toISOString(),
|
|
145
|
-
}],
|
|
146
|
-
})
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Send loop summary notification
|
|
151
|
-
*/
|
|
152
|
-
async notifyLoopEnd(stats: { completed: number; failed: number; duration: number }): Promise<void> {
|
|
153
|
-
const color = stats.failed > 0 ? COLORS.yellow : COLORS.green
|
|
154
|
-
await this.sendEmbed({
|
|
155
|
-
title: `📊 Loop Summary`,
|
|
156
|
-
color,
|
|
157
|
-
fields: [
|
|
158
|
-
{ name: 'Completed', value: `${stats.completed}`, inline: true },
|
|
159
|
-
{ name: 'Failed', value: `${stats.failed}`, inline: true },
|
|
160
|
-
{ name: 'Duration', value: formatDuration(stats.duration), inline: true },
|
|
161
|
-
],
|
|
162
|
-
timestamp: new Date().toISOString(),
|
|
163
|
-
})
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Format seconds to human readable duration
|
|
169
|
-
*/
|
|
170
|
-
function formatDuration(seconds: number): string {
|
|
171
|
-
if (seconds < 60) return `${Math.round(seconds)}s`
|
|
172
|
-
const mins = Math.floor(seconds / 60)
|
|
173
|
-
const secs = Math.round(seconds % 60)
|
|
174
|
-
if (mins < 60) return `${mins}m ${secs}s`
|
|
175
|
-
const hours = Math.floor(mins / 60)
|
|
176
|
-
const remainMins = mins % 60
|
|
177
|
-
return `${hours}h ${remainMins}m`
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Create Discord plugin wrapper
|
|
182
|
-
*/
|
|
183
|
-
export function withDiscord(config: DiscordConfig = {}) {
|
|
184
|
-
const webhookUrl = config.webhookUrl || process.env.DISCORD_WEBHOOK_URL
|
|
185
|
-
|
|
186
|
-
return (baseConfig: any) => ({
|
|
187
|
-
...baseConfig,
|
|
188
|
-
discord: {
|
|
189
|
-
webhookUrl,
|
|
190
|
-
username: config.username || 'Loopwork',
|
|
191
|
-
avatarUrl: config.avatarUrl,
|
|
192
|
-
notifyOnStart: config.notifyOnStart ?? false,
|
|
193
|
-
notifyOnComplete: config.notifyOnComplete ?? true,
|
|
194
|
-
notifyOnFail: config.notifyOnFail ?? true,
|
|
195
|
-
notifyOnLoopEnd: config.notifyOnLoopEnd ?? true,
|
|
196
|
-
mentionOnFail: config.mentionOnFail,
|
|
197
|
-
},
|
|
198
|
-
})
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Create Discord hook plugin
|
|
203
|
-
*/
|
|
204
|
-
export function createDiscordPlugin(config: DiscordConfig = {}): LoopworkPlugin {
|
|
205
|
-
const webhookUrl = config.webhookUrl || process.env.DISCORD_WEBHOOK_URL || ''
|
|
206
|
-
const notifyOnStart = config.notifyOnStart ?? false
|
|
207
|
-
const notifyOnComplete = config.notifyOnComplete ?? true
|
|
208
|
-
const notifyOnFail = config.notifyOnFail ?? true
|
|
209
|
-
const notifyOnLoopEnd = config.notifyOnLoopEnd ?? true
|
|
210
|
-
|
|
211
|
-
if (!webhookUrl) {
|
|
212
|
-
return {
|
|
213
|
-
name: 'discord',
|
|
214
|
-
onConfigLoad: (cfg) => {
|
|
215
|
-
console.warn('Discord plugin: Missing DISCORD_WEBHOOK_URL')
|
|
216
|
-
return cfg
|
|
217
|
-
},
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const client = new DiscordClient(webhookUrl, {
|
|
222
|
-
username: config.username || 'Loopwork',
|
|
223
|
-
avatarUrl: config.avatarUrl,
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
return {
|
|
227
|
-
name: 'discord',
|
|
228
|
-
|
|
229
|
-
async onTaskStart(task) {
|
|
230
|
-
if (!notifyOnStart) return
|
|
231
|
-
|
|
232
|
-
try {
|
|
233
|
-
await client.notifyTaskStart(task)
|
|
234
|
-
} catch (e: any) {
|
|
235
|
-
console.warn(`Discord: Failed to send notification: ${e.message}`)
|
|
236
|
-
}
|
|
237
|
-
},
|
|
238
|
-
|
|
239
|
-
async onTaskComplete(task, result) {
|
|
240
|
-
if (!notifyOnComplete) return
|
|
241
|
-
|
|
242
|
-
try {
|
|
243
|
-
await client.notifyTaskComplete(task, result.duration)
|
|
244
|
-
} catch (e: any) {
|
|
245
|
-
console.warn(`Discord: Failed to send notification: ${e.message}`)
|
|
246
|
-
}
|
|
247
|
-
},
|
|
248
|
-
|
|
249
|
-
async onTaskFailed(task, error) {
|
|
250
|
-
if (!notifyOnFail) return
|
|
251
|
-
|
|
252
|
-
try {
|
|
253
|
-
await client.notifyTaskFailed(task, error, config.mentionOnFail)
|
|
254
|
-
} catch (e: any) {
|
|
255
|
-
console.warn(`Discord: Failed to send notification: ${e.message}`)
|
|
256
|
-
}
|
|
257
|
-
},
|
|
258
|
-
|
|
259
|
-
async onLoopEnd(stats) {
|
|
260
|
-
if (!notifyOnLoopEnd) return
|
|
261
|
-
|
|
262
|
-
try {
|
|
263
|
-
await client.notifyLoopEnd(stats)
|
|
264
|
-
} catch (e: any) {
|
|
265
|
-
console.warn(`Discord: Failed to send notification: ${e.message}`)
|
|
266
|
-
}
|
|
267
|
-
},
|
|
268
|
-
}
|
|
269
|
-
}
|