@gonzih/cc-discord 0.2.5 → 0.2.6
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 +16 -0
- package/dist/bot.js +151 -0
- package/dist/loop-manager.d.ts +66 -0
- package/dist/loop-manager.js +118 -0
- package/dist/notifier.d.ts +3 -0
- package/dist/notifier.js +31 -11
- package/package.json +1 -1
package/dist/bot.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
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";
|
|
6
7
|
/** Returns true if the attachment name/contentType indicates an audio file. */
|
|
7
8
|
export declare function isAudioAttachment(name: string, contentType: string): boolean;
|
|
8
9
|
/** Build the prompt text for a file/document attachment, optionally with caption. */
|
|
@@ -36,6 +37,7 @@ export declare class CcDiscordBot {
|
|
|
36
37
|
private metaAgentManager;
|
|
37
38
|
/** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
|
|
38
39
|
private mcpSession?;
|
|
40
|
+
private loopManager?;
|
|
39
41
|
constructor(opts: DiscordBotOptions);
|
|
40
42
|
/** Reverse-lookup: find the channelId string for a cron-stored integer */
|
|
41
43
|
private snowflakeMap;
|
|
@@ -98,6 +100,20 @@ export declare class CcDiscordBot {
|
|
|
98
100
|
getLastActiveChannelId(): string | undefined;
|
|
99
101
|
/** Reverse lookup: find the Discord channelId registered for a given namespace. */
|
|
100
102
|
getChannelIdForNamespace(ns: string): string | undefined;
|
|
103
|
+
/** Return the thread ID for an active loop on `channelId`, or undefined. */
|
|
104
|
+
getLoopThreadId(channelId: string): string | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* Post a structured eval-report embed to the loop thread for `channelId`.
|
|
107
|
+
* Also records gate failures in the LoopManager if the gate did not pass.
|
|
108
|
+
*/
|
|
109
|
+
postEvalEmbed(channelId: string, report: EvalReport): Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* Create a Discord thread on `msg`, register loop state, and add reaction gates.
|
|
112
|
+
* Requires MANAGE_THREADS permission. Falls back silently on failure.
|
|
113
|
+
*/
|
|
114
|
+
private createLoopThread;
|
|
115
|
+
/** Handle 🔄/✅/❌ reactions on loop gate messages. */
|
|
116
|
+
private handleReactionAdd;
|
|
101
117
|
/**
|
|
102
118
|
* Feed a text message into the active Claude session for the given channel.
|
|
103
119
|
* Called by the notifier when a UI message arrives via Redis pub/sub.
|
package/dist/bot.js
CHANGED
|
@@ -13,6 +13,7 @@ import { transcribeVoice, isVoiceAvailable } from "./voice.js";
|
|
|
13
13
|
import { formatForDiscord, splitLongMessage, stripAnsi } from "./formatter.js";
|
|
14
14
|
import { getCurrentToken } from "./tokens.js";
|
|
15
15
|
import { writeChatLog } from "./notifier.js";
|
|
16
|
+
import { LoopManager, isGoalMessage } from "./loop-manager.js";
|
|
16
17
|
import { CronManager } from "./cron.js";
|
|
17
18
|
import { parseChannelCreateIntent, routeToMetaAgent } from "./router.js";
|
|
18
19
|
import { createMetaAgentManager } from "./meta-agent-manager.js";
|
|
@@ -122,12 +123,14 @@ export class CcDiscordBot {
|
|
|
122
123
|
metaAgentManager;
|
|
123
124
|
/** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
|
|
124
125
|
mcpSession;
|
|
126
|
+
loopManager;
|
|
125
127
|
constructor(opts) {
|
|
126
128
|
this.opts = opts;
|
|
127
129
|
this.redis = opts.redis;
|
|
128
130
|
this.namespace = opts.namespace ?? "default";
|
|
129
131
|
if (opts.redis) {
|
|
130
132
|
this.wire = createCcWire(opts.redis);
|
|
133
|
+
this.loopManager = new LoopManager(opts.redis);
|
|
131
134
|
}
|
|
132
135
|
this.metaAgentManager = createMetaAgentManager();
|
|
133
136
|
this.client = new Client({
|
|
@@ -136,6 +139,7 @@ export class CcDiscordBot {
|
|
|
136
139
|
GatewayIntentBits.GuildMessages,
|
|
137
140
|
GatewayIntentBits.MessageContent,
|
|
138
141
|
GatewayIntentBits.DirectMessages,
|
|
142
|
+
GatewayIntentBits.GuildMessageReactions,
|
|
139
143
|
],
|
|
140
144
|
});
|
|
141
145
|
this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatIdNum, prompt, _jobId, done) => {
|
|
@@ -168,6 +172,9 @@ export class CcDiscordBot {
|
|
|
168
172
|
return;
|
|
169
173
|
void this.handleSlashCommand(interaction);
|
|
170
174
|
});
|
|
175
|
+
this.client.on(Events.MessageReactionAdd, (reaction, user) => {
|
|
176
|
+
void this.handleReactionAdd(reaction, user);
|
|
177
|
+
});
|
|
171
178
|
this.client.on("error", (err) => {
|
|
172
179
|
console.error("[discord] client error:", err.message);
|
|
173
180
|
});
|
|
@@ -365,13 +372,42 @@ export class CcDiscordBot {
|
|
|
365
372
|
return;
|
|
366
373
|
}
|
|
367
374
|
}
|
|
375
|
+
// Handle messages sent inside a loop thread — route to the running meta-agent session
|
|
376
|
+
if (msg.channel.isThread() && this.redis && this.loopManager) {
|
|
377
|
+
const parentId = msg.channel.parentId;
|
|
378
|
+
if (parentId) {
|
|
379
|
+
const loopState = this.loopManager.getState(parentId);
|
|
380
|
+
if (loopState && loopState.threadId === effectiveChannelId) {
|
|
381
|
+
const username = msg.member?.displayName ?? msg.author.username;
|
|
382
|
+
try {
|
|
383
|
+
await routeToMetaAgent(loopState.namespace, stampPrompt(text, username, msg.createdAt), this.redis);
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
await msg.channel.send(`Failed to route: ${err.message}`).catch(() => { });
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
368
392
|
// Channel registered via createChannelForRepo or /channel — route directly to its meta-agent
|
|
369
393
|
const mappedNs = this.channelNamespaceMap.get(effectiveChannelId);
|
|
370
394
|
if (mappedNs && this.redis) {
|
|
395
|
+
// If a loop is already running for this channel, point the user to the thread
|
|
396
|
+
if (this.loopManager?.isActive(effectiveChannelId)) {
|
|
397
|
+
const loopState = this.loopManager.getState(effectiveChannelId);
|
|
398
|
+
await msg.channel.send(`Loop in progress → <#${loopState.threadId}>\nSend messages in the thread to interact with the running loop.`).catch(() => { });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
371
401
|
this.writeChatMessage("user", "discord", text, effectiveChannelId, mappedNs.namespace);
|
|
372
402
|
this.opts.registerRoutedChannelId?.(mappedNs.namespace, effectiveChannelId);
|
|
373
403
|
this.persistChannelMapping(effectiveChannelId, mappedNs.namespace, mappedNs.repoUrl);
|
|
374
404
|
this.startMetaAgentTyping(effectiveChannelId, msg.channel);
|
|
405
|
+
// Detect goal messages → create a thread for loop tracking
|
|
406
|
+
if (this.loopManager && msg.guild && !msg.channel.isThread()) {
|
|
407
|
+
if (isGoalMessage(text)) {
|
|
408
|
+
await this.createLoopThread(msg, effectiveChannelId, mappedNs.namespace, text);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
375
411
|
const username = msg.member?.displayName ?? msg.author.username;
|
|
376
412
|
try {
|
|
377
413
|
await routeToMetaAgent(mappedNs.namespace, stampPrompt(text, username, msg.createdAt), this.redis);
|
|
@@ -1008,6 +1044,121 @@ export class CcDiscordBot {
|
|
|
1008
1044
|
}
|
|
1009
1045
|
return undefined;
|
|
1010
1046
|
}
|
|
1047
|
+
/** Return the thread ID for an active loop on `channelId`, or undefined. */
|
|
1048
|
+
getLoopThreadId(channelId) {
|
|
1049
|
+
return this.loopManager?.getThreadId(channelId);
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Post a structured eval-report embed to the loop thread for `channelId`.
|
|
1053
|
+
* Also records gate failures in the LoopManager if the gate did not pass.
|
|
1054
|
+
*/
|
|
1055
|
+
async postEvalEmbed(channelId, report) {
|
|
1056
|
+
const threadId = this.loopManager?.getThreadId(channelId);
|
|
1057
|
+
if (!threadId)
|
|
1058
|
+
return;
|
|
1059
|
+
const channel = await this.getChannel(threadId);
|
|
1060
|
+
if (!channel)
|
|
1061
|
+
return;
|
|
1062
|
+
const color = report.passed ? 0x57F287 : 0xED4245; // green / red
|
|
1063
|
+
const embed = new EmbedBuilder()
|
|
1064
|
+
.setTitle(`${report.passed ? "✅" : "❌"} Gate: ${report.gate}`)
|
|
1065
|
+
.setColor(color)
|
|
1066
|
+
.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 })
|
|
1067
|
+
.setTimestamp();
|
|
1068
|
+
await channel.send({ embeds: [embed] }).catch((err) => {
|
|
1069
|
+
console.warn(`[bot] postEvalEmbed send failed:`, err.message);
|
|
1070
|
+
});
|
|
1071
|
+
if (!report.passed && this.loopManager) {
|
|
1072
|
+
await this.loopManager.addGateFailure(channelId, {
|
|
1073
|
+
gate: report.gate,
|
|
1074
|
+
feedback: report.feedback,
|
|
1075
|
+
iteration: report.iteration,
|
|
1076
|
+
confidence: report.confidence,
|
|
1077
|
+
timestamp: new Date().toISOString(),
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Create a Discord thread on `msg`, register loop state, and add reaction gates.
|
|
1083
|
+
* Requires MANAGE_THREADS permission. Falls back silently on failure.
|
|
1084
|
+
*/
|
|
1085
|
+
async createLoopThread(msg, channelId, namespace, goal) {
|
|
1086
|
+
try {
|
|
1087
|
+
const shortGoal = goal.slice(0, 48).replace(/\s+/g, " ");
|
|
1088
|
+
const threadName = `Goal: ${shortGoal}${goal.length > 48 ? "…" : ""}`;
|
|
1089
|
+
const thread = await msg.channel.threads.create({
|
|
1090
|
+
name: threadName,
|
|
1091
|
+
autoArchiveDuration: 10080, // 1 week
|
|
1092
|
+
startMessage: msg,
|
|
1093
|
+
reason: "Loop observability thread",
|
|
1094
|
+
});
|
|
1095
|
+
const firstMsg = await thread.send(`🎯 **Loop tracking thread**\n> ${goal.slice(0, 200)}\n\nReact to control the loop:\n` +
|
|
1096
|
+
`🔄 Retry current iteration ✅ Accept & exit ❌ Kill loop`);
|
|
1097
|
+
await firstMsg.react("🔄");
|
|
1098
|
+
await firstMsg.react("✅");
|
|
1099
|
+
await firstMsg.react("❌");
|
|
1100
|
+
await this.loopManager.startLoop(channelId, thread.id, firstMsg.id, namespace, goal);
|
|
1101
|
+
console.log(`[bot] loop thread created: channelId=${channelId} threadId=${thread.id}`);
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
console.warn(`[bot] createLoopThread failed:`, err.message);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
/** Handle 🔄/✅/❌ reactions on loop gate messages. */
|
|
1108
|
+
async handleReactionAdd(reaction, user) {
|
|
1109
|
+
if (user.bot)
|
|
1110
|
+
return;
|
|
1111
|
+
if (!this.isAllowed(user.id))
|
|
1112
|
+
return;
|
|
1113
|
+
if (!this.loopManager || !this.redis)
|
|
1114
|
+
return;
|
|
1115
|
+
// Fetch full reaction object if partial (bot doesn't have it cached)
|
|
1116
|
+
let fullReaction = reaction;
|
|
1117
|
+
if (reaction.partial) {
|
|
1118
|
+
try {
|
|
1119
|
+
fullReaction = await reaction.fetch();
|
|
1120
|
+
}
|
|
1121
|
+
catch {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
const emoji = fullReaction.emoji.name;
|
|
1126
|
+
if (emoji !== "🔄" && emoji !== "✅" && emoji !== "❌")
|
|
1127
|
+
return;
|
|
1128
|
+
const messageId = fullReaction.message.id;
|
|
1129
|
+
const channelId = this.loopManager.getChannelIdByReactionMessage(messageId);
|
|
1130
|
+
if (!channelId)
|
|
1131
|
+
return;
|
|
1132
|
+
const loopState = this.loopManager.getState(channelId);
|
|
1133
|
+
if (!loopState)
|
|
1134
|
+
return;
|
|
1135
|
+
const thread = await this.getChannel(loopState.threadId);
|
|
1136
|
+
if (emoji === "🔄") {
|
|
1137
|
+
if (thread) {
|
|
1138
|
+
await thread.send(`🔄 Retry requested — re-running iteration ${loopState.iteration + 1}`).catch(() => { });
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
await routeToMetaAgent(loopState.namespace, `Please retry the previous goal: ${loopState.goal}`, this.redis);
|
|
1142
|
+
}
|
|
1143
|
+
catch (err) {
|
|
1144
|
+
console.warn(`[bot] retry routeToMetaAgent failed:`, err.message);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
else if (emoji === "✅") {
|
|
1148
|
+
if (thread) {
|
|
1149
|
+
await thread.send("✅ Loop accepted — marking complete").catch(() => { });
|
|
1150
|
+
}
|
|
1151
|
+
await this.sendToChannelById(channelId, `✅ Goal completed: ${loopState.goal.slice(0, 100)}`);
|
|
1152
|
+
await this.loopManager.endLoop(channelId);
|
|
1153
|
+
}
|
|
1154
|
+
else if (emoji === "❌") {
|
|
1155
|
+
if (thread) {
|
|
1156
|
+
await thread.send("❌ Loop killed by user").catch(() => { });
|
|
1157
|
+
}
|
|
1158
|
+
await this.sendToChannelById(channelId, `❌ Loop killed: ${loopState.goal.slice(0, 100)}`);
|
|
1159
|
+
await this.loopManager.endLoop(channelId);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1011
1162
|
/**
|
|
1012
1163
|
* Feed a text message into the active Claude session for the given channel.
|
|
1013
1164
|
* Called by the notifier when a UI message arrives via Redis pub/sub.
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
}
|
package/dist/notifier.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* cca:discord:chat:outgoing:{ns} — PUBLISH for web UI to consume
|
|
12
12
|
*/
|
|
13
13
|
import { Redis } from "ioredis";
|
|
14
|
+
import { type EvalReport } from "./loop-manager.js";
|
|
14
15
|
import type { CcDiscordBot } from "./bot.js";
|
|
15
16
|
export interface ChatMessage {
|
|
16
17
|
id: string;
|
|
@@ -23,6 +24,8 @@ export interface ChatMessage {
|
|
|
23
24
|
export interface ParsedNotification {
|
|
24
25
|
text: string;
|
|
25
26
|
chatId?: number;
|
|
27
|
+
/** Populated when the notification JSON contains an `eval_report` object. */
|
|
28
|
+
evalReport?: EvalReport;
|
|
26
29
|
}
|
|
27
30
|
/**
|
|
28
31
|
* Parse a notification payload.
|
package/dist/notifier.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { discordChatLog, discordChatOutgoing, discordNotify, notifyChannel, chatIncomingChannel, createCcWire, TIMING, } from "@gonzih/cc-wire";
|
|
14
14
|
import { splitLongMessage, stripAnsi } from "./formatter.js";
|
|
15
|
+
import { parseEvalReport } from "./loop-manager.js";
|
|
15
16
|
function log(level, ...args) {
|
|
16
17
|
const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
17
18
|
fn("[notifier]", ...args);
|
|
@@ -61,12 +62,14 @@ export function parseNotification(raw) {
|
|
|
61
62
|
catch {
|
|
62
63
|
return { text };
|
|
63
64
|
}
|
|
65
|
+
// Parse eval_report if present — this field is non-standard and not in NotificationPayload type
|
|
66
|
+
const evalReport = parseEvalReport(raw);
|
|
64
67
|
if (!driver)
|
|
65
|
-
return { text, chatId };
|
|
68
|
+
return { text, chatId, evalReport: evalReport ?? undefined };
|
|
66
69
|
const shortModel = shortenModelName(model ?? "", driver);
|
|
67
70
|
const badge = shortModel ? `${driver}:${shortModel}` : driver;
|
|
68
71
|
const costStr = cost != null ? ` cost: $${cost.toFixed(3)}` : "";
|
|
69
|
-
return { text: `${text}\n[${badge}]${costStr}`, chatId };
|
|
72
|
+
return { text: `${text}\n[${badge}]${costStr}`, chatId, evalReport: evalReport ?? undefined };
|
|
70
73
|
}
|
|
71
74
|
/**
|
|
72
75
|
* Write a message to the chat log in Redis.
|
|
@@ -204,9 +207,11 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
204
207
|
const text = `← [${ns}] ` + stripAnsi(buf.text.trim());
|
|
205
208
|
buf.text = "";
|
|
206
209
|
buf.timer = null;
|
|
210
|
+
// During an active loop, route meta-agent output to the thread rather than main channel
|
|
211
|
+
const deliverTo = bot.getLoopThreadId(targetChannelId) ?? targetChannelId;
|
|
207
212
|
const chunks = splitLongMessage(text);
|
|
208
213
|
for (const chunk of chunks) {
|
|
209
|
-
bot.sendToChannelById(
|
|
214
|
+
bot.sendToChannelById(deliverTo, chunk).catch((err) => {
|
|
210
215
|
log("warn", `meta-agent flush sendToChannelById failed (ns=${ns}):`, err.message);
|
|
211
216
|
});
|
|
212
217
|
}
|
|
@@ -279,14 +284,22 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
279
284
|
// Primary namespace: honour chatId-based per-channel routing via reverseSnowflakeLookup,
|
|
280
285
|
// then namespace → channelId lookup, then notifyChannelId / active channel.
|
|
281
286
|
// Routed namespaces: always deliver to the registered Discord channelId — no leakage.
|
|
282
|
-
const
|
|
287
|
+
const mainChannelId = ns === namespace
|
|
283
288
|
? (resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup, ns, getChannelIdForNamespace) ?? targetChannelId)
|
|
284
289
|
: targetChannelId;
|
|
290
|
+
// If a loop is active for this channel, route to its thread
|
|
291
|
+
const destChannelId = bot.getLoopThreadId(mainChannelId) ?? mainChannelId;
|
|
292
|
+
// When an eval report is embedded, post a structured embed to the thread
|
|
293
|
+
if (notification.evalReport) {
|
|
294
|
+
bot.postEvalEmbed(mainChannelId, notification.evalReport).catch((err) => {
|
|
295
|
+
log("warn", `postEvalEmbed failed (ns=${ns}):`, err.message);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
285
298
|
bot.sendToChannelById(destChannelId, notification.text).catch((err) => {
|
|
286
299
|
log("warn", `notify list send failed (ns=${ns}):`, err.message);
|
|
287
300
|
});
|
|
288
301
|
if (forwardNotification) {
|
|
289
|
-
forwardNotification(
|
|
302
|
+
forwardNotification(mainChannelId, notification.text);
|
|
290
303
|
}
|
|
291
304
|
}
|
|
292
305
|
if (remaining > 0) {
|
|
@@ -324,22 +337,29 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
324
337
|
const notification = parseNotification(message);
|
|
325
338
|
if (notification === null)
|
|
326
339
|
return; // routing excludes discord
|
|
327
|
-
let
|
|
340
|
+
let mainChannelId;
|
|
328
341
|
if (isPrimary) {
|
|
329
|
-
|
|
342
|
+
mainChannelId = resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup, ns, getChannelIdForNamespace);
|
|
330
343
|
}
|
|
331
344
|
else {
|
|
332
345
|
// For routed namespaces, only use the registered channelId — no fallback to primary
|
|
333
|
-
|
|
346
|
+
mainChannelId = notification.chatId != null && reverseSnowflakeLookup
|
|
334
347
|
? (reverseSnowflakeLookup(notification.chatId) ?? routedChannelIds.get(ns))
|
|
335
348
|
: routedChannelIds.get(ns);
|
|
336
349
|
}
|
|
337
|
-
if (
|
|
338
|
-
|
|
350
|
+
if (mainChannelId != null) {
|
|
351
|
+
// If a loop is active, route notification text to the thread
|
|
352
|
+
const deliverTo = bot.getLoopThreadId(mainChannelId) ?? mainChannelId;
|
|
353
|
+
if (notification.evalReport) {
|
|
354
|
+
bot.postEvalEmbed(mainChannelId, notification.evalReport).catch((err) => {
|
|
355
|
+
log("warn", `postEvalEmbed failed (ns=${ns}):`, err.message);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
bot.sendToChannelById(deliverTo, notification.text).catch((err) => {
|
|
339
359
|
log("warn", `notify send failed (ns=${ns}):`, err.message);
|
|
340
360
|
});
|
|
341
361
|
if (forwardNotification) {
|
|
342
|
-
forwardNotification(
|
|
362
|
+
forwardNotification(mainChannelId, notification.text);
|
|
343
363
|
}
|
|
344
364
|
}
|
|
345
365
|
else {
|