@gonzih/cc-discord 0.2.21 → 0.2.23
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/dist/bot.d.ts +11 -12
- package/dist/bot.js +47 -130
- package/dist/cron-engine.d.ts +12 -0
- package/dist/cron-engine.js +17 -3
- package/dist/index.js +3 -4
- package/dist/meta-agent-manager.d.ts +3 -2
- package/dist/meta-agent-manager.js +97 -40
- package/dist/notifier.d.ts +9 -1
- package/dist/notifier.js +23 -5
- package/package.json +2 -2
- package/dist/loop-manager.d.ts +0 -66
- package/dist/loop-manager.js +0 -118
package/dist/bot.d.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* One ClaudeProcess per channel (or channel:thread) — sessions are isolated per channel.
|
|
4
4
|
*/
|
|
5
5
|
import { Redis } from "ioredis";
|
|
6
|
-
import { type EvalReport } from "./loop-manager.js";
|
|
7
6
|
/** Returns true if the attachment name/contentType indicates an audio file. */
|
|
8
7
|
export declare function isAudioAttachment(name: string, contentType: string): boolean;
|
|
9
8
|
/** Build the prompt text for a file/document attachment, optionally with caption. */
|
|
@@ -40,7 +39,6 @@ export declare class CcDiscordBot {
|
|
|
40
39
|
private metaAgentManager;
|
|
41
40
|
/** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
|
|
42
41
|
private mcpSession?;
|
|
43
|
-
private loopManager?;
|
|
44
42
|
constructor(opts: DiscordBotOptions);
|
|
45
43
|
/** Reverse-lookup: find the channelId string for a cron-stored integer */
|
|
46
44
|
private snowflakeMap;
|
|
@@ -69,6 +67,12 @@ export declare class CcDiscordBot {
|
|
|
69
67
|
private sendToChannel;
|
|
70
68
|
/** Send to a channel by ID — used by notifier callbacks. */
|
|
71
69
|
sendToChannelById(channelId: string, text: string): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* After sending a Discord message on behalf of a cron, look up the pending cron ID
|
|
72
|
+
* for the channel's namespace and store cca:discord:cron-message:{messageId} = cronId.
|
|
73
|
+
* This lets the ❌/🛑 reaction handler disable the cron that fired this message.
|
|
74
|
+
*/
|
|
75
|
+
private storeCronMessageMapping;
|
|
72
76
|
/**
|
|
73
77
|
* Send text to a channel by ID, scanning for absolute file paths and attaching them.
|
|
74
78
|
* Used exclusively for Claude coordinator output (meta-agent flush).
|
|
@@ -115,18 +119,13 @@ export declare class CcDiscordBot {
|
|
|
115
119
|
/** Reverse lookup: find the Discord channelId registered for a given namespace. */
|
|
116
120
|
getChannelIdForNamespace(ns: string): string | undefined;
|
|
117
121
|
/** Return the thread ID for an active loop on `channelId`, or undefined. */
|
|
118
|
-
getLoopThreadId(
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
* Also records gate failures in the LoopManager if the gate did not pass.
|
|
122
|
-
*/
|
|
123
|
-
postEvalEmbed(channelId: string, report: EvalReport): Promise<void>;
|
|
122
|
+
getLoopThreadId(_channelId: string): string | undefined;
|
|
123
|
+
/** No-op stub kept for notifier compatibility — loop threads removed. */
|
|
124
|
+
postEvalEmbed(_channelId: string, _report: unknown): Promise<void>;
|
|
124
125
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
126
|
+
* Handle ❌/🛑 reactions on cron-fired messages to disable the cron.
|
|
127
|
+
* Also no-ops the old loop gate reactions (🔄/✅) gracefully.
|
|
127
128
|
*/
|
|
128
|
-
private createLoopThread;
|
|
129
|
-
/** Handle 🔄/✅/❌ reactions on loop gate messages. */
|
|
130
129
|
private handleReactionAdd;
|
|
131
130
|
/**
|
|
132
131
|
* Feed a text message into the active Claude session for the given channel.
|
package/dist/bot.js
CHANGED
|
@@ -14,9 +14,8 @@ import { transcribeVoice, isVoiceAvailable } from "./voice.js";
|
|
|
14
14
|
import { formatForDiscord, splitLongMessage, stripAnsi } from "./formatter.js";
|
|
15
15
|
import { getCurrentToken } from "./tokens.js";
|
|
16
16
|
import { writeChatLog } from "./notifier.js";
|
|
17
|
-
import { LoopManager, isGoalMessage } from "./loop-manager.js";
|
|
18
17
|
import { CronManager } from "./cron.js";
|
|
19
|
-
import { CronEngine } from "./cron-engine.js";
|
|
18
|
+
import { CronEngine, cronPendingKey, CRON_MESSAGE_TTL } from "./cron-engine.js";
|
|
20
19
|
import { parseChannelCreateIntent, routeToMetaAgent } from "./router.js";
|
|
21
20
|
import { createMetaAgentManager } from "./meta-agent-manager.js";
|
|
22
21
|
/** Convert a Discord snowflake string to a safe 53-bit integer for CronManager compatibility. */
|
|
@@ -126,14 +125,12 @@ export class CcDiscordBot {
|
|
|
126
125
|
metaAgentManager;
|
|
127
126
|
/** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
|
|
128
127
|
mcpSession;
|
|
129
|
-
loopManager;
|
|
130
128
|
constructor(opts) {
|
|
131
129
|
this.opts = opts;
|
|
132
130
|
this.redis = opts.redis;
|
|
133
131
|
this.namespace = opts.namespace ?? "default";
|
|
134
132
|
if (opts.redis) {
|
|
135
133
|
this.wire = createCcWire(opts.redis);
|
|
136
|
-
this.loopManager = new LoopManager(opts.redis);
|
|
137
134
|
this.cronEngine = new CronEngine(opts.redis);
|
|
138
135
|
}
|
|
139
136
|
this.metaAgentManager = createMetaAgentManager();
|
|
@@ -305,6 +302,26 @@ export class CcDiscordBot {
|
|
|
305
302
|
}
|
|
306
303
|
await this.sendToChannel(channel, text);
|
|
307
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* After sending a Discord message on behalf of a cron, look up the pending cron ID
|
|
307
|
+
* for the channel's namespace and store cca:discord:cron-message:{messageId} = cronId.
|
|
308
|
+
* This lets the ❌/🛑 reaction handler disable the cron that fired this message.
|
|
309
|
+
*/
|
|
310
|
+
async storeCronMessageMapping(messageId, channelId) {
|
|
311
|
+
if (!this.redis)
|
|
312
|
+
return;
|
|
313
|
+
const ns = this.resolveNamespaceForChannel(channelId);
|
|
314
|
+
try {
|
|
315
|
+
const cronId = await this.redis.get(cronPendingKey(ns));
|
|
316
|
+
if (!cronId)
|
|
317
|
+
return;
|
|
318
|
+
await this.redis.set(`cca:discord:cron-message:${messageId}`, cronId, "EX", CRON_MESSAGE_TTL);
|
|
319
|
+
console.log(`[bot] linked message ${messageId} → cron ${cronId} (ns=${ns})`);
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
console.warn(`[bot] storeCronMessageMapping failed (ns=${ns}):`, err.message);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
308
325
|
/**
|
|
309
326
|
* Send text to a channel by ID, scanning for absolute file paths and attaching them.
|
|
310
327
|
* Used exclusively for Claude coordinator output (meta-agent flush).
|
|
@@ -346,15 +363,19 @@ export class CcDiscordBot {
|
|
|
346
363
|
// Send first chunk with file attachments, remaining chunks as plain text
|
|
347
364
|
const files = validPaths.map((p) => ({ attachment: p, name: basename(p) }));
|
|
348
365
|
try {
|
|
349
|
-
await channel.send({ content: chunks[0] || undefined, files });
|
|
366
|
+
const sent = await channel.send({ content: chunks[0] || undefined, files });
|
|
367
|
+
void this.storeCronMessageMapping(sent.id, channelId);
|
|
350
368
|
}
|
|
351
369
|
catch (err) {
|
|
352
370
|
console.warn(`[bot] sendWithFileDetection attach failed:`, err.message);
|
|
353
371
|
// Fall back to plain text for the first chunk
|
|
354
372
|
if (chunks[0]?.trim()) {
|
|
355
|
-
await channel.send(chunks[0]).catch((e) => {
|
|
373
|
+
const sent = await channel.send(chunks[0]).catch((e) => {
|
|
356
374
|
console.error("[bot] sendWithFileDetection fallback failed:", e.message);
|
|
375
|
+
return null;
|
|
357
376
|
});
|
|
377
|
+
if (sent)
|
|
378
|
+
void this.storeCronMessageMapping(sent.id, channelId);
|
|
358
379
|
}
|
|
359
380
|
}
|
|
360
381
|
for (const chunk of chunks.slice(1)) {
|
|
@@ -440,43 +461,13 @@ export class CcDiscordBot {
|
|
|
440
461
|
return;
|
|
441
462
|
}
|
|
442
463
|
}
|
|
443
|
-
// Handle messages sent inside a loop thread — route to the running meta-agent session
|
|
444
|
-
if (msg.channel.isThread() && this.redis && this.loopManager) {
|
|
445
|
-
const parentId = msg.channel.parentId;
|
|
446
|
-
if (parentId) {
|
|
447
|
-
const loopState = this.loopManager.getState(parentId);
|
|
448
|
-
if (loopState && loopState.threadId === effectiveChannelId) {
|
|
449
|
-
const username = msg.member?.displayName ?? msg.author.username;
|
|
450
|
-
this.startMetaAgentTyping(effectiveChannelId, msg.channel);
|
|
451
|
-
try {
|
|
452
|
-
await routeToMetaAgent(loopState.namespace, stampPrompt(text, username, msg.createdAt), this.redis);
|
|
453
|
-
}
|
|
454
|
-
catch (err) {
|
|
455
|
-
await msg.channel.send(`Failed to route: ${err.message}`).catch(() => { });
|
|
456
|
-
}
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
464
|
// Channel registered via createChannelForRepo or /channel — route directly to its meta-agent
|
|
462
465
|
const mappedNs = this.channelNamespaceMap.get(effectiveChannelId);
|
|
463
466
|
if (mappedNs && this.redis) {
|
|
464
|
-
// If a loop is already running for this channel, point the user to the thread
|
|
465
|
-
if (this.loopManager?.isActive(effectiveChannelId)) {
|
|
466
|
-
const loopState = this.loopManager.getState(effectiveChannelId);
|
|
467
|
-
await msg.channel.send(`Loop in progress → <#${loopState.threadId}>\nSend messages in the thread to interact with the running loop.`).catch(() => { });
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
467
|
this.writeChatMessage("user", "discord", text, effectiveChannelId, mappedNs.namespace);
|
|
471
468
|
this.opts.registerRoutedChannelId?.(mappedNs.namespace, effectiveChannelId);
|
|
472
469
|
this.persistChannelMapping(effectiveChannelId, mappedNs.namespace, mappedNs.repoUrl);
|
|
473
470
|
this.startMetaAgentTyping(effectiveChannelId, msg.channel);
|
|
474
|
-
// Detect goal messages → create a thread for loop tracking
|
|
475
|
-
if (this.loopManager && msg.guild && !msg.channel.isThread()) {
|
|
476
|
-
if (isGoalMessage(text)) {
|
|
477
|
-
await this.createLoopThread(msg, effectiveChannelId, mappedNs.namespace, text);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
471
|
const username = msg.member?.displayName ?? msg.author.username;
|
|
481
472
|
try {
|
|
482
473
|
await routeToMetaAgent(mappedNs.namespace, stampPrompt(text, username, msg.createdAt), this.redis);
|
|
@@ -1243,74 +1234,26 @@ export class CcDiscordBot {
|
|
|
1243
1234
|
return undefined;
|
|
1244
1235
|
}
|
|
1245
1236
|
/** Return the thread ID for an active loop on `channelId`, or undefined. */
|
|
1246
|
-
getLoopThreadId(
|
|
1247
|
-
|
|
1237
|
+
getLoopThreadId(_channelId) {
|
|
1238
|
+
// Loop-as-threads removed — cron engine is the loop mechanism.
|
|
1239
|
+
return undefined;
|
|
1248
1240
|
}
|
|
1249
|
-
/**
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
*/
|
|
1253
|
-
async postEvalEmbed(channelId, report) {
|
|
1254
|
-
const threadId = this.loopManager?.getThreadId(channelId);
|
|
1255
|
-
if (!threadId)
|
|
1256
|
-
return;
|
|
1257
|
-
const channel = await this.getChannel(threadId);
|
|
1258
|
-
if (!channel)
|
|
1259
|
-
return;
|
|
1260
|
-
const color = report.passed ? 0x57F287 : 0xED4245; // green / red
|
|
1261
|
-
const embed = new EmbedBuilder()
|
|
1262
|
-
.setTitle(`${report.passed ? "✅" : "❌"} Gate: ${report.gate}`)
|
|
1263
|
-
.setColor(color)
|
|
1264
|
-
.addFields({ name: "Result", value: report.passed ? "PASSED" : "FAILED", inline: true }, { name: "Confidence", value: `${(report.confidence * 100).toFixed(0)}%`, inline: true }, { name: "Iteration", value: `${report.iteration} / ${report.maxIterations}`, inline: true }, { name: "Feedback", value: report.feedback || "(none)", inline: false })
|
|
1265
|
-
.setTimestamp();
|
|
1266
|
-
await channel.send({ embeds: [embed] }).catch((err) => {
|
|
1267
|
-
console.warn(`[bot] postEvalEmbed send failed:`, err.message);
|
|
1268
|
-
});
|
|
1269
|
-
if (!report.passed && this.loopManager) {
|
|
1270
|
-
await this.loopManager.addGateFailure(channelId, {
|
|
1271
|
-
gate: report.gate,
|
|
1272
|
-
feedback: report.feedback,
|
|
1273
|
-
iteration: report.iteration,
|
|
1274
|
-
confidence: report.confidence,
|
|
1275
|
-
timestamp: new Date().toISOString(),
|
|
1276
|
-
});
|
|
1277
|
-
}
|
|
1241
|
+
/** No-op stub kept for notifier compatibility — loop threads removed. */
|
|
1242
|
+
async postEvalEmbed(_channelId, _report) {
|
|
1243
|
+
return;
|
|
1278
1244
|
}
|
|
1279
1245
|
/**
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1246
|
+
* Handle ❌/🛑 reactions on cron-fired messages to disable the cron.
|
|
1247
|
+
* Also no-ops the old loop gate reactions (🔄/✅) gracefully.
|
|
1282
1248
|
*/
|
|
1283
|
-
async createLoopThread(msg, channelId, namespace, goal) {
|
|
1284
|
-
try {
|
|
1285
|
-
const shortGoal = goal.slice(0, 48).replace(/\s+/g, " ");
|
|
1286
|
-
const threadName = `Goal: ${shortGoal}${goal.length > 48 ? "…" : ""}`;
|
|
1287
|
-
const thread = await msg.channel.threads.create({
|
|
1288
|
-
name: threadName,
|
|
1289
|
-
autoArchiveDuration: 10080, // 1 week
|
|
1290
|
-
startMessage: msg,
|
|
1291
|
-
reason: "Loop observability thread",
|
|
1292
|
-
});
|
|
1293
|
-
const firstMsg = await thread.send(`🎯 **Loop tracking thread**\n> ${goal.slice(0, 200)}\n\nReact to control the loop:\n` +
|
|
1294
|
-
`🔄 Retry current iteration ✅ Accept & exit ❌ Kill loop`);
|
|
1295
|
-
await firstMsg.react("🔄");
|
|
1296
|
-
await firstMsg.react("✅");
|
|
1297
|
-
await firstMsg.react("❌");
|
|
1298
|
-
await this.loopManager.startLoop(channelId, thread.id, firstMsg.id, namespace, goal);
|
|
1299
|
-
console.log(`[bot] loop thread created: channelId=${channelId} threadId=${thread.id}`);
|
|
1300
|
-
}
|
|
1301
|
-
catch (err) {
|
|
1302
|
-
console.warn(`[bot] createLoopThread failed:`, err.message);
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
/** Handle 🔄/✅/❌ reactions on loop gate messages. */
|
|
1306
1249
|
async handleReactionAdd(reaction, user) {
|
|
1307
1250
|
if (user.bot)
|
|
1308
1251
|
return;
|
|
1309
1252
|
if (!this.isAllowed(user.id))
|
|
1310
1253
|
return;
|
|
1311
|
-
if (!this.
|
|
1254
|
+
if (!this.redis || !this.cronEngine)
|
|
1312
1255
|
return;
|
|
1313
|
-
// Fetch full reaction object if partial
|
|
1256
|
+
// Fetch full reaction object if partial
|
|
1314
1257
|
let fullReaction = reaction;
|
|
1315
1258
|
if (reaction.partial) {
|
|
1316
1259
|
try {
|
|
@@ -1321,46 +1264,20 @@ export class CcDiscordBot {
|
|
|
1321
1264
|
}
|
|
1322
1265
|
}
|
|
1323
1266
|
const emoji = fullReaction.emoji.name;
|
|
1324
|
-
if (emoji !== "
|
|
1267
|
+
if (emoji !== "❌" && emoji !== "🛑")
|
|
1325
1268
|
return;
|
|
1326
1269
|
const messageId = fullReaction.message.id;
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
const loopState = this.loopManager.getState(channelId);
|
|
1331
|
-
if (!loopState)
|
|
1270
|
+
// Look up which cron fired this message
|
|
1271
|
+
const cronId = await this.redis.get(`cca:discord:cron-message:${messageId}`).catch(() => null);
|
|
1272
|
+
if (!cronId)
|
|
1332
1273
|
return;
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
}
|
|
1338
|
-
try {
|
|
1339
|
-
await routeToMetaAgent(loopState.namespace, `Please retry the previous goal: ${loopState.goal}`, this.redis);
|
|
1340
|
-
}
|
|
1341
|
-
catch (err) {
|
|
1342
|
-
console.warn(`[bot] retry routeToMetaAgent failed:`, err.message);
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
else if (emoji === "✅") {
|
|
1346
|
-
if (thread) {
|
|
1347
|
-
await thread.send("✅ Loop accepted — marking complete").catch(() => { });
|
|
1348
|
-
}
|
|
1349
|
-
await this.sendToChannelById(channelId, `✅ Goal completed: ${loopState.goal.slice(0, 100)}`);
|
|
1350
|
-
await this.loopManager.endLoop(channelId);
|
|
1351
|
-
if (thread && thread.isThread()) {
|
|
1352
|
-
await thread.setArchived(true).catch(() => { });
|
|
1353
|
-
}
|
|
1274
|
+
// Disable the cron by setting enabled="false" in its hash
|
|
1275
|
+
try {
|
|
1276
|
+
await this.redis.hset(`cca:discord:cron:${cronId}`, "enabled", "0");
|
|
1277
|
+
console.log(`[bot] cron ${cronId} disabled via ${emoji} reaction on message ${messageId}`);
|
|
1354
1278
|
}
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
await thread.send("❌ Loop killed by user").catch(() => { });
|
|
1358
|
-
}
|
|
1359
|
-
await this.sendToChannelById(channelId, `❌ Loop killed: ${loopState.goal.slice(0, 100)}`);
|
|
1360
|
-
await this.loopManager.endLoop(channelId);
|
|
1361
|
-
if (thread && thread.isThread()) {
|
|
1362
|
-
await thread.setArchived(true).catch(() => { });
|
|
1363
|
-
}
|
|
1279
|
+
catch (err) {
|
|
1280
|
+
console.warn(`[bot] failed to disable cron ${cronId}:`, err.message);
|
|
1364
1281
|
}
|
|
1365
1282
|
}
|
|
1366
1283
|
/**
|
package/dist/cron-engine.d.ts
CHANGED
|
@@ -17,6 +17,18 @@
|
|
|
17
17
|
* 5. Update last_fired_at
|
|
18
18
|
*/
|
|
19
19
|
import type { Redis } from "ioredis";
|
|
20
|
+
/**
|
|
21
|
+
* Temporary key written by the cron engine when it fires a cron.
|
|
22
|
+
* Value is the cronId that fired. TTL 300s.
|
|
23
|
+
* The bot reads this after sending a Discord message to link the message to the cron.
|
|
24
|
+
*/
|
|
25
|
+
export declare const cronPendingKey: (ns: string) => string;
|
|
26
|
+
/**
|
|
27
|
+
* Key that maps a sent Discord messageId → cronId.
|
|
28
|
+
* Used by the reaction handler to look up which cron to disable.
|
|
29
|
+
* TTL 86400s (24h).
|
|
30
|
+
*/
|
|
31
|
+
export declare const CRON_MESSAGE_TTL = 86400;
|
|
20
32
|
export interface CronRecord {
|
|
21
33
|
id: string;
|
|
22
34
|
namespace: string;
|
package/dist/cron-engine.js
CHANGED
|
@@ -18,10 +18,21 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { randomUUID } from "crypto";
|
|
20
20
|
import { schedule, validate } from "node-cron";
|
|
21
|
+
import { cronListKey, cronHashKey, discordMetaInputKey as metaInputKey, } from "@gonzih/cc-wire";
|
|
21
22
|
// ─── Redis key helpers ────────────────────────────────────────────────────────
|
|
22
|
-
const CRON_LIST_KEY =
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
const CRON_LIST_KEY = cronListKey();
|
|
24
|
+
/**
|
|
25
|
+
* Temporary key written by the cron engine when it fires a cron.
|
|
26
|
+
* Value is the cronId that fired. TTL 300s.
|
|
27
|
+
* The bot reads this after sending a Discord message to link the message to the cron.
|
|
28
|
+
*/
|
|
29
|
+
export const cronPendingKey = (ns) => `cca:discord:cron-pending:${ns}`;
|
|
30
|
+
/**
|
|
31
|
+
* Key that maps a sent Discord messageId → cronId.
|
|
32
|
+
* Used by the reaction handler to look up which cron to disable.
|
|
33
|
+
* TTL 86400s (24h).
|
|
34
|
+
*/
|
|
35
|
+
export const CRON_MESSAGE_TTL = 86400;
|
|
25
36
|
// ─── CronEngine ──────────────────────────────────────────────────────────────
|
|
26
37
|
export class CronEngine {
|
|
27
38
|
redis;
|
|
@@ -230,6 +241,9 @@ export class CronEngine {
|
|
|
230
241
|
await this.redis.rpush(inputKey, entry);
|
|
231
242
|
rec.last_fired_at = new Date().toISOString();
|
|
232
243
|
console.log(`[cron-engine] fire: pushed message (id=${id} ns=${ns} fire_count=${rec.fire_count})`);
|
|
244
|
+
// Record which cron fired for this namespace — the bot reads this after
|
|
245
|
+
// sending the Discord message to store the cca:discord:cron-message:{msgId} mapping.
|
|
246
|
+
await this.redis.set(cronPendingKey(ns), id, "EX", 300);
|
|
233
247
|
}
|
|
234
248
|
catch (err) {
|
|
235
249
|
console.warn(`[cron-engine] fire: push failed (id=${id}):`, err.message);
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { createRequire } from "node:module";
|
|
23
23
|
import { randomUUID } from "crypto";
|
|
24
24
|
import { Redis } from "ioredis";
|
|
25
|
-
import { createCcWire } from "@gonzih/cc-wire";
|
|
25
|
+
import { createCcWire, DISCORD_INSTANCE_KEY } from "@gonzih/cc-wire";
|
|
26
26
|
import { CcDiscordBot } from "./bot.js";
|
|
27
27
|
import { startNotifier } from "./notifier.js";
|
|
28
28
|
import { loadTokens } from "./tokens.js";
|
|
@@ -80,7 +80,6 @@ sharedRedis.on("error", (err) => {
|
|
|
80
80
|
const wire = createCcWire(sharedRedis);
|
|
81
81
|
// Singleton instance ID — used to detect stale processes after a restart
|
|
82
82
|
const instanceId = randomUUID();
|
|
83
|
-
const INSTANCE_KEY = "cca:discord:instance";
|
|
84
83
|
const INSTANCE_TTL_MS = 30_000;
|
|
85
84
|
const INSTANCE_REFRESH_MS = 10_000;
|
|
86
85
|
sharedRedis.once("ready", () => {
|
|
@@ -90,13 +89,13 @@ sharedRedis.once("ready", () => {
|
|
|
90
89
|
});
|
|
91
90
|
console.log(`[cc-discord] version:reported ${version}`);
|
|
92
91
|
// Write singleton instance ID with 30s TTL
|
|
93
|
-
sharedRedis.set(
|
|
92
|
+
sharedRedis.set(DISCORD_INSTANCE_KEY, instanceId, "PX", INSTANCE_TTL_MS).catch((err) => {
|
|
94
93
|
console.warn("[cc-discord] failed to write instance ID:", err.message);
|
|
95
94
|
});
|
|
96
95
|
console.log(`[cc-discord] instance:${instanceId}`);
|
|
97
96
|
// Refresh TTL every 10s so launchd-respawned processes displace old ones
|
|
98
97
|
setInterval(() => {
|
|
99
|
-
sharedRedis.set(
|
|
98
|
+
sharedRedis.set(DISCORD_INSTANCE_KEY, instanceId, "PX", INSTANCE_TTL_MS).catch((err) => {
|
|
100
99
|
console.warn("[cc-discord] instance refresh failed:", err.message);
|
|
101
100
|
});
|
|
102
101
|
}, INSTANCE_REFRESH_MS);
|
|
@@ -34,9 +34,10 @@ export declare function injectMcp(ns: string, wsPath: string, token: string): vo
|
|
|
34
34
|
* Redis keys for meta-agent stdout streaming.
|
|
35
35
|
* cca:meta:{ns}:stream — pub/sub channel (live streaming)
|
|
36
36
|
* cca:meta:{ns}:log — list (history, capped at 2000)
|
|
37
|
+
* Re-exported from cc-wire for backwards-compat with callers in this package.
|
|
37
38
|
*/
|
|
38
|
-
export declare
|
|
39
|
-
export declare
|
|
39
|
+
export declare const metaStreamChannel: (ns: string) => string;
|
|
40
|
+
export declare const metaLogKey: (ns: string) => string;
|
|
40
41
|
/**
|
|
41
42
|
* Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
|
|
42
43
|
* namespace workspace. Pipes stdout line-by-line → wire.discord.publishOutgoing.
|
|
@@ -11,7 +11,7 @@ import { spawn, execSync } from "child_process";
|
|
|
11
11
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
import { homedir } from "os";
|
|
14
|
-
import { CC_DISCORD_WORKSPACE_ROOT, TIMING, discordMetaInputKey, } from "@gonzih/cc-wire";
|
|
14
|
+
import { CC_DISCORD_WORKSPACE_ROOT, TIMING, DISCORD_INSTANCE_KEY, discordMetaInputKey, metaStreamChannel as ccWireMetaStreamChannel, metaLogKey as ccWireMetaLogKey, } from "@gonzih/cc-wire";
|
|
15
15
|
const WORKSPACE_ROOT = join(homedir(), CC_DISCORD_WORKSPACE_ROOT);
|
|
16
16
|
/**
|
|
17
17
|
* Returns the path to the workspace for the given namespace.
|
|
@@ -120,13 +120,10 @@ function resolveClaude() {
|
|
|
120
120
|
* Redis keys for meta-agent stdout streaming.
|
|
121
121
|
* cca:meta:{ns}:stream — pub/sub channel (live streaming)
|
|
122
122
|
* cca:meta:{ns}:log — list (history, capped at 2000)
|
|
123
|
+
* Re-exported from cc-wire for backwards-compat with callers in this package.
|
|
123
124
|
*/
|
|
124
|
-
export
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
export function metaLogKey(ns) {
|
|
128
|
-
return `cca:meta:${ns}:log`;
|
|
129
|
-
}
|
|
125
|
+
export const metaStreamChannel = ccWireMetaStreamChannel;
|
|
126
|
+
export const metaLogKey = ccWireMetaLogKey;
|
|
130
127
|
/**
|
|
131
128
|
* Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
|
|
132
129
|
* namespace workspace. Pipes stdout line-by-line → wire.discord.publishOutgoing.
|
|
@@ -149,52 +146,113 @@ export function spawnSession(ns, message, token, wire) {
|
|
|
149
146
|
const proc = spawn(claudeBin, [
|
|
150
147
|
"--continue",
|
|
151
148
|
"-p", message,
|
|
152
|
-
"--output-format", "
|
|
149
|
+
"--output-format", "stream-json",
|
|
153
150
|
"--verbose",
|
|
154
151
|
"--dangerously-skip-permissions",
|
|
155
152
|
], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
156
153
|
let lineBuffer = "";
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
role: "assistant",
|
|
165
|
-
content: trimmed,
|
|
166
|
-
timestamp: new Date().toISOString(),
|
|
167
|
-
chatId: 0,
|
|
168
|
-
};
|
|
169
|
-
wire.discord.publishOutgoing(ns, msg).catch((err) => {
|
|
170
|
-
console.warn(`[meta-agent-manager] publishOutgoing failed (ns=${ns}):`, err.message);
|
|
171
|
-
});
|
|
172
|
-
};
|
|
154
|
+
/**
|
|
155
|
+
* Parse a JSONL line from claude's stream-json stdout into a structured event.
|
|
156
|
+
* For lines that fail JSON.parse, emit { type: "text", text: line }.
|
|
157
|
+
* Forward each event as a JSON string to Redis:
|
|
158
|
+
* PUBLISH cca:meta:{ns}:stream
|
|
159
|
+
* LPUSH cca:meta:{ns}:log (capped at 2000)
|
|
160
|
+
*/
|
|
173
161
|
const rawRedis = wire._redis;
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// Publish for live consumers (fire-and-forget)
|
|
179
|
-
rawRedis.publish(streamCh, entry).catch((err) => {
|
|
162
|
+
const streamCh = metaStreamChannel(ns);
|
|
163
|
+
const logKey = metaLogKey(ns);
|
|
164
|
+
const forwardEventToRedis = (eventJson) => {
|
|
165
|
+
rawRedis.publish(streamCh, eventJson).catch((err) => {
|
|
180
166
|
console.warn(`[meta-agent-manager] stream publish failed (ns=${ns}):`, err.message);
|
|
181
167
|
});
|
|
182
|
-
|
|
183
|
-
rawRedis.lpush(logKey, entry).then(() => {
|
|
168
|
+
rawRedis.lpush(logKey, eventJson).then(() => {
|
|
184
169
|
rawRedis.ltrim(logKey, 0, 1999).catch(() => { });
|
|
185
170
|
}).catch((err) => {
|
|
186
171
|
console.warn(`[meta-agent-manager] log lpush failed (ns=${ns}):`, err.message);
|
|
187
172
|
});
|
|
188
173
|
};
|
|
174
|
+
const processLine = (line) => {
|
|
175
|
+
const trimmed = line.trim();
|
|
176
|
+
if (!trimmed)
|
|
177
|
+
return;
|
|
178
|
+
let structuredEvent;
|
|
179
|
+
try {
|
|
180
|
+
const parsed = JSON.parse(trimmed);
|
|
181
|
+
// Map claude stream-json event shapes to our canonical structured event format
|
|
182
|
+
const type = parsed.type;
|
|
183
|
+
if (type === "assistant") {
|
|
184
|
+
// Extract text from content blocks
|
|
185
|
+
const content = parsed.message;
|
|
186
|
+
const textBlock = content?.content?.find((b) => b.type === "text");
|
|
187
|
+
const text = textBlock?.text ?? "";
|
|
188
|
+
structuredEvent = { type: "assistant", text };
|
|
189
|
+
// Also publish to Discord outgoing channel
|
|
190
|
+
if (text) {
|
|
191
|
+
const msg = {
|
|
192
|
+
id: crypto.randomUUID(),
|
|
193
|
+
source: "claude",
|
|
194
|
+
role: "assistant",
|
|
195
|
+
content: text,
|
|
196
|
+
timestamp: new Date().toISOString(),
|
|
197
|
+
chatId: 0,
|
|
198
|
+
};
|
|
199
|
+
wire.discord.publishOutgoing(ns, msg).catch((err) => {
|
|
200
|
+
console.warn(`[meta-agent-manager] publishOutgoing failed (ns=${ns}):`, err.message);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else if (type === "tool_use") {
|
|
205
|
+
structuredEvent = {
|
|
206
|
+
type: "tool_use",
|
|
207
|
+
name: parsed.name ?? "",
|
|
208
|
+
input: parsed.input ?? {},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
else if (type === "tool_result") {
|
|
212
|
+
structuredEvent = {
|
|
213
|
+
type: "tool_result",
|
|
214
|
+
content: parsed.content ?? "",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
else if (type === "result") {
|
|
218
|
+
const resultText = typeof parsed.result === "string" ? parsed.result : JSON.stringify(parsed.result ?? "");
|
|
219
|
+
structuredEvent = {
|
|
220
|
+
type: "result",
|
|
221
|
+
result: resultText,
|
|
222
|
+
is_error: parsed.is_error ?? false,
|
|
223
|
+
};
|
|
224
|
+
// Publish result text to Discord as final assistant message
|
|
225
|
+
if (resultText) {
|
|
226
|
+
const msg = {
|
|
227
|
+
id: crypto.randomUUID(),
|
|
228
|
+
source: "claude",
|
|
229
|
+
role: "assistant",
|
|
230
|
+
content: resultText,
|
|
231
|
+
timestamp: new Date().toISOString(),
|
|
232
|
+
chatId: 0,
|
|
233
|
+
};
|
|
234
|
+
wire.discord.publishOutgoing(ns, msg).catch((err) => {
|
|
235
|
+
console.warn(`[meta-agent-manager] publishOutgoing (result) failed (ns=${ns}):`, err.message);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
// Forward other event types as-is
|
|
241
|
+
structuredEvent = parsed;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// Non-JSON line — wrap as text event
|
|
246
|
+
structuredEvent = { type: "text", text: trimmed };
|
|
247
|
+
}
|
|
248
|
+
forwardEventToRedis(JSON.stringify(structuredEvent));
|
|
249
|
+
};
|
|
189
250
|
proc.stdout.on("data", (chunk) => {
|
|
190
|
-
|
|
191
|
-
// Stream raw chunk to Redis (before line-splitting)
|
|
192
|
-
streamToRedis(chunkStr);
|
|
193
|
-
lineBuffer += chunkStr;
|
|
251
|
+
lineBuffer += chunk.toString();
|
|
194
252
|
const lines = lineBuffer.split("\n");
|
|
195
253
|
lineBuffer = lines.pop() ?? "";
|
|
196
254
|
for (const line of lines) {
|
|
197
|
-
|
|
255
|
+
processLine(line);
|
|
198
256
|
}
|
|
199
257
|
});
|
|
200
258
|
proc.stderr.on("data", (chunk) => {
|
|
@@ -205,7 +263,7 @@ export function spawnSession(ns, message, token, wire) {
|
|
|
205
263
|
proc.on("exit", (code) => {
|
|
206
264
|
// Flush any remaining buffered content
|
|
207
265
|
if (lineBuffer.trim())
|
|
208
|
-
|
|
266
|
+
processLine(lineBuffer);
|
|
209
267
|
lineBuffer = "";
|
|
210
268
|
console.log(`[meta-agent-manager] session exited (ns=${ns}, code=${code})`);
|
|
211
269
|
resolve();
|
|
@@ -233,7 +291,6 @@ export function createMetaAgentManager() {
|
|
|
233
291
|
startPolling(wire, getNamespaces, instanceId) {
|
|
234
292
|
if (pollInterval)
|
|
235
293
|
return; // already running
|
|
236
|
-
const INSTANCE_KEY = "cca:discord:instance";
|
|
237
294
|
pollInterval = setInterval(() => {
|
|
238
295
|
const namespaces = getNamespaces();
|
|
239
296
|
if (namespaces.length === 0)
|
|
@@ -248,7 +305,7 @@ export function createMetaAgentManager() {
|
|
|
248
305
|
// Staleness check: if a newer instance has registered, this process is stale — exit.
|
|
249
306
|
if (instanceId) {
|
|
250
307
|
try {
|
|
251
|
-
const current = await wire._redis.get(
|
|
308
|
+
const current = await wire._redis.get(DISCORD_INSTANCE_KEY);
|
|
252
309
|
if (current && current !== instanceId) {
|
|
253
310
|
console.log(`[meta-agent-manager] stale instance detected (current=${current}, ours=${instanceId}) — exiting`);
|
|
254
311
|
process.exit(0);
|
package/dist/notifier.d.ts
CHANGED
|
@@ -11,7 +11,15 @@
|
|
|
11
11
|
* cca:discord:chat:outgoing:{ns} — PUBLISH for web UI to consume
|
|
12
12
|
*/
|
|
13
13
|
import { Redis } from "ioredis";
|
|
14
|
-
|
|
14
|
+
/** Eval report from a meta-agent notification. */
|
|
15
|
+
export interface EvalReport {
|
|
16
|
+
gate: string;
|
|
17
|
+
passed: boolean;
|
|
18
|
+
feedback: string;
|
|
19
|
+
iteration: number;
|
|
20
|
+
maxIterations: number;
|
|
21
|
+
confidence: number;
|
|
22
|
+
}
|
|
15
23
|
import type { CcDiscordBot } from "./bot.js";
|
|
16
24
|
export interface ChatMessage {
|
|
17
25
|
id: string;
|
package/dist/notifier.js
CHANGED
|
@@ -11,12 +11,30 @@
|
|
|
11
11
|
* cca:discord:chat:outgoing:{ns} — PUBLISH for web UI to consume
|
|
12
12
|
*/
|
|
13
13
|
import { createHash } from "crypto";
|
|
14
|
-
import { discordChatLog, discordChatOutgoing, discordNotify, notifyChannel, chatIncomingChannel, createCcWire, TIMING, } from "@gonzih/cc-wire";
|
|
14
|
+
import { discordChatLog, discordChatOutgoing, discordNotify, notifyChannel, chatIncomingChannel, createCcWire, TIMING, dedupKey, } from "@gonzih/cc-wire";
|
|
15
15
|
import { splitLongMessage, stripAnsi } from "./formatter.js";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Parse an `eval_report` object embedded in a raw notification JSON string.
|
|
18
|
+
* Returns null when the field is absent, malformed, or the input is not JSON.
|
|
19
|
+
*/
|
|
20
|
+
function parseEvalReport(raw) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
const r = parsed.eval_report;
|
|
24
|
+
if (!r || typeof r.gate !== "string" || typeof r.passed !== "boolean")
|
|
25
|
+
return null;
|
|
26
|
+
return {
|
|
27
|
+
gate: r.gate,
|
|
28
|
+
passed: r.passed,
|
|
29
|
+
feedback: typeof r.feedback === "string" ? r.feedback : "",
|
|
30
|
+
iteration: typeof r.iteration === "number" ? r.iteration : 0,
|
|
31
|
+
maxIterations: typeof r.max_iterations === "number" ? r.max_iterations : 0,
|
|
32
|
+
confidence: typeof r.confidence === "number" ? r.confidence : 0,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
20
38
|
}
|
|
21
39
|
/** Compute a short stable dedup fingerprint for a raw notification string */
|
|
22
40
|
function notifFingerprint(raw) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonzih/cc-discord",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.23",
|
|
4
4
|
"description": "Claude Code Discord bot — chat with Claude Code via Discord",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"dist/"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@gonzih/cc-wire": "^0.
|
|
22
|
+
"@gonzih/cc-wire": "^0.4.0",
|
|
23
23
|
"@types/node-cron": "^3.0.11",
|
|
24
24
|
"discord.js": "^14.0.0",
|
|
25
25
|
"ioredis": "^5.0.0",
|
package/dist/loop-manager.d.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Loop observability for cc-discord meta-agent channels.
|
|
3
|
-
*
|
|
4
|
-
* When a user sends a goal-oriented message to a meta-agent channel the bot
|
|
5
|
-
* creates a Discord thread on that message, tracks iteration state in Redis,
|
|
6
|
-
* and lets human operators steer the loop via 🔄/✅/❌ reactions.
|
|
7
|
-
*
|
|
8
|
-
* Redis layout:
|
|
9
|
-
* cca:discord:loop:{channelId}:{threadId} HASH TTL 24h
|
|
10
|
-
* goal → string
|
|
11
|
-
* namespace → string
|
|
12
|
-
* iteration → stringified number
|
|
13
|
-
* max_iterations → stringified number
|
|
14
|
-
* thread_id → string
|
|
15
|
-
* gate_failures → JSON array of GateFailure
|
|
16
|
-
*/
|
|
17
|
-
import type { Redis } from "ioredis";
|
|
18
|
-
export interface GateFailure {
|
|
19
|
-
gate: string;
|
|
20
|
-
feedback: string;
|
|
21
|
-
iteration: number;
|
|
22
|
-
confidence: number;
|
|
23
|
-
timestamp: string;
|
|
24
|
-
}
|
|
25
|
-
export interface LoopState {
|
|
26
|
-
channelId: string;
|
|
27
|
-
threadId: string;
|
|
28
|
-
/** ID of the first message posted inside the thread (reactions live here). */
|
|
29
|
-
goalMessageId: string;
|
|
30
|
-
namespace: string;
|
|
31
|
-
goal: string;
|
|
32
|
-
iteration: number;
|
|
33
|
-
maxIterations: number;
|
|
34
|
-
gateFailures: GateFailure[];
|
|
35
|
-
}
|
|
36
|
-
export interface EvalReport {
|
|
37
|
-
gate: string;
|
|
38
|
-
passed: boolean;
|
|
39
|
-
feedback: string;
|
|
40
|
-
iteration: number;
|
|
41
|
-
maxIterations: number;
|
|
42
|
-
confidence: number;
|
|
43
|
-
}
|
|
44
|
-
/** Return true when `text` looks like an action goal rather than a question or chat. */
|
|
45
|
-
export declare function isGoalMessage(text: string): boolean;
|
|
46
|
-
/**
|
|
47
|
-
* Parse an `eval_report` object embedded in a raw notification JSON string.
|
|
48
|
-
* Returns null when the field is absent, malformed, or the input is not JSON.
|
|
49
|
-
*/
|
|
50
|
-
export declare function parseEvalReport(raw: string): EvalReport | null;
|
|
51
|
-
export declare class LoopManager {
|
|
52
|
-
private redis;
|
|
53
|
-
/** channelId → LoopState */
|
|
54
|
-
private activeLoops;
|
|
55
|
-
/** reactionMessageId → channelId — for O(1) lookup on reaction events */
|
|
56
|
-
private reactionMessageMap;
|
|
57
|
-
constructor(redis: Redis);
|
|
58
|
-
startLoop(channelId: string, threadId: string, goalMessageId: string, namespace: string, goal: string, maxIterations?: number): Promise<LoopState>;
|
|
59
|
-
isActive(channelId: string): boolean;
|
|
60
|
-
getState(channelId: string): LoopState | undefined;
|
|
61
|
-
getThreadId(channelId: string): string | undefined;
|
|
62
|
-
/** Look up the main channel ID from a reaction message ID. */
|
|
63
|
-
getChannelIdByReactionMessage(messageId: string): string | undefined;
|
|
64
|
-
addGateFailure(channelId: string, failure: GateFailure): Promise<void>;
|
|
65
|
-
endLoop(channelId: string): Promise<void>;
|
|
66
|
-
}
|
package/dist/loop-manager.js
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Loop observability for cc-discord meta-agent channels.
|
|
3
|
-
*
|
|
4
|
-
* When a user sends a goal-oriented message to a meta-agent channel the bot
|
|
5
|
-
* creates a Discord thread on that message, tracks iteration state in Redis,
|
|
6
|
-
* and lets human operators steer the loop via 🔄/✅/❌ reactions.
|
|
7
|
-
*
|
|
8
|
-
* Redis layout:
|
|
9
|
-
* cca:discord:loop:{channelId}:{threadId} HASH TTL 24h
|
|
10
|
-
* goal → string
|
|
11
|
-
* namespace → string
|
|
12
|
-
* iteration → stringified number
|
|
13
|
-
* max_iterations → stringified number
|
|
14
|
-
* thread_id → string
|
|
15
|
-
* gate_failures → JSON array of GateFailure
|
|
16
|
-
*/
|
|
17
|
-
const LOOP_TTL = 86_400; // 24h in seconds
|
|
18
|
-
function loopKey(channelId, threadId) {
|
|
19
|
-
return `cca:discord:loop:${channelId}:${threadId}`;
|
|
20
|
-
}
|
|
21
|
-
// Action-verb heuristic: messages that start with one of these words are treated as goals.
|
|
22
|
-
const GOAL_RE = /^(create|build|write|implement|fix|add|make|generate|refactor|set\s+up|update|deploy|run|test|check|migrate|convert|analyze|summarize|extract|draft|scaffold|configure|install|delete|remove|optimize|debug|review|audit|plan|design|port|bump|release)\b/i;
|
|
23
|
-
/** Return true when `text` looks like an action goal rather than a question or chat. */
|
|
24
|
-
export function isGoalMessage(text) {
|
|
25
|
-
return GOAL_RE.test(text.trim());
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Parse an `eval_report` object embedded in a raw notification JSON string.
|
|
29
|
-
* Returns null when the field is absent, malformed, or the input is not JSON.
|
|
30
|
-
*/
|
|
31
|
-
export function parseEvalReport(raw) {
|
|
32
|
-
try {
|
|
33
|
-
const parsed = JSON.parse(raw);
|
|
34
|
-
const r = parsed.eval_report;
|
|
35
|
-
if (!r || typeof r.gate !== "string" || typeof r.passed !== "boolean")
|
|
36
|
-
return null;
|
|
37
|
-
return {
|
|
38
|
-
gate: r.gate,
|
|
39
|
-
passed: r.passed,
|
|
40
|
-
feedback: typeof r.feedback === "string" ? r.feedback : "",
|
|
41
|
-
iteration: typeof r.iteration === "number" ? r.iteration : 0,
|
|
42
|
-
maxIterations: typeof r.max_iterations === "number" ? r.max_iterations : 0,
|
|
43
|
-
confidence: typeof r.confidence === "number" ? r.confidence : 0,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
export class LoopManager {
|
|
51
|
-
redis;
|
|
52
|
-
/** channelId → LoopState */
|
|
53
|
-
activeLoops = new Map();
|
|
54
|
-
/** reactionMessageId → channelId — for O(1) lookup on reaction events */
|
|
55
|
-
reactionMessageMap = new Map();
|
|
56
|
-
constructor(redis) {
|
|
57
|
-
this.redis = redis;
|
|
58
|
-
}
|
|
59
|
-
async startLoop(channelId, threadId, goalMessageId, namespace, goal, maxIterations = 10) {
|
|
60
|
-
const state = {
|
|
61
|
-
channelId,
|
|
62
|
-
threadId,
|
|
63
|
-
goalMessageId,
|
|
64
|
-
namespace,
|
|
65
|
-
goal,
|
|
66
|
-
iteration: 0,
|
|
67
|
-
maxIterations,
|
|
68
|
-
gateFailures: [],
|
|
69
|
-
};
|
|
70
|
-
this.activeLoops.set(channelId, state);
|
|
71
|
-
this.reactionMessageMap.set(goalMessageId, channelId);
|
|
72
|
-
const key = loopKey(channelId, threadId);
|
|
73
|
-
await this.redis.hset(key, {
|
|
74
|
-
goal,
|
|
75
|
-
namespace,
|
|
76
|
-
iteration: "0",
|
|
77
|
-
max_iterations: String(maxIterations),
|
|
78
|
-
thread_id: threadId,
|
|
79
|
-
gate_failures: "[]",
|
|
80
|
-
});
|
|
81
|
-
await this.redis.expire(key, LOOP_TTL);
|
|
82
|
-
return state;
|
|
83
|
-
}
|
|
84
|
-
isActive(channelId) {
|
|
85
|
-
return this.activeLoops.has(channelId);
|
|
86
|
-
}
|
|
87
|
-
getState(channelId) {
|
|
88
|
-
return this.activeLoops.get(channelId);
|
|
89
|
-
}
|
|
90
|
-
getThreadId(channelId) {
|
|
91
|
-
return this.activeLoops.get(channelId)?.threadId;
|
|
92
|
-
}
|
|
93
|
-
/** Look up the main channel ID from a reaction message ID. */
|
|
94
|
-
getChannelIdByReactionMessage(messageId) {
|
|
95
|
-
return this.reactionMessageMap.get(messageId);
|
|
96
|
-
}
|
|
97
|
-
async addGateFailure(channelId, failure) {
|
|
98
|
-
const state = this.activeLoops.get(channelId);
|
|
99
|
-
if (!state)
|
|
100
|
-
return;
|
|
101
|
-
state.gateFailures.push(failure);
|
|
102
|
-
state.iteration++;
|
|
103
|
-
const key = loopKey(channelId, state.threadId);
|
|
104
|
-
await this.redis.hset(key, {
|
|
105
|
-
iteration: String(state.iteration),
|
|
106
|
-
gate_failures: JSON.stringify(state.gateFailures),
|
|
107
|
-
});
|
|
108
|
-
await this.redis.expire(key, LOOP_TTL);
|
|
109
|
-
}
|
|
110
|
-
async endLoop(channelId) {
|
|
111
|
-
const state = this.activeLoops.get(channelId);
|
|
112
|
-
if (state) {
|
|
113
|
-
this.reactionMessageMap.delete(state.goalMessageId);
|
|
114
|
-
await this.redis.del(loopKey(channelId, state.threadId));
|
|
115
|
-
}
|
|
116
|
-
this.activeLoops.delete(channelId);
|
|
117
|
-
}
|
|
118
|
-
}
|