@gonzih/cc-discord 0.2.25 → 0.2.26

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 CHANGED
@@ -36,6 +36,7 @@ export declare class CcDiscordBot {
36
36
  private lastActiveChannelId?;
37
37
  private cron;
38
38
  private cronEngine?;
39
+ private loopEngine?;
39
40
  private metaAgentManager;
40
41
  /** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
41
42
  private mcpSession?;
@@ -100,6 +101,12 @@ export declare class CcDiscordBot {
100
101
  * Namespace is derived from the channel's registered namespace (same pattern as /clear and /compact).
101
102
  */
102
103
  private handleCronEngineCommand;
104
+ /**
105
+ * Handle the /loop command group — backed by LoopEngine (Redis-persisted, interval-based).
106
+ * Loops fire immediately on creation and on startup, then repeat every intervalMs ms.
107
+ * Namespace is derived from the channel's registered namespace.
108
+ */
109
+ private handleLoopEngineCommand;
103
110
  /**
104
111
  * Call a cc-agent MCP tool via a dedicated ClaudeProcess.
105
112
  * Returns the tool result as a string, or null on failure.
@@ -150,6 +157,12 @@ export declare class CcDiscordBot {
150
157
  * Called from index.ts after startup migrations complete (alongside startMetaAgentPolling).
151
158
  */
152
159
  startCronEngine(): void;
160
+ /**
161
+ * Start the LoopEngine — loads all active loops from Redis, fires each immediately,
162
+ * then starts their repeat timers.
163
+ * Called from index.ts after startup migrations complete (alongside startCronEngine).
164
+ */
165
+ startLoopEngine(): void;
153
166
  /**
154
167
  * Resolve the namespace for a given channelId.
155
168
  * Routed channels use their registered namespace; everything else uses the bot's primary namespace.
package/dist/bot.js CHANGED
@@ -16,6 +16,7 @@ import { getCurrentToken } from "./tokens.js";
16
16
  import { writeChatLog } from "./notifier.js";
17
17
  import { CronManager } from "./cron.js";
18
18
  import { CronEngine, cronPendingKey, CRON_MESSAGE_TTL } from "./cron-engine.js";
19
+ import { LoopEngine, parseIntervalMs } from "./loop-engine.js";
19
20
  import { parseChannelCreateIntent, routeToMetaAgent } from "./router.js";
20
21
  import { createMetaAgentManager } from "./meta-agent-manager.js";
21
22
  /** Convert a Discord snowflake string to a safe 53-bit integer for CronManager compatibility. */
@@ -122,6 +123,7 @@ export class CcDiscordBot {
122
123
  lastActiveChannelId;
123
124
  cron;
124
125
  cronEngine;
126
+ loopEngine;
125
127
  metaAgentManager;
126
128
  /** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
127
129
  mcpSession;
@@ -132,6 +134,7 @@ export class CcDiscordBot {
132
134
  if (opts.redis) {
133
135
  this.wire = createCcWire(opts.redis);
134
136
  this.cronEngine = new CronEngine(opts.redis);
137
+ this.loopEngine = new LoopEngine(opts.redis);
135
138
  }
136
139
  this.metaAgentManager = createMetaAgentManager();
137
140
  this.client = new Client({
@@ -842,6 +845,28 @@ export class CcDiscordBot {
842
845
  .setName("delete")
843
846
  .setDescription("Delete a cron job permanently")
844
847
  .addStringOption((opt) => opt.setName("id").setDescription("Cron ID").setRequired(true))),
848
+ new SlashCommandBuilder()
849
+ .setName("loop")
850
+ .setDescription("Manage Redis-persisted interval loops for this channel's namespace")
851
+ .addSubcommand((sub) => sub
852
+ .setName("add")
853
+ .setDescription("Add a loop (fires immediately, then repeats at fixed interval)")
854
+ .addStringOption((opt) => opt.setName("interval").setDescription("Interval e.g. '30m', '1h', '20m', '30s'").setRequired(true))
855
+ .addStringOption((opt) => opt.setName("message").setDescription("Message to push to the meta-agent input queue").setRequired(true))
856
+ .addIntegerOption((opt) => opt.setName("compact_every").setDescription("Push /compact every N fires (default: 10, 0 = never)").setRequired(false)))
857
+ .addSubcommand((sub) => sub.setName("list").setDescription("List all loops for this namespace"))
858
+ .addSubcommand((sub) => sub
859
+ .setName("pause")
860
+ .setDescription("Pause a loop")
861
+ .addStringOption((opt) => opt.setName("id").setDescription("Loop ID").setRequired(true)))
862
+ .addSubcommand((sub) => sub
863
+ .setName("resume")
864
+ .setDescription("Resume a paused loop (fires immediately)")
865
+ .addStringOption((opt) => opt.setName("id").setDescription("Loop ID").setRequired(true)))
866
+ .addSubcommand((sub) => sub
867
+ .setName("delete")
868
+ .setDescription("Delete a loop permanently")
869
+ .addStringOption((opt) => opt.setName("id").setDescription("Loop ID").setRequired(true))),
845
870
  ].map((cmd) => cmd.toJSON());
846
871
  const rest = new REST().setToken(this.opts.discordToken);
847
872
  if (this.opts.guildIds?.length) {
@@ -992,6 +1017,10 @@ export class CcDiscordBot {
992
1017
  await this.handleCronEngineCommand(interaction, channelId);
993
1018
  break;
994
1019
  }
1020
+ case "loop": {
1021
+ await this.handleLoopEngineCommand(interaction, channelId);
1022
+ break;
1023
+ }
995
1024
  }
996
1025
  }
997
1026
  async handleCronsCommand(interaction, channelId) {
@@ -1113,6 +1142,82 @@ export class CcDiscordBot {
1113
1142
  await interaction.reply("Unknown /cron subcommand.");
1114
1143
  }
1115
1144
  }
1145
+ /**
1146
+ * Handle the /loop command group — backed by LoopEngine (Redis-persisted, interval-based).
1147
+ * Loops fire immediately on creation and on startup, then repeat every intervalMs ms.
1148
+ * Namespace is derived from the channel's registered namespace.
1149
+ */
1150
+ async handleLoopEngineCommand(interaction, channelId) {
1151
+ if (!this.loopEngine) {
1152
+ await interaction.reply({ content: "Loop engine unavailable (no Redis connection).", ephemeral: true });
1153
+ return;
1154
+ }
1155
+ const ns = this.resolveNamespaceForChannel(channelId);
1156
+ const sub = interaction.options.getSubcommand();
1157
+ switch (sub) {
1158
+ case "add": {
1159
+ const intervalStr = interaction.options.getString("interval", true);
1160
+ const message = interaction.options.getString("message", true);
1161
+ const compactEvery = interaction.options.getInteger("compact_every") ?? 10;
1162
+ const intervalMs = parseIntervalMs(intervalStr);
1163
+ if (intervalMs === null || intervalMs <= 0) {
1164
+ await interaction.reply(`Invalid interval: \`${intervalStr}\`\nUse format: \`30s\`, \`5m\`, \`20m\`, \`1h\`, \`2h\`, \`1d\``);
1165
+ return;
1166
+ }
1167
+ await interaction.deferReply();
1168
+ const rec = await this.loopEngine.add(ns, intervalMs, message, compactEvery);
1169
+ const humanInterval = intervalStr;
1170
+ await interaction.editReply(`Loop added for namespace **${ns}**\nID: \`${rec.id}\`\nInterval: \`${humanInterval}\` (${intervalMs}ms)\nCompact every: ${rec.compactEvery} fires\n(Fired immediately on creation.)`);
1171
+ break;
1172
+ }
1173
+ case "list": {
1174
+ await interaction.deferReply();
1175
+ const allLoops = await this.loopEngine.list();
1176
+ const nsLoops = allLoops.filter((r) => r.namespace === ns);
1177
+ if (nsLoops.length === 0) {
1178
+ await interaction.editReply(`No loops for namespace **${ns}**.`);
1179
+ }
1180
+ else {
1181
+ const embed = new EmbedBuilder()
1182
+ .setTitle(`Loops — ${ns}`)
1183
+ .setColor(0x57F287)
1184
+ .setDescription(nsLoops
1185
+ .map((r) => {
1186
+ const statusEmoji = r.status === "active" ? "🟢" : "⏸️";
1187
+ const last = r.lastRun ? `last run ${r.lastRun}` : "never run";
1188
+ const intervalSec = Math.round(r.intervalMs / 1000);
1189
+ return `${statusEmoji} **\`${r.id}\`** [${r.status}]\nInterval: ${intervalSec}s | Fires: ${r.fireCount} | ${last}\nMessage: \`${r.message.slice(0, 80)}${r.message.length > 80 ? "…" : ""}\``;
1190
+ })
1191
+ .join("\n\n"));
1192
+ await interaction.editReply({ embeds: [embed] });
1193
+ }
1194
+ break;
1195
+ }
1196
+ case "pause": {
1197
+ const id = interaction.options.getString("id", true);
1198
+ await interaction.deferReply();
1199
+ const ok = await this.loopEngine.pause(id);
1200
+ await interaction.editReply(ok ? `Loop \`${id}\` paused.` : `Loop \`${id}\` not found.`);
1201
+ break;
1202
+ }
1203
+ case "resume": {
1204
+ const id = interaction.options.getString("id", true);
1205
+ await interaction.deferReply();
1206
+ const ok = await this.loopEngine.resume(id);
1207
+ await interaction.editReply(ok ? `Loop \`${id}\` resumed (fired immediately).` : `Loop \`${id}\` not found.`);
1208
+ break;
1209
+ }
1210
+ case "delete": {
1211
+ const id = interaction.options.getString("id", true);
1212
+ await interaction.deferReply();
1213
+ const ok = await this.loopEngine.delete(id);
1214
+ await interaction.editReply(ok ? `Loop \`${id}\` deleted.` : `Loop \`${id}\` not found.`);
1215
+ break;
1216
+ }
1217
+ default:
1218
+ await interaction.reply("Unknown /loop subcommand.");
1219
+ }
1220
+ }
1116
1221
  /**
1117
1222
  * Call a cc-agent MCP tool via a dedicated ClaudeProcess.
1118
1223
  * Returns the tool result as a string, or null on failure.
@@ -1356,6 +1461,18 @@ export class CcDiscordBot {
1356
1461
  console.warn("[bot] cronEngine.start() failed:", err.message);
1357
1462
  });
1358
1463
  }
1464
+ /**
1465
+ * Start the LoopEngine — loads all active loops from Redis, fires each immediately,
1466
+ * then starts their repeat timers.
1467
+ * Called from index.ts after startup migrations complete (alongside startCronEngine).
1468
+ */
1469
+ startLoopEngine() {
1470
+ if (!this.loopEngine)
1471
+ return;
1472
+ this.loopEngine.start().catch((err) => {
1473
+ console.warn("[bot] loopEngine.start() failed:", err.message);
1474
+ });
1475
+ }
1359
1476
  /**
1360
1477
  * Resolve the namespace for a given channelId.
1361
1478
  * Routed channels use their registered namespace; everything else uses the bot's primary namespace.
@@ -1452,6 +1569,7 @@ export class CcDiscordBot {
1452
1569
  this.stopMetaAgentTyping(channelId);
1453
1570
  }
1454
1571
  this.metaAgentManager.stop();
1572
+ this.loopEngine?.stop();
1455
1573
  void this.client.destroy();
1456
1574
  }
1457
1575
  }
package/dist/index.js CHANGED
@@ -145,6 +145,8 @@ async function runStartupMigrations() {
145
145
  bot.startMetaAgentPolling();
146
146
  // 4. Start cron engine — loads and schedules all Redis-persisted crons
147
147
  bot.startCronEngine();
148
+ // 5. Start loop engine — loads all active loops, fires each immediately, then starts timers
149
+ bot.startLoopEngine();
148
150
  }
149
151
  // Mutable placeholder closures — filled in once `bot` is created below
150
152
  let getLastActiveChannelIdFn = () => undefined;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * LoopEngine — Redis-persisted, interval-based meta-agent messaging.
3
+ *
4
+ * Loops differ from crons:
5
+ * - Fire IMMEDIATELY on creation and on startup
6
+ * - Repeat at a fixed millisecond interval (not a cron expression)
7
+ *
8
+ * Redis key schema:
9
+ * cca:discord:loop:list — SET of loop IDs
10
+ * cca:discord:loop:{id} — HASH with fields:
11
+ * id, namespace, message, interval_ms,
12
+ * fire_count, compact_every, status,
13
+ * last_run, created_at
14
+ *
15
+ * On each fire:
16
+ * 1. Increment fire_count
17
+ * 2. Duplicate check: LRANGE cca:discord:meta:{ns}:input 0 -1
18
+ * — skip if the exact message is already queued
19
+ * 3. If fire_count % compact_every === 0: RPUSH /compact first
20
+ * 4. RPUSH JSON entry to cca:discord:meta:{ns}:input
21
+ * 5. Update last_run
22
+ */
23
+ import type { Redis } from "ioredis";
24
+ /**
25
+ * Temporary key written by the loop engine when it fires a loop.
26
+ * Value is the loopId that fired. TTL 300s.
27
+ * The bot reads this after sending a Discord message to link the message to the loop.
28
+ */
29
+ export declare const loopPendingKey: (ns: string) => string;
30
+ /**
31
+ * Key that maps a sent Discord messageId → loopId.
32
+ * Used by the reaction handler to look up which loop to disable.
33
+ * TTL 86400s (24h).
34
+ */
35
+ export declare const LOOP_MESSAGE_TTL = 86400;
36
+ /**
37
+ * Parse a human-readable interval string to milliseconds.
38
+ * Supports: 30s, 5m, 20m, 1h, 2h, 1d, 7d, etc.
39
+ * Returns null if the format is unrecognised.
40
+ */
41
+ export declare function parseIntervalMs(input: string): number | null;
42
+ export interface LoopRecord {
43
+ id: string;
44
+ namespace: string;
45
+ message: string;
46
+ intervalMs: number;
47
+ fireCount: number;
48
+ compactEvery: number;
49
+ status: "active" | "paused";
50
+ lastRun: string;
51
+ createdAt: string;
52
+ }
53
+ export declare class LoopEngine {
54
+ private redis;
55
+ /** Map of loop ID → NodeJS.Timeout handle */
56
+ private timers;
57
+ constructor(redis: Redis);
58
+ /**
59
+ * Add a new loop.
60
+ * Fires immediately, then repeats every intervalMs milliseconds.
61
+ *
62
+ * @param namespace The meta-agent namespace to push messages to.
63
+ * @param intervalMs Milliseconds between fires.
64
+ * @param message Text content to push into the meta-agent input queue.
65
+ * @param compactEvery Push /compact every N fires. Defaults to 10. 0 = never.
66
+ * @returns The created LoopRecord.
67
+ */
68
+ add(namespace: string, intervalMs: number, message: string, compactEvery?: number): Promise<LoopRecord>;
69
+ /**
70
+ * List all loops stored in Redis.
71
+ */
72
+ list(): Promise<LoopRecord[]>;
73
+ /**
74
+ * Pause a loop (sets status=paused and clears the timer).
75
+ */
76
+ pause(id: string): Promise<boolean>;
77
+ /**
78
+ * Resume a paused loop (sets status=active, fires immediately, then restarts timer).
79
+ */
80
+ resume(id: string): Promise<boolean>;
81
+ /**
82
+ * Delete a loop (clears timer and removes from Redis).
83
+ */
84
+ delete(id: string): Promise<boolean>;
85
+ /**
86
+ * Load all active loops from Redis, fire each immediately, then start their timers.
87
+ * Call once after the bot connects and Redis is ready.
88
+ */
89
+ start(): Promise<void>;
90
+ /**
91
+ * Stop all running timers. Does not modify Redis.
92
+ */
93
+ stop(): void;
94
+ private startTimer;
95
+ private clearTimer;
96
+ /**
97
+ * Execute one loop tick:
98
+ * 1. Reload record (picks up any pause between schedule and fire)
99
+ * 2. Increment fireCount
100
+ * 3. Duplicate check against the input queue
101
+ * 4. Optionally push /compact
102
+ * 5. Push the message
103
+ * 6. Update lastRun
104
+ */
105
+ fire(id: string): Promise<void>;
106
+ /** Write a LoopRecord to Redis as a HASH. */
107
+ private saveRecord;
108
+ /** Load a LoopRecord from Redis. Returns null if the key doesn't exist. */
109
+ private loadRecord;
110
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * LoopEngine — Redis-persisted, interval-based meta-agent messaging.
3
+ *
4
+ * Loops differ from crons:
5
+ * - Fire IMMEDIATELY on creation and on startup
6
+ * - Repeat at a fixed millisecond interval (not a cron expression)
7
+ *
8
+ * Redis key schema:
9
+ * cca:discord:loop:list — SET of loop IDs
10
+ * cca:discord:loop:{id} — HASH with fields:
11
+ * id, namespace, message, interval_ms,
12
+ * fire_count, compact_every, status,
13
+ * last_run, created_at
14
+ *
15
+ * On each fire:
16
+ * 1. Increment fire_count
17
+ * 2. Duplicate check: LRANGE cca:discord:meta:{ns}:input 0 -1
18
+ * — skip if the exact message is already queued
19
+ * 3. If fire_count % compact_every === 0: RPUSH /compact first
20
+ * 4. RPUSH JSON entry to cca:discord:meta:{ns}:input
21
+ * 5. Update last_run
22
+ */
23
+ import { randomUUID } from "crypto";
24
+ import { loopListKey, loopHashKey, discordMetaInputKey as metaInputKey, } from "@gonzih/cc-wire";
25
+ // ─── Redis key helpers ────────────────────────────────────────────────────────
26
+ const LOOP_LIST_KEY = loopListKey();
27
+ /**
28
+ * Temporary key written by the loop engine when it fires a loop.
29
+ * Value is the loopId that fired. TTL 300s.
30
+ * The bot reads this after sending a Discord message to link the message to the loop.
31
+ */
32
+ export const loopPendingKey = (ns) => `cca:discord:loop-pending:${ns}`;
33
+ /**
34
+ * Key that maps a sent Discord messageId → loopId.
35
+ * Used by the reaction handler to look up which loop to disable.
36
+ * TTL 86400s (24h).
37
+ */
38
+ export const LOOP_MESSAGE_TTL = 86400;
39
+ // ─── Human-readable interval parsing ─────────────────────────────────────────
40
+ /**
41
+ * Parse a human-readable interval string to milliseconds.
42
+ * Supports: 30s, 5m, 20m, 1h, 2h, 1d, 7d, etc.
43
+ * Returns null if the format is unrecognised.
44
+ */
45
+ export function parseIntervalMs(input) {
46
+ const trimmed = input.trim().toLowerCase();
47
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/);
48
+ if (!match)
49
+ return null;
50
+ const value = parseFloat(match[1]);
51
+ const unit = match[2];
52
+ if (value <= 0 || !isFinite(value))
53
+ return null;
54
+ switch (unit) {
55
+ case "ms": return Math.round(value);
56
+ case "s": return Math.round(value * 1_000);
57
+ case "m": return Math.round(value * 60_000);
58
+ case "h": return Math.round(value * 3_600_000);
59
+ case "d": return Math.round(value * 86_400_000);
60
+ default: return null;
61
+ }
62
+ }
63
+ // ─── LoopEngine ───────────────────────────────────────────────────────────────
64
+ export class LoopEngine {
65
+ redis;
66
+ /** Map of loop ID → NodeJS.Timeout handle */
67
+ timers = new Map();
68
+ constructor(redis) {
69
+ this.redis = redis;
70
+ }
71
+ /**
72
+ * Add a new loop.
73
+ * Fires immediately, then repeats every intervalMs milliseconds.
74
+ *
75
+ * @param namespace The meta-agent namespace to push messages to.
76
+ * @param intervalMs Milliseconds between fires.
77
+ * @param message Text content to push into the meta-agent input queue.
78
+ * @param compactEvery Push /compact every N fires. Defaults to 10. 0 = never.
79
+ * @returns The created LoopRecord.
80
+ */
81
+ async add(namespace, intervalMs, message, compactEvery = 10) {
82
+ const id = randomUUID();
83
+ const now = new Date().toISOString();
84
+ const record = {
85
+ id,
86
+ namespace,
87
+ message,
88
+ intervalMs,
89
+ fireCount: 0,
90
+ compactEvery,
91
+ status: "active",
92
+ lastRun: "",
93
+ createdAt: now,
94
+ };
95
+ await this.saveRecord(record);
96
+ await this.redis.sadd(LOOP_LIST_KEY, id);
97
+ // Fire immediately, then schedule repeating interval
98
+ await this.fire(id);
99
+ this.startTimer(record);
100
+ console.log(`[loop-engine] added loop id=${id} ns=${namespace} intervalMs=${intervalMs}`);
101
+ return record;
102
+ }
103
+ /**
104
+ * List all loops stored in Redis.
105
+ */
106
+ async list() {
107
+ const ids = await this.redis.smembers(LOOP_LIST_KEY);
108
+ const records = [];
109
+ for (const id of ids) {
110
+ const rec = await this.loadRecord(id);
111
+ if (rec)
112
+ records.push(rec);
113
+ }
114
+ return records;
115
+ }
116
+ /**
117
+ * Pause a loop (sets status=paused and clears the timer).
118
+ */
119
+ async pause(id) {
120
+ const rec = await this.loadRecord(id);
121
+ if (!rec)
122
+ return false;
123
+ rec.status = "paused";
124
+ await this.saveRecord(rec);
125
+ this.clearTimer(id);
126
+ console.log(`[loop-engine] paused loop id=${id}`);
127
+ return true;
128
+ }
129
+ /**
130
+ * Resume a paused loop (sets status=active, fires immediately, then restarts timer).
131
+ */
132
+ async resume(id) {
133
+ const rec = await this.loadRecord(id);
134
+ if (!rec)
135
+ return false;
136
+ rec.status = "active";
137
+ await this.saveRecord(rec);
138
+ // Fire immediately then start interval
139
+ await this.fire(id);
140
+ this.startTimer(rec);
141
+ console.log(`[loop-engine] resumed loop id=${id}`);
142
+ return true;
143
+ }
144
+ /**
145
+ * Delete a loop (clears timer and removes from Redis).
146
+ */
147
+ async delete(id) {
148
+ const rec = await this.loadRecord(id);
149
+ if (!rec)
150
+ return false;
151
+ this.clearTimer(id);
152
+ await this.redis.srem(LOOP_LIST_KEY, id);
153
+ await this.redis.del(loopHashKey(id));
154
+ console.log(`[loop-engine] deleted loop id=${id}`);
155
+ return true;
156
+ }
157
+ /**
158
+ * Load all active loops from Redis, fire each immediately, then start their timers.
159
+ * Call once after the bot connects and Redis is ready.
160
+ */
161
+ async start() {
162
+ const ids = await this.redis.smembers(LOOP_LIST_KEY);
163
+ let started = 0;
164
+ for (const id of ids) {
165
+ const rec = await this.loadRecord(id);
166
+ if (!rec)
167
+ continue;
168
+ if (rec.status === "active") {
169
+ await this.fire(id);
170
+ this.startTimer(rec);
171
+ started++;
172
+ }
173
+ }
174
+ console.log(`[loop-engine] started — loaded ${ids.length} loops, started ${started}`);
175
+ }
176
+ /**
177
+ * Stop all running timers. Does not modify Redis.
178
+ */
179
+ stop() {
180
+ for (const [id] of this.timers) {
181
+ this.clearTimer(id);
182
+ }
183
+ console.log("[loop-engine] stopped all timers");
184
+ }
185
+ // ─── Private helpers ────────────────────────────────────────────────────────
186
+ startTimer(rec) {
187
+ // Clear any existing timer for this ID first
188
+ this.clearTimer(rec.id);
189
+ const handle = setInterval(async () => {
190
+ await this.fire(rec.id);
191
+ }, rec.intervalMs);
192
+ this.timers.set(rec.id, handle);
193
+ }
194
+ clearTimer(id) {
195
+ const handle = this.timers.get(id);
196
+ if (handle !== undefined) {
197
+ clearInterval(handle);
198
+ this.timers.delete(id);
199
+ }
200
+ }
201
+ /**
202
+ * Execute one loop tick:
203
+ * 1. Reload record (picks up any pause between schedule and fire)
204
+ * 2. Increment fireCount
205
+ * 3. Duplicate check against the input queue
206
+ * 4. Optionally push /compact
207
+ * 5. Push the message
208
+ * 6. Update lastRun
209
+ */
210
+ async fire(id) {
211
+ const rec = await this.loadRecord(id);
212
+ if (!rec) {
213
+ console.warn(`[loop-engine] fire: record not found for id=${id}`);
214
+ return;
215
+ }
216
+ if (rec.status !== "active") {
217
+ console.log(`[loop-engine] fire: loop id=${id} is paused — skipping`);
218
+ return;
219
+ }
220
+ const ns = rec.namespace;
221
+ const inputKey = metaInputKey(ns);
222
+ // Increment fireCount
223
+ rec.fireCount += 1;
224
+ // Duplicate check: skip if the message is already queued
225
+ try {
226
+ const queued = await this.redis.lrange(inputKey, 0, -1);
227
+ const alreadyQueued = queued.some((entry) => {
228
+ try {
229
+ const parsed = JSON.parse(entry);
230
+ return parsed.content === rec.message;
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ });
236
+ if (alreadyQueued) {
237
+ console.log(`[loop-engine] fire: duplicate detected, skipping push (id=${id} ns=${ns})`);
238
+ await this.saveRecord(rec);
239
+ return;
240
+ }
241
+ }
242
+ catch (err) {
243
+ console.warn(`[loop-engine] fire: duplicate check failed (id=${id}):`, err.message);
244
+ }
245
+ // Auto-compact every N fires
246
+ if (rec.compactEvery > 0 && rec.fireCount % rec.compactEvery === 0) {
247
+ const compactEntry = JSON.stringify({
248
+ id: randomUUID(),
249
+ content: "/compact",
250
+ timestamp: new Date().toISOString(),
251
+ source: "loop",
252
+ });
253
+ try {
254
+ await this.redis.rpush(inputKey, compactEntry);
255
+ console.log(`[loop-engine] fire: pushed /compact (id=${id} ns=${ns} fireCount=${rec.fireCount})`);
256
+ }
257
+ catch (err) {
258
+ console.warn(`[loop-engine] fire: /compact push failed (id=${id}):`, err.message);
259
+ }
260
+ }
261
+ // Push the scheduled message
262
+ const entry = JSON.stringify({
263
+ id: randomUUID(),
264
+ content: rec.message,
265
+ timestamp: new Date().toISOString(),
266
+ source: "loop",
267
+ });
268
+ try {
269
+ await this.redis.rpush(inputKey, entry);
270
+ rec.lastRun = new Date().toISOString();
271
+ console.log(`[loop-engine] fire: pushed message (id=${id} ns=${ns} fireCount=${rec.fireCount})`);
272
+ // Record which loop fired for this namespace
273
+ await this.redis.set(loopPendingKey(ns), id, "EX", 300);
274
+ }
275
+ catch (err) {
276
+ console.warn(`[loop-engine] fire: push failed (id=${id}):`, err.message);
277
+ }
278
+ await this.saveRecord(rec);
279
+ }
280
+ /** Write a LoopRecord to Redis as a HASH. */
281
+ async saveRecord(rec) {
282
+ const key = loopHashKey(rec.id);
283
+ await this.redis.hset(key, {
284
+ id: rec.id,
285
+ namespace: rec.namespace,
286
+ message: rec.message,
287
+ interval_ms: String(rec.intervalMs),
288
+ fire_count: String(rec.fireCount),
289
+ compact_every: String(rec.compactEvery),
290
+ status: rec.status,
291
+ last_run: rec.lastRun,
292
+ created_at: rec.createdAt,
293
+ });
294
+ }
295
+ /** Load a LoopRecord from Redis. Returns null if the key doesn't exist. */
296
+ async loadRecord(id) {
297
+ const data = await this.redis.hgetall(loopHashKey(id));
298
+ if (!data || !data.id)
299
+ return null;
300
+ return {
301
+ id: data.id,
302
+ namespace: data.namespace,
303
+ message: data.message,
304
+ intervalMs: parseInt(data.interval_ms ?? "0", 10),
305
+ fireCount: parseInt(data.fire_count ?? "0", 10),
306
+ compactEvery: parseInt(data.compact_every ?? "10", 10),
307
+ status: (data.status === "paused" ? "paused" : "active"),
308
+ lastRun: data.last_run ?? "",
309
+ createdAt: data.created_at ?? "",
310
+ };
311
+ }
312
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
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.4.0",
22
+ "@gonzih/cc-wire": "^0.4.1",
23
23
  "@types/node-cron": "^3.0.11",
24
24
  "discord.js": "^14.0.0",
25
25
  "ioredis": "^5.0.0",
@@ -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
- }
@@ -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
- }