clementine-agent 1.0.90 → 1.0.92

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,87 @@
1
+ /**
2
+ * Clementine TypeScript — Self-improve loop (autonomous fix consumer).
3
+ *
4
+ * Closes the gap between "we noticed a job is failing" and "we did
5
+ * something about it." Periodically scans
6
+ * ~/.clementine/self-improve/triggers/*.json (written by cron-scheduler
7
+ * when consecutiveErrors >= 3), classifies the failure pattern from
8
+ * recentErrors, and either:
9
+ *
10
+ * - Auto-applies a safe cron-config fix (mode, max_hours, max_turns)
11
+ * and DMs the OWNING agent via their bot
12
+ * - Writes a proposal to self-improve/pending-changes/ and DMs the
13
+ * owning agent the diagnosis (full audit-inbox button approval is
14
+ * a separate Phase 8b ship)
15
+ *
16
+ * After processing, the trigger file is removed. The existing
17
+ * fix-verification system (cron-scheduler.ts) records preFailureCount
18
+ * when a job's config changes and tracks whether the next run succeeds.
19
+ *
20
+ * Routing rule: notifications go to the owning agent's DM via their
21
+ * own bot using `dispatcher.send(text, { agentSlug })`. Unowned crons
22
+ * (no agentSlug) → Clementine's main bot DMs the owner.
23
+ *
24
+ * Idempotent: re-applying the same fix to an already-fixed job is a
25
+ * no-op; the trigger gets removed regardless.
26
+ */
27
+ export interface TriggerFile {
28
+ jobName: string;
29
+ consecutiveErrors: number;
30
+ recentErrors: string[];
31
+ triggeredAt: string;
32
+ }
33
+ export type FixCategory = 'safe-cron-config' | 'risky' | 'noop' | 'unknown';
34
+ export interface FixRecipe {
35
+ category: FixCategory;
36
+ /** Description of what this fix does, for DMs. */
37
+ description: string;
38
+ /**
39
+ * For safe-cron-config: a function that mutates the job's frontmatter
40
+ * entry in-place. Returns true if any change was made (false = idempotent
41
+ * no-op because the fix is already applied).
42
+ */
43
+ apply?: (job: Record<string, unknown>) => boolean;
44
+ }
45
+ export interface SelfImproveDispatcher {
46
+ send(text: string, context?: {
47
+ agentSlug?: string;
48
+ }): Promise<{
49
+ delivered: boolean;
50
+ channelErrors: Record<string, string>;
51
+ }>;
52
+ }
53
+ export interface SelfImproveLoopOptions {
54
+ /** Override scan interval for tests. */
55
+ tickMs?: number;
56
+ /** Override directories for tests. */
57
+ triggersDir?: string;
58
+ pendingDir?: string;
59
+ cronPath?: string;
60
+ }
61
+ export declare function classifyFailure(recentErrors: string[]): FixRecipe;
62
+ export declare class SelfImproveLoop {
63
+ private readonly tickMs;
64
+ private readonly triggersDir;
65
+ private readonly pendingDir;
66
+ private readonly cronPath;
67
+ private readonly dispatcher;
68
+ private timer;
69
+ private running;
70
+ private ticking;
71
+ constructor(dispatcher: SelfImproveDispatcher, opts?: SelfImproveLoopOptions);
72
+ start(): void;
73
+ stop(): void;
74
+ /**
75
+ * Process all pending triggers. Public so tests + manual invocations
76
+ * (e.g., a `clementine self-improve tick` CLI command) can call it.
77
+ */
78
+ tick(): Promise<{
79
+ processed: number;
80
+ applied: number;
81
+ pending: number;
82
+ noop: number;
83
+ }>;
84
+ private processOne;
85
+ private notifyAgent;
86
+ }
87
+ //# sourceMappingURL=self-improve-loop.d.ts.map
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Clementine TypeScript — Self-improve loop (autonomous fix consumer).
3
+ *
4
+ * Closes the gap between "we noticed a job is failing" and "we did
5
+ * something about it." Periodically scans
6
+ * ~/.clementine/self-improve/triggers/*.json (written by cron-scheduler
7
+ * when consecutiveErrors >= 3), classifies the failure pattern from
8
+ * recentErrors, and either:
9
+ *
10
+ * - Auto-applies a safe cron-config fix (mode, max_hours, max_turns)
11
+ * and DMs the OWNING agent via their bot
12
+ * - Writes a proposal to self-improve/pending-changes/ and DMs the
13
+ * owning agent the diagnosis (full audit-inbox button approval is
14
+ * a separate Phase 8b ship)
15
+ *
16
+ * After processing, the trigger file is removed. The existing
17
+ * fix-verification system (cron-scheduler.ts) records preFailureCount
18
+ * when a job's config changes and tracks whether the next run succeeds.
19
+ *
20
+ * Routing rule: notifications go to the owning agent's DM via their
21
+ * own bot using `dispatcher.send(text, { agentSlug })`. Unowned crons
22
+ * (no agentSlug) → Clementine's main bot DMs the owner.
23
+ *
24
+ * Idempotent: re-applying the same fix to an already-fixed job is a
25
+ * no-op; the trigger gets removed regardless.
26
+ */
27
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
28
+ import path from 'node:path';
29
+ import matter from 'gray-matter';
30
+ import pino from 'pino';
31
+ import { BASE_DIR, SYSTEM_DIR } from '../config.js';
32
+ const logger = pino({ name: 'clementine.self-improve-loop' });
33
+ const TICK_MS = 10 * 60 * 1000;
34
+ const TRIGGERS_DIR = path.join(BASE_DIR, 'self-improve', 'triggers');
35
+ const PENDING_CHANGES_DIR = path.join(BASE_DIR, 'self-improve', 'pending-changes');
36
+ const CRON_PATH = path.join(SYSTEM_DIR, 'CRON.md');
37
+ // ── Pattern recognition ──────────────────────────────────────────────
38
+ const PATTERNS = [
39
+ {
40
+ // "Reached maximum number of turns (8)"
41
+ match: /Reached maximum number of turns/i,
42
+ recipe: () => ({
43
+ category: 'safe-cron-config',
44
+ description: 'Hit max-turns ceiling repeatedly. Switching to unleashed mode (multi-phase) so the job can complete its workflow.',
45
+ apply: (job) => {
46
+ let changed = false;
47
+ if (job.mode !== 'unleashed') {
48
+ job.mode = 'unleashed';
49
+ changed = true;
50
+ }
51
+ if (typeof job.max_hours !== 'number' || job.max_hours < 1) {
52
+ job.max_hours = 1;
53
+ changed = true;
54
+ }
55
+ return changed;
56
+ },
57
+ }),
58
+ },
59
+ {
60
+ // "Autocompact is thrashing: the context refilled to the limit within 3 turns"
61
+ match: /Autocompact is thrashing/i,
62
+ recipe: () => ({
63
+ category: 'safe-cron-config',
64
+ description: 'Context window blowing up mid-run. Switching to unleashed mode so each phase starts with a fresh context.',
65
+ apply: (job) => {
66
+ let changed = false;
67
+ if (job.mode !== 'unleashed') {
68
+ job.mode = 'unleashed';
69
+ changed = true;
70
+ }
71
+ if (typeof job.max_hours !== 'number' || job.max_hours < 1) {
72
+ job.max_hours = 1;
73
+ changed = true;
74
+ }
75
+ return changed;
76
+ },
77
+ }),
78
+ },
79
+ {
80
+ // Already-fixed-in-code patterns
81
+ match: /This model does not support user-configurable task budgets|Budget exceeded for cron job/i,
82
+ recipe: () => ({
83
+ category: 'noop',
84
+ description: 'Old taskBudget rejection — already addressed in v1.0.90 (taskBudget no longer passed to SDK). Trigger cleared without action.',
85
+ }),
86
+ },
87
+ ];
88
+ export function classifyFailure(recentErrors) {
89
+ const blob = recentErrors.join('\n').slice(0, 4000);
90
+ for (const { match, recipe } of PATTERNS) {
91
+ if (match.test(blob))
92
+ return recipe();
93
+ }
94
+ return {
95
+ category: 'unknown',
96
+ description: 'Unrecognized failure pattern. Owner needs to inspect the trigger file.',
97
+ };
98
+ }
99
+ function loadCronJob(jobName, cronPath) {
100
+ if (!existsSync(cronPath))
101
+ return null;
102
+ const raw = readFileSync(cronPath, 'utf-8');
103
+ const parsed = matter(raw);
104
+ const jobs = (parsed.data.jobs ?? []);
105
+ const job = jobs.find((j) => String(j.name ?? '') === jobName);
106
+ if (!job)
107
+ return null;
108
+ const agentSlug = typeof job.agentSlug === 'string' ? job.agentSlug : (typeof job.agent_slug === 'string' ? job.agent_slug : undefined);
109
+ return { agentSlug, job, raw, parsed };
110
+ }
111
+ /**
112
+ * Apply the recipe's mutator to the job's frontmatter and write CRON.md
113
+ * back atomically. Returns true if a change was actually written.
114
+ */
115
+ function applyCronEdit(jobName, recipe, cronPath) {
116
+ if (!recipe.apply)
117
+ return false;
118
+ const lookup = loadCronJob(jobName, cronPath);
119
+ if (!lookup) {
120
+ logger.warn({ jobName }, 'Job not found in CRON.md — cannot apply fix');
121
+ return false;
122
+ }
123
+ const changed = recipe.apply(lookup.job);
124
+ if (!changed)
125
+ return false;
126
+ // Re-stringify with the existing content body preserved.
127
+ const updated = matter.stringify(lookup.parsed.content, lookup.parsed.data);
128
+ writeFileSync(cronPath, updated);
129
+ return true;
130
+ }
131
+ function writePendingChange(record, dir) {
132
+ mkdirSync(dir, { recursive: true });
133
+ const file = path.join(dir, `${record.id}.json`);
134
+ writeFileSync(file, JSON.stringify(record, null, 2));
135
+ return file;
136
+ }
137
+ // ── Main loop ────────────────────────────────────────────────────────
138
+ export class SelfImproveLoop {
139
+ tickMs;
140
+ triggersDir;
141
+ pendingDir;
142
+ cronPath;
143
+ dispatcher;
144
+ timer = null;
145
+ running = false;
146
+ ticking = false;
147
+ constructor(dispatcher, opts = {}) {
148
+ this.dispatcher = dispatcher;
149
+ this.tickMs = opts.tickMs ?? TICK_MS;
150
+ this.triggersDir = opts.triggersDir ?? TRIGGERS_DIR;
151
+ this.pendingDir = opts.pendingDir ?? PENDING_CHANGES_DIR;
152
+ this.cronPath = opts.cronPath ?? CRON_PATH;
153
+ }
154
+ start() {
155
+ if (this.running)
156
+ return;
157
+ this.running = true;
158
+ // Run immediately so any backlog from the prior daemon gets handled
159
+ // without a 10-minute wait.
160
+ this.tick().catch((err) => logger.error({ err }, 'Initial self-improve tick failed'));
161
+ this.timer = setInterval(() => {
162
+ this.tick().catch((err) => logger.error({ err }, 'Self-improve tick failed'));
163
+ }, this.tickMs);
164
+ logger.info({ tickMs: this.tickMs }, 'Self-improve loop started');
165
+ }
166
+ stop() {
167
+ if (!this.running)
168
+ return;
169
+ this.running = false;
170
+ if (this.timer) {
171
+ clearInterval(this.timer);
172
+ this.timer = null;
173
+ }
174
+ logger.info('Self-improve loop stopped');
175
+ }
176
+ /**
177
+ * Process all pending triggers. Public so tests + manual invocations
178
+ * (e.g., a `clementine self-improve tick` CLI command) can call it.
179
+ */
180
+ async tick() {
181
+ if (this.ticking)
182
+ return { processed: 0, applied: 0, pending: 0, noop: 0 };
183
+ this.ticking = true;
184
+ const counts = { processed: 0, applied: 0, pending: 0, noop: 0 };
185
+ try {
186
+ if (!existsSync(this.triggersDir))
187
+ return counts;
188
+ let files;
189
+ try {
190
+ files = readdirSync(this.triggersDir).filter((f) => f.endsWith('.json'));
191
+ }
192
+ catch {
193
+ return counts;
194
+ }
195
+ for (const file of files) {
196
+ const triggerPath = path.join(this.triggersDir, file);
197
+ let trigger;
198
+ try {
199
+ trigger = JSON.parse(readFileSync(triggerPath, 'utf-8'));
200
+ }
201
+ catch (err) {
202
+ logger.warn({ err, file }, 'Failed to parse trigger — removing');
203
+ try {
204
+ unlinkSync(triggerPath);
205
+ }
206
+ catch { /* ignore */ }
207
+ continue;
208
+ }
209
+ try {
210
+ await this.processOne(trigger, counts);
211
+ }
212
+ catch (err) {
213
+ logger.warn({ err, jobName: trigger.jobName }, 'Failed to process trigger — leaving in place for next tick');
214
+ continue;
215
+ }
216
+ // Successfully handled — remove the trigger
217
+ try {
218
+ unlinkSync(triggerPath);
219
+ }
220
+ catch { /* ignore */ }
221
+ counts.processed++;
222
+ }
223
+ }
224
+ finally {
225
+ this.ticking = false;
226
+ }
227
+ if (counts.processed > 0) {
228
+ logger.info(counts, 'Self-improve loop: processed triggers');
229
+ }
230
+ return counts;
231
+ }
232
+ async processOne(trigger, counts) {
233
+ const recipe = classifyFailure(trigger.recentErrors);
234
+ const lookup = loadCronJob(trigger.jobName, this.cronPath);
235
+ const agentSlug = lookup?.agentSlug;
236
+ if (recipe.category === 'safe-cron-config') {
237
+ const applied = applyCronEdit(trigger.jobName, recipe, this.cronPath);
238
+ if (applied) {
239
+ counts.applied++;
240
+ await this.notifyAgent(agentSlug, [
241
+ `🔧 **Auto-fixed** \`${trigger.jobName}\` after ${trigger.consecutiveErrors} consecutive failures.`,
242
+ '',
243
+ recipe.description,
244
+ '',
245
+ 'I\'ll watch the next run to confirm it lands cleanly.',
246
+ ].join('\n'));
247
+ }
248
+ else {
249
+ counts.noop++;
250
+ logger.info({ jobName: trigger.jobName }, 'Fix recipe applied is already in place — trigger removed without further action');
251
+ }
252
+ return;
253
+ }
254
+ if (recipe.category === 'noop') {
255
+ counts.noop++;
256
+ logger.info({ jobName: trigger.jobName, reason: recipe.description }, 'Self-improve: no-op');
257
+ return;
258
+ }
259
+ // risky | unknown → write proposal + DM agent
260
+ const id = `proposal-${Date.now()}-${trigger.jobName.replace(/[^a-z0-9-]/gi, '_')}`;
261
+ const record = {
262
+ id,
263
+ jobName: trigger.jobName,
264
+ ...(agentSlug ? { agentSlug } : {}),
265
+ category: recipe.category,
266
+ description: recipe.description,
267
+ recentErrors: trigger.recentErrors,
268
+ consecutiveErrors: trigger.consecutiveErrors,
269
+ proposedAt: new Date().toISOString(),
270
+ };
271
+ const file = writePendingChange(record, this.pendingDir);
272
+ counts.pending++;
273
+ await this.notifyAgent(agentSlug, [
274
+ `⚠️ **${trigger.jobName}** has failed ${trigger.consecutiveErrors} times in a row.`,
275
+ '',
276
+ recipe.description,
277
+ '',
278
+ `Proposal saved to \`${file}\`. Review when convenient.`,
279
+ '',
280
+ '_(approve flow via #audit-inbox buttons coming in P8b)_',
281
+ ].join('\n'));
282
+ }
283
+ async notifyAgent(agentSlug, message) {
284
+ try {
285
+ await this.dispatcher.send(message, agentSlug && agentSlug !== 'clementine' ? { agentSlug } : {});
286
+ }
287
+ catch (err) {
288
+ logger.debug({ err, agentSlug }, 'Failed to dispatch self-improve notification (non-fatal)');
289
+ }
290
+ }
291
+ }
292
+ //# sourceMappingURL=self-improve-loop.js.map
@@ -63,8 +63,15 @@ export declare class AgentBotClient {
63
63
  *
64
64
  * Priority:
65
65
  * 1. Explicit channelIds from config (e.g. discordChannelId in agent.md)
66
- * 2. Match by channelName in any guild the bot is in
67
- * 3. All text channels the bot can see (fallback for simple setups)
66
+ * 2. Match by channelName in any guild the bot is in (single name or array)
67
+ * 3. **DM-only.** If neither is configured, the bot does not subscribe to any
68
+ * text channel — it only responds in DMs. Each agent has its own bot
69
+ * token specifically so it has its own DM lane to the owner; spamming
70
+ * every visible channel by default is the opposite of what users want.
71
+ *
72
+ * Previously this fell back to "all visible text channels," which made
73
+ * Ross + Nora respond everywhere in guild because they had no channelName
74
+ * set. Opt-in is the correct default.
68
75
  */
69
76
  private discoverChannels;
70
77
  /**
@@ -200,8 +200,15 @@ export class AgentBotClient {
200
200
  *
201
201
  * Priority:
202
202
  * 1. Explicit channelIds from config (e.g. discordChannelId in agent.md)
203
- * 2. Match by channelName in any guild the bot is in
204
- * 3. All text channels the bot can see (fallback for simple setups)
203
+ * 2. Match by channelName in any guild the bot is in (single name or array)
204
+ * 3. **DM-only.** If neither is configured, the bot does not subscribe to any
205
+ * text channel — it only responds in DMs. Each agent has its own bot
206
+ * token specifically so it has its own DM lane to the owner; spamming
207
+ * every visible channel by default is the opposite of what users want.
208
+ *
209
+ * Previously this fell back to "all visible text channels," which made
210
+ * Ross + Nora respond everywhere in guild because they had no channelName
211
+ * set. Opt-in is the correct default.
205
212
  */
206
213
  discoverChannels() {
207
214
  // 1. Explicit IDs
@@ -225,19 +232,13 @@ export class AgentBotClient {
225
232
  logger.info({ slug: this.config.slug, channelNames, matched }, 'Auto-discovered channels by name');
226
233
  return matched;
227
234
  }
228
- logger.warn({ slug: this.config.slug, channelNames }, 'No channels found matching channelName(s) — falling back to all visible text channels');
229
- }
230
- // 3. Fallback: all text channels the bot can see
231
- const all = [];
232
- for (const guild of this.client.guilds.cache.values()) {
233
- for (const channel of guild.channels.cache.values()) {
234
- if (channel.type === ChannelType.GuildText) {
235
- all.push(channel.id);
236
- }
237
- }
235
+ logger.warn({ slug: this.config.slug, channelNames }, 'No channels found matching channelName(s) — falling back to DM-only');
238
236
  }
239
- logger.info({ slug: this.config.slug, count: all.length }, 'Fallback: listening in all visible text channels');
240
- return all;
237
+ // 3. DM-only. Bot will still respond to DMs (handleMessage checks isDMBased
238
+ // before consulting resolvedChannelIds), so this is the right "no channels"
239
+ // default — not silence.
240
+ logger.info({ slug: this.config.slug }, 'Bot in DM-only mode (no channelName configured)');
241
+ return [];
241
242
  }
242
243
  /**
243
244
  * Send a notification to the owner's DMs on behalf of this agent bot.
package/dist/index.js CHANGED
@@ -665,6 +665,11 @@ async function asyncMain() {
665
665
  // agent's profile and routes output to their Discord channel.
666
666
  const { AgentHeartbeatManager } = await import('./gateway/agent-heartbeat-manager.js');
667
667
  const agentHeartbeats = new AgentHeartbeatManager(gateway.getAgentManager(), gateway);
668
+ // Self-improve loop — closes the gap between "trigger written" and
669
+ // "fix applied." Every 10 min, scans self-improve/triggers/, classifies
670
+ // failures, auto-applies safe cron-config fixes, escalates risky ones.
671
+ const { SelfImproveLoop } = await import('./agent/self-improve-loop.js');
672
+ const selfImproveLoop = new SelfImproveLoop(dispatcher);
668
673
  // ── Build channel tasks ──────────────────────────────────────────
669
674
  const channelTasks = [];
670
675
  const activeChannels = [];
@@ -762,6 +767,7 @@ async function asyncMain() {
762
767
  heartbeat.start();
763
768
  cronScheduler.start();
764
769
  agentHeartbeats.start();
770
+ selfImproveLoop.start();
765
771
  // Background-task hygiene: any task left in 'running' is from a prior
766
772
  // process. Mark them aborted so the lifecycle is honest. (P6b will add
767
773
  // resumability; for now fail-fast is clearer than silently re-running.)
@@ -966,6 +972,7 @@ async function asyncMain() {
966
972
  heartbeat.stop();
967
973
  cronScheduler.stop();
968
974
  agentHeartbeats.stop();
975
+ selfImproveLoop.stop();
969
976
  // ── Self-restart (enhanced with health check + rollback) ────────
970
977
  if (restartRequested) {
971
978
  // Clear our PID file BEFORE spawning the child, so ensureSingleton()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.90",
3
+ "version": "1.0.92",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",