@gonzih/cc-discord 0.2.23 → 0.2.25
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/loop-manager.d.ts +66 -0
- package/dist/loop-manager.js +118 -0
- package/dist/notifier.js +22 -15
- package/package.json +1 -1
|
@@ -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.js
CHANGED
|
@@ -101,6 +101,9 @@ function shortenModelName(model, driver) {
|
|
|
101
101
|
* Appends " cost: $X.XXX" if a numeric cost field is present.
|
|
102
102
|
*/
|
|
103
103
|
export function parseNotification(raw) {
|
|
104
|
+
// Filter cron-fire noise before any parsing — catches plain-text and JSON-wrapped ⏰ cron notifications
|
|
105
|
+
if (raw.startsWith("⏰") || raw.includes('"⏰'))
|
|
106
|
+
return null;
|
|
104
107
|
let text = raw;
|
|
105
108
|
let driver;
|
|
106
109
|
let model;
|
|
@@ -127,10 +130,8 @@ export function parseNotification(raw) {
|
|
|
127
130
|
isCron = parsed.is_cron;
|
|
128
131
|
}
|
|
129
132
|
catch {
|
|
130
|
-
// non-JSON: fall through
|
|
133
|
+
// non-JSON: fall through
|
|
131
134
|
}
|
|
132
|
-
if (text.startsWith("⏰"))
|
|
133
|
-
return null;
|
|
134
135
|
// Parse eval_report if present — this field is non-standard and not in NotificationPayload type
|
|
135
136
|
const evalReport = parseEvalReport(raw);
|
|
136
137
|
if (!driver)
|
|
@@ -356,23 +357,24 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
356
357
|
const notification = parseNotification(raw);
|
|
357
358
|
if (notification === null)
|
|
358
359
|
continue; // routing excludes discord
|
|
359
|
-
// Dedup: skip if this notification was already forwarded recently
|
|
360
|
-
//
|
|
360
|
+
// Dedup: skip if this notification was already forwarded recently.
|
|
361
|
+
// Check in-memory first (catches pub/sub-delivered notifications in the same process).
|
|
362
|
+
if (checkAndMarkSentSync(ns, raw)) {
|
|
363
|
+
log("info", `dedup: skipping already-sent notification (ns=${ns})`);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
// Also check Redis for cross-restart/cross-process dedup.
|
|
361
367
|
let isDup = false;
|
|
362
368
|
try {
|
|
363
369
|
if (typeof redis.sadd === "function") {
|
|
364
370
|
isDup = await checkAndMarkSent(redis, ns, raw);
|
|
365
371
|
}
|
|
366
|
-
else {
|
|
367
|
-
isDup = checkAndMarkSentSync(ns, raw);
|
|
368
|
-
}
|
|
369
372
|
}
|
|
370
373
|
catch (err) {
|
|
371
|
-
log("warn", `dedup check failed (ns=${ns})
|
|
372
|
-
isDup = checkAndMarkSentSync(ns, raw);
|
|
374
|
+
log("warn", `dedup Redis check failed (ns=${ns}):`, err.message);
|
|
373
375
|
}
|
|
374
376
|
if (isDup) {
|
|
375
|
-
log("info", `dedup: skipping already-sent notification (ns=${ns})`);
|
|
377
|
+
log("info", `dedup: skipping already-sent notification (ns=${ns}) [redis]`);
|
|
376
378
|
continue;
|
|
377
379
|
}
|
|
378
380
|
// Primary namespace: honour chatId-based per-channel routing via reverseSnowflakeLookup,
|
|
@@ -436,6 +438,8 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
436
438
|
log("info", `dedup: skipping already-sent pub/sub notification (ns=${ns})`);
|
|
437
439
|
return;
|
|
438
440
|
}
|
|
441
|
+
// Also mark in Redis so list-poller dedup sees it (prevents cross-path duplicates)
|
|
442
|
+
checkAndMarkSent(redis, ns, message).catch(() => { });
|
|
439
443
|
let mainChannelId;
|
|
440
444
|
if (isPrimary) {
|
|
441
445
|
mainChannelId = resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup, ns, getChannelIdForNamespace);
|
|
@@ -483,10 +487,13 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
483
487
|
? (notifyChannelId ?? getActiveChannelId?.())
|
|
484
488
|
: routedChannelIds.get(ns);
|
|
485
489
|
if (targetChannelId !== undefined) {
|
|
486
|
-
// Echo to Discord so the user sees UI messages
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
+
// Echo to Discord so the user sees UI messages (suppress cron-fire noise)
|
|
491
|
+
const isCronMessage = content.startsWith("⏰") || content.includes("[cron]");
|
|
492
|
+
if (!isCronMessage) {
|
|
493
|
+
bot.sendToChannelById(targetChannelId, `[from UI]: ${content}`).catch((err) => {
|
|
494
|
+
log("warn", `sendToChannelById (UI echo) failed (ns=${ns}):`, err.message);
|
|
495
|
+
});
|
|
496
|
+
}
|
|
490
497
|
// Log the incoming message
|
|
491
498
|
const inMsg = {
|
|
492
499
|
id: crypto.randomUUID(),
|