clementine-agent 1.8.0 → 1.8.1

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.
@@ -26,6 +26,19 @@
26
26
  */
27
27
  export interface TriggerFile {
28
28
  jobName: string;
29
+ /**
30
+ * Bare job name (without `{agentSlug}:` prefix). Set by cron-scheduler
31
+ * for agent-scoped jobs so the loop can look the job up in
32
+ * agents/{agentSlug}/CRON.md. Optional for backward compat with
33
+ * triggers written before this field existed.
34
+ */
35
+ bareName?: string;
36
+ /**
37
+ * Owning agent slug, set by cron-scheduler. When present, the loop
38
+ * applies fixes to vault/00-System/agents/{agentSlug}/CRON.md instead
39
+ * of the central CRON.md. Falls back to scanning if absent (older triggers).
40
+ */
41
+ agentSlug?: string;
29
42
  consecutiveErrors: number;
30
43
  recentErrors: string[];
31
44
  triggeredAt: string;
@@ -61,6 +74,11 @@ export interface SelfImproveLoopOptions {
61
74
  triggersDir?: string;
62
75
  pendingDir?: string;
63
76
  cronPath?: string;
77
+ /**
78
+ * Override the agents root (vault/00-System/agents). When a trigger
79
+ * has agentSlug, the loop reads/writes `${agentsDir}/${agentSlug}/CRON.md`.
80
+ */
81
+ agentsDir?: string;
64
82
  /**
65
83
  * Disable the fs.watch event-driven path. Tests use this so they can
66
84
  * call tick() directly without racing the watcher.
@@ -73,6 +91,7 @@ export declare class SelfImproveLoop {
73
91
  private readonly triggersDir;
74
92
  private readonly pendingDir;
75
93
  private readonly cronPath;
94
+ private readonly agentsDir;
76
95
  private readonly dispatcher;
77
96
  private readonly watchEnabled;
78
97
  private timer;
@@ -28,7 +28,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, watch, wr
28
28
  import path from 'node:path';
29
29
  import matter from 'gray-matter';
30
30
  import pino from 'pino';
31
- import { BASE_DIR, SYSTEM_DIR } from '../config.js';
31
+ import { AGENTS_DIR, BASE_DIR, SYSTEM_DIR } from '../config.js';
32
32
  const logger = pino({ name: 'clementine.self-improve-loop' });
33
33
  /**
34
34
  * Fallback tick interval. The loop is primarily event-driven via fs.watch
@@ -46,6 +46,7 @@ const WATCH_DEBOUNCE_MS = 2000;
46
46
  const TRIGGERS_DIR = path.join(BASE_DIR, 'self-improve', 'triggers');
47
47
  const PENDING_CHANGES_DIR = path.join(BASE_DIR, 'self-improve', 'pending-changes');
48
48
  const CRON_PATH = path.join(SYSTEM_DIR, 'CRON.md');
49
+ const AGENTS_ROOT = AGENTS_DIR;
49
50
  // ── Pattern recognition ──────────────────────────────────────────────
50
51
  const PATTERNS = [
51
52
  {
@@ -108,36 +109,108 @@ export function classifyFailure(recentErrors) {
108
109
  description: 'Unrecognized failure pattern. Owner needs to inspect the trigger file.',
109
110
  };
110
111
  }
111
- function loadCronJob(jobName, cronPath) {
112
+ function readJobsFromFile(cronPath) {
112
113
  if (!existsSync(cronPath))
113
114
  return null;
114
115
  const raw = readFileSync(cronPath, 'utf-8');
115
116
  const parsed = matter(raw);
116
117
  const jobs = (parsed.data.jobs ?? []);
117
- const job = jobs.find((j) => String(j.name ?? '') === jobName);
118
- if (!job)
119
- return null;
120
- const agentSlug = typeof job.agentSlug === 'string' ? job.agentSlug : (typeof job.agent_slug === 'string' ? job.agent_slug : undefined);
121
- return { agentSlug, job, raw, parsed };
118
+ return { raw, parsed, jobs };
119
+ }
120
+ function readAgentSlug(job) {
121
+ if (typeof job.agentSlug === 'string')
122
+ return job.agentSlug;
123
+ if (typeof job.agent_slug === 'string')
124
+ return job.agent_slug;
125
+ return undefined;
122
126
  }
123
127
  /**
124
- * Apply the recipe's mutator to the job's frontmatter and write CRON.md
125
- * back atomically. Returns true if a change was actually written.
128
+ * Locate a job's frontmatter entry in either the central CRON.md or an
129
+ * agent-scoped CRON.md. Search priority:
130
+ *
131
+ * 1. If trigger.agentSlug is set, look in agents/{slug}/CRON.md by bareName.
132
+ * 2. Otherwise look in central CRON.md by exact name.
133
+ * 3. Fall back to scanning agents/* for the bareName (covers older triggers
134
+ * that lack agentSlug — the cron-scheduler-prefixed jobName like
135
+ * `slug:name` lets us recover the slug).
126
136
  */
127
- function applyCronEdit(jobName, recipe, cronPath) {
137
+ function loadCronJob(trigger, cronPath, agentsDir) {
138
+ const explicitSlug = trigger.agentSlug;
139
+ const bare = trigger.bareName ?? (explicitSlug && trigger.jobName.startsWith(`${explicitSlug}:`)
140
+ ? trigger.jobName.slice(explicitSlug.length + 1)
141
+ : trigger.jobName);
142
+ // 1. Agent-scoped file when slug is known
143
+ if (explicitSlug) {
144
+ const agentCronPath = path.join(agentsDir, explicitSlug, 'CRON.md');
145
+ const file = readJobsFromFile(agentCronPath);
146
+ if (file) {
147
+ const job = file.jobs.find((j) => String(j.name ?? '') === bare);
148
+ if (job) {
149
+ return {
150
+ agentSlug: explicitSlug,
151
+ cronPath: agentCronPath,
152
+ bareName: bare,
153
+ job,
154
+ raw: file.raw,
155
+ parsed: file.parsed,
156
+ };
157
+ }
158
+ }
159
+ }
160
+ // 2. Central CRON.md by full jobName (handles globally-defined jobs and
161
+ // legacy jobs tagged with agentSlug field directly in the central file)
162
+ const central = readJobsFromFile(cronPath);
163
+ if (central) {
164
+ const job = central.jobs.find((j) => String(j.name ?? '') === trigger.jobName);
165
+ if (job) {
166
+ return {
167
+ agentSlug: explicitSlug ?? readAgentSlug(job),
168
+ cronPath,
169
+ bareName: String(job.name ?? ''),
170
+ job,
171
+ raw: central.raw,
172
+ parsed: central.parsed,
173
+ };
174
+ }
175
+ }
176
+ // 3. Recover via scan: trigger jobName follows `{slug}:{bareName}` for
177
+ // agent-scoped jobs even when older triggers omit agentSlug.
178
+ if (!explicitSlug && trigger.jobName.includes(':')) {
179
+ const [slug, ...rest] = trigger.jobName.split(':');
180
+ const inferredBare = rest.join(':');
181
+ if (slug && inferredBare) {
182
+ const agentCronPath = path.join(agentsDir, slug, 'CRON.md');
183
+ const file = readJobsFromFile(agentCronPath);
184
+ if (file) {
185
+ const job = file.jobs.find((j) => String(j.name ?? '') === inferredBare);
186
+ if (job) {
187
+ return {
188
+ agentSlug: slug,
189
+ cronPath: agentCronPath,
190
+ bareName: inferredBare,
191
+ job,
192
+ raw: file.raw,
193
+ parsed: file.parsed,
194
+ };
195
+ }
196
+ }
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+ /**
202
+ * Apply the recipe's mutator to the job's frontmatter and write the CRON.md
203
+ * (central or agent-scoped, whichever the lookup resolved to) back atomically.
204
+ * Returns true if a change was actually written.
205
+ */
206
+ function applyCronEdit(lookup, recipe) {
128
207
  if (!recipe.apply)
129
208
  return false;
130
- const lookup = loadCronJob(jobName, cronPath);
131
- if (!lookup) {
132
- logger.warn({ jobName }, 'Job not found in CRON.md — cannot apply fix');
133
- return false;
134
- }
135
209
  const changed = recipe.apply(lookup.job);
136
210
  if (!changed)
137
211
  return false;
138
- // Re-stringify with the existing content body preserved.
139
212
  const updated = matter.stringify(lookup.parsed.content, lookup.parsed.data);
140
- writeFileSync(cronPath, updated);
213
+ writeFileSync(lookup.cronPath, updated);
141
214
  return true;
142
215
  }
143
216
  function writePendingChange(record, dir) {
@@ -152,6 +225,7 @@ export class SelfImproveLoop {
152
225
  triggersDir;
153
226
  pendingDir;
154
227
  cronPath;
228
+ agentsDir;
155
229
  dispatcher;
156
230
  watchEnabled;
157
231
  timer = null;
@@ -165,6 +239,7 @@ export class SelfImproveLoop {
165
239
  this.triggersDir = opts.triggersDir ?? TRIGGERS_DIR;
166
240
  this.pendingDir = opts.pendingDir ?? PENDING_CHANGES_DIR;
167
241
  this.cronPath = opts.cronPath ?? CRON_PATH;
242
+ this.agentsDir = opts.agentsDir ?? AGENTS_ROOT;
168
243
  this.watchEnabled = opts.disableWatch !== true;
169
244
  }
170
245
  start() {
@@ -286,23 +361,32 @@ export class SelfImproveLoop {
286
361
  }
287
362
  async processOne(trigger, counts) {
288
363
  const recipe = classifyFailure(trigger.recentErrors);
289
- const lookup = loadCronJob(trigger.jobName, this.cronPath);
290
- const agentSlug = lookup?.agentSlug;
364
+ const lookup = loadCronJob(trigger, this.cronPath, this.agentsDir);
365
+ const agentSlug = trigger.agentSlug ?? lookup?.agentSlug;
291
366
  if (recipe.category === 'safe-cron-config') {
292
- const applied = applyCronEdit(trigger.jobName, recipe, this.cronPath);
367
+ if (!lookup) {
368
+ // Job vanished from CRON files (renamed/deleted). Nothing to fix.
369
+ counts.noop++;
370
+ logger.warn({ jobName: trigger.jobName, agentSlug }, 'Job not found in any CRON.md — cannot apply fix');
371
+ return;
372
+ }
373
+ const applied = applyCronEdit(lookup, recipe);
293
374
  if (applied) {
294
375
  counts.applied++;
376
+ const where = lookup.agentSlug
377
+ ? `\`agents/${lookup.agentSlug}/CRON.md\``
378
+ : '`CRON.md`';
295
379
  await this.notifyAgent(agentSlug, [
296
380
  `🔧 **Auto-fixed** \`${trigger.jobName}\` after ${trigger.consecutiveErrors} consecutive failures.`,
297
381
  '',
298
382
  recipe.description,
299
383
  '',
300
- 'I\'ll watch the next run to confirm it lands cleanly.',
384
+ `Edit applied to ${where}. I'll watch the next run to confirm it lands cleanly.`,
301
385
  ].join('\n'));
302
386
  }
303
387
  else {
304
388
  counts.noop++;
305
- logger.info({ jobName: trigger.jobName }, 'Fix recipe applied is already in place — trigger removed without further action');
389
+ logger.info({ jobName: trigger.jobName, agentSlug }, 'Fix recipe applied is already in place — trigger removed without further action');
306
390
  }
307
391
  return;
308
392
  }
@@ -21292,8 +21292,8 @@ async function refreshMemoryHealth() {
21292
21292
  html += '<div style="font-weight:600;margin-bottom:4px">Retrieval running on sparse vectors for ' + missing.toLocaleString() + ' chunks</div>';
21293
21293
  html += '<div style="font-size:12px;color:var(--text-muted)">Backfill builds 768-dim neural embeddings for semantic search. First run downloads ~440MB.</div>';
21294
21294
  html += '</div>';
21295
- html += '<button class="btn-sm" onclick="memoryHealthAction(\'reembed-dense\', { limit: 200 })" title="Embed up to 200 chunks now">Backfill 200</button>';
21296
- html += '<button class="btn-sm" onclick="memoryHealthAction(\'reembed-dense\', { limit: 2000 })" title="Embed up to 2000 chunks now (slower)">Backfill 2000</button>';
21295
+ html += '<button class="btn-sm" onclick="memoryHealthAction(\\'reembed-dense\\', { limit: 200 })" title="Embed up to 200 chunks now">Backfill 200</button>';
21296
+ html += '<button class="btn-sm" onclick="memoryHealthAction(\\'reembed-dense\\', { limit: 2000 })" title="Embed up to 2000 chunks now (slower)">Backfill 2000</button>';
21297
21297
  html += '</div></div>';
21298
21298
  }
21299
21299
 
@@ -1184,19 +1184,27 @@ export class CronScheduler {
1184
1184
  if (advice.shouldEscalate) {
1185
1185
  this.logAdvisorEvent('escalation', job.name, advice.escalationReason ?? 'Escalated to unleashed');
1186
1186
  }
1187
- // Write targeted self-improvement trigger when consecutive errors are high
1187
+ // Write targeted self-improvement trigger when consecutive errors are high.
1188
+ // Include agentSlug + bareName so the self-improve loop can locate jobs
1189
+ // defined in per-agent CRON.md files (vault/00-System/agents/{slug}/CRON.md)
1190
+ // rather than only the central one.
1188
1191
  if (consErrors >= 3) {
1189
1192
  try {
1190
1193
  const triggerDir = path.join(BASE_DIR, 'self-improve', 'triggers');
1191
1194
  mkdirSync(triggerDir, { recursive: true });
1192
1195
  const triggerPath = path.join(triggerDir, `${job.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
1196
+ const bareName = job.agentSlug && job.name.startsWith(`${job.agentSlug}:`)
1197
+ ? job.name.slice(job.agentSlug.length + 1)
1198
+ : job.name;
1193
1199
  writeFileSync(triggerPath, JSON.stringify({
1194
1200
  jobName: job.name,
1201
+ bareName,
1202
+ agentSlug: job.agentSlug,
1195
1203
  consecutiveErrors: consErrors,
1196
1204
  recentErrors: this.runLog.readRecent(job.name, 3).map(e => e.error?.slice(0, 200)),
1197
1205
  triggeredAt: new Date().toISOString(),
1198
1206
  }, null, 2));
1199
- logger.info({ job: job.name, consErrors }, 'Wrote self-improvement trigger for failing job');
1207
+ logger.info({ job: job.name, agentSlug: job.agentSlug, consErrors }, 'Wrote self-improvement trigger for failing job');
1200
1208
  }
1201
1209
  catch { /* non-fatal */ }
1202
1210
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",