@gonzih/cc-discord 0.2.26 → 0.2.27

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,7 +36,6 @@ export declare class CcDiscordBot {
36
36
  private lastActiveChannelId?;
37
37
  private cron;
38
38
  private cronEngine?;
39
- private loopEngine?;
40
39
  private metaAgentManager;
41
40
  /** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
42
41
  private mcpSession?;
@@ -101,12 +100,6 @@ export declare class CcDiscordBot {
101
100
  * Namespace is derived from the channel's registered namespace (same pattern as /clear and /compact).
102
101
  */
103
102
  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;
110
103
  /**
111
104
  * Call a cc-agent MCP tool via a dedicated ClaudeProcess.
112
105
  * Returns the tool result as a string, or null on failure.
@@ -157,12 +150,6 @@ export declare class CcDiscordBot {
157
150
  * Called from index.ts after startup migrations complete (alongside startMetaAgentPolling).
158
151
  */
159
152
  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;
166
153
  /**
167
154
  * Resolve the namespace for a given channelId.
168
155
  * Routed channels use their registered namespace; everything else uses the bot's primary namespace.
package/dist/bot.js CHANGED
@@ -16,7 +16,6 @@ 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";
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. */
@@ -123,7 +122,6 @@ export class CcDiscordBot {
123
122
  lastActiveChannelId;
124
123
  cron;
125
124
  cronEngine;
126
- loopEngine;
127
125
  metaAgentManager;
128
126
  /** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
129
127
  mcpSession;
@@ -134,7 +132,6 @@ export class CcDiscordBot {
134
132
  if (opts.redis) {
135
133
  this.wire = createCcWire(opts.redis);
136
134
  this.cronEngine = new CronEngine(opts.redis);
137
- this.loopEngine = new LoopEngine(opts.redis);
138
135
  }
139
136
  this.metaAgentManager = createMetaAgentManager();
140
137
  this.client = new Client({
@@ -845,28 +842,6 @@ export class CcDiscordBot {
845
842
  .setName("delete")
846
843
  .setDescription("Delete a cron job permanently")
847
844
  .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))),
870
845
  ].map((cmd) => cmd.toJSON());
871
846
  const rest = new REST().setToken(this.opts.discordToken);
872
847
  if (this.opts.guildIds?.length) {
@@ -1017,10 +992,6 @@ export class CcDiscordBot {
1017
992
  await this.handleCronEngineCommand(interaction, channelId);
1018
993
  break;
1019
994
  }
1020
- case "loop": {
1021
- await this.handleLoopEngineCommand(interaction, channelId);
1022
- break;
1023
- }
1024
995
  }
1025
996
  }
1026
997
  async handleCronsCommand(interaction, channelId) {
@@ -1142,82 +1113,6 @@ export class CcDiscordBot {
1142
1113
  await interaction.reply("Unknown /cron subcommand.");
1143
1114
  }
1144
1115
  }
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
- }
1221
1116
  /**
1222
1117
  * Call a cc-agent MCP tool via a dedicated ClaudeProcess.
1223
1118
  * Returns the tool result as a string, or null on failure.
@@ -1461,18 +1356,6 @@ export class CcDiscordBot {
1461
1356
  console.warn("[bot] cronEngine.start() failed:", err.message);
1462
1357
  });
1463
1358
  }
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
- }
1476
1359
  /**
1477
1360
  * Resolve the namespace for a given channelId.
1478
1361
  * Routed channels use their registered namespace; everything else uses the bot's primary namespace.
@@ -1569,7 +1452,6 @@ export class CcDiscordBot {
1569
1452
  this.stopMetaAgentTyping(channelId);
1570
1453
  }
1571
1454
  this.metaAgentManager.stop();
1572
- this.loopEngine?.stop();
1573
1455
  void this.client.destroy();
1574
1456
  }
1575
1457
  }
package/dist/index.js CHANGED
@@ -145,8 +145,6 @@ 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();
150
148
  }
151
149
  // Mutable placeholder closures — filled in once `bot` is created below
152
150
  let getLastActiveChannelIdFn = () => undefined;
package/dist/notifier.js CHANGED
@@ -112,14 +112,17 @@ export function parseNotification(raw) {
112
112
  let isCron = false;
113
113
  try {
114
114
  const parsed = JSON.parse(raw);
115
+ // Accept 'targets' as alias for 'routing' (coordinator sessions use this format)
116
+ const routingArr = parsed.routing ?? parsed.targets;
115
117
  // routing: absent/empty → all transports; non-empty → only listed transports
116
- if (parsed.routing && parsed.routing.length > 0 && !parsed.routing.includes("discord")) {
118
+ if (routingArr && routingArr.length > 0 && !routingArr.includes("discord")) {
117
119
  return null;
118
120
  }
119
121
  if (parsed.is_cron === true)
120
122
  return null;
121
- if (parsed.text)
122
- text = parsed.text;
123
+ // Accept 'message' as alias for 'text'
124
+ if (parsed.text ?? parsed.message)
125
+ text = (parsed.text ?? parsed.message);
123
126
  driver = parsed.driver;
124
127
  model = parsed.model;
125
128
  if (typeof parsed.cost === "number")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.26",
3
+ "version": "0.2.27",
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.1",
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",
@@ -1,110 +0,0 @@
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
- }
@@ -1,312 +0,0 @@
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
- }