@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.
@@ -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 to text-based check below
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
- // Uses Redis when available; falls back to in-memory dedup on Redis errors.
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}), falling back to in-memory:`, err.message);
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
- bot.sendToChannelById(targetChannelId, `[from UI]: ${content}`).catch((err) => {
488
- log("warn", `sendToChannelById (UI echo) failed (ns=${ns}):`, err.message);
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(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {