clementine-agent 1.7.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
  }
@@ -15255,6 +15255,12 @@ function switchBuildTab(tab) {
15255
15255
  if (typeof _ensureDrawflowLoaded === 'function') {
15256
15256
  _ensureDrawflowLoaded().catch(function() { /* */ });
15257
15257
  }
15258
+ // Populate the Owner picker once per session — agents rarely change
15259
+ // mid-session, so the cost is minimal and the dropdown is ready by
15260
+ // the time the user clicks New.
15261
+ if (typeof populateBuilderOwnerPicker === 'function') {
15262
+ populateBuilderOwnerPicker().catch(function() { /* */ });
15263
+ }
15258
15264
  // Focus chat input
15259
15265
  setTimeout(function() {
15260
15266
  var bi = document.getElementById('builder-input');
@@ -15263,10 +15269,10 @@ function switchBuildTab(tab) {
15263
15269
  }
15264
15270
  }
15265
15271
 
15266
- // "New" button in the Build header strip — context-aware: prompts for a
15267
- // name and creates the right artifact for the active tab. Workflows + Crons
15268
- // route through the workflow_create surface; Skills falls back to the
15269
- // existing chat-based Skill Studio reset.
15272
+ // "New" button in the Build header strip — context-aware. Crons open the
15273
+ // dedicated cron modal so the entry lands in CRON.md (not as a one-step
15274
+ // workflow file). Workflows route through /api/builder/workflows. Owner is
15275
+ // read from the header's Owner picker — empty string means global.
15270
15276
  async function newFromBuildHeader() {
15271
15277
  var activeTab = document.querySelector('#build-tabs button.active')?.getAttribute('data-build-tab') || 'workflows';
15272
15278
  if (activeTab === 'skills') {
@@ -15279,22 +15285,72 @@ async function newFromBuildHeader() {
15279
15285
  toast('Pick a template to fork from the cards.', 'info');
15280
15286
  return;
15281
15287
  }
15282
- var noun = activeTab === 'crons' ? 'cron' : 'workflow';
15283
- var name = prompt('Name your new ' + noun + ':');
15288
+ var owner = (document.getElementById('builder-owner') || {}).value || '';
15289
+ if (activeTab === 'crons') {
15290
+ if (typeof openCreateCronModal === 'function') {
15291
+ openCreateCronModal(owner);
15292
+ return;
15293
+ }
15294
+ }
15295
+ var name = prompt('Name your new workflow:');
15284
15296
  if (!name || !name.trim()) return;
15285
15297
  try {
15286
15298
  var body = { name: name.trim() };
15287
- if (activeTab === 'crons') body.schedule = '0 9 * * *'; // sensible default; user edits in canvas
15299
+ if (owner) body.agent = owner;
15288
15300
  var r = await apiJson('POST', '/api/builder/workflows', body);
15289
15301
  if (r && r.error) { toast('Create failed: ' + r.error, 'error'); return; }
15290
15302
  if (r && r.id) {
15291
- await refreshBuilderCanvasPicker(activeTab === 'crons' ? 'cron' : 'workflow');
15303
+ await refreshBuilderCanvasPicker('workflow');
15292
15304
  await openBuilderWorkflow(r.id);
15293
- toast('Created ' + noun + ': ' + name, 'success');
15305
+ toast('Created workflow: ' + name + (owner ? ' (' + owner + ')' : ''), 'success');
15294
15306
  }
15295
15307
  } catch (err) { toast('Create error: ' + err, 'error'); }
15296
15308
  }
15297
15309
 
15310
+ // Owner picker — populated from /api/agents on first build-tab activation
15311
+ // and refreshed on demand. Empty value = Clementine/global; any other value
15312
+ // is the agent slug for scoped reads/writes.
15313
+ var _builderOwnerPickerLoaded = false;
15314
+ async function populateBuilderOwnerPicker(force) {
15315
+ if (_builderOwnerPickerLoaded && !force) return;
15316
+ var sel = document.getElementById('builder-owner');
15317
+ if (!sel) return;
15318
+ try {
15319
+ var r = await apiFetch('/api/agents');
15320
+ var agents = r.ok ? await r.json() : [];
15321
+ var prev = sel.value;
15322
+ var opts = '<option value="">Clementine (global)</option>';
15323
+ if (Array.isArray(agents)) {
15324
+ for (var i = 0; i < agents.length; i++) {
15325
+ var slug = agents[i] && agents[i].slug;
15326
+ if (!slug) continue;
15327
+ var label = agents[i].name ? (agents[i].name + ' (' + slug + ')') : slug;
15328
+ opts += '<option value="' + esc(slug) + '">' + esc(label) + '</option>';
15329
+ }
15330
+ }
15331
+ sel.innerHTML = opts;
15332
+ if (prev) sel.value = prev;
15333
+ _builderOwnerPickerLoaded = true;
15334
+ } catch (err) {
15335
+ // Leave the default global option in place; not fatal.
15336
+ }
15337
+ }
15338
+
15339
+ // Mirror the visible owner selection into the legacy hidden builder-agent
15340
+ // input + label so chat/skill/agent flows that already read those keep
15341
+ // working, then refresh the canvas picker so the list re-filters.
15342
+ async function onBuilderOwnerChange() {
15343
+ var sel = document.getElementById('builder-owner');
15344
+ var owner = sel ? sel.value : '';
15345
+ var hidden = document.getElementById('builder-agent');
15346
+ var label = document.getElementById('builder-agent-label');
15347
+ if (hidden) hidden.value = owner || '';
15348
+ if (label) label.textContent = owner ? 'Owner: ' + owner : '';
15349
+ var typeSel = document.getElementById('builder-type');
15350
+ var type = typeSel && typeSel.value === 'cron' ? 'cron' : 'workflow';
15351
+ await refreshBuilderCanvasPicker(type);
15352
+ }
15353
+
15298
15354
  // ── Build templates: fork a starter pattern into a new workflow ─────
15299
15355
  async function forkBuildTemplate(templateId) {
15300
15356
  var templates = {
@@ -19503,10 +19559,19 @@ async function refreshBuilderCanvasPicker(type) {
19503
19559
  var picker = document.getElementById('builder-canvas-picker');
19504
19560
  if (!picker) return;
19505
19561
  try {
19562
+ var owner = (document.getElementById('builder-owner') || {}).value || '';
19506
19563
  var r = await apiFetch('/api/builder/workflows');
19507
19564
  var d = await r.json();
19508
- var items = (d.workflows || []).filter(function(w) { return w.origin === type; });
19509
- var opts = '<option value="">' + (items.length ? '— pick a ' + type + ' —' : '(none yet)') + '</option>';
19565
+ var items = (d.workflows || []).filter(function(w) {
19566
+ if (w.origin !== type) return false;
19567
+ // Owner filter: empty owner = Clementine/global only; named owner =
19568
+ // agent-scoped entries for that slug only. The serializer reports
19569
+ // scope='agent' for entries living under <AGENTS_DIR>/<slug>/.
19570
+ if (owner) return w.scope === 'agent' && w.agentSlug === owner;
19571
+ return w.scope !== 'agent';
19572
+ });
19573
+ var ownerLabel = owner ? '@' + owner : 'global';
19574
+ var opts = '<option value="">' + (items.length ? '— pick a ' + type + ' (' + ownerLabel + ') —' : '(none yet for ' + ownerLabel + ')') + '</option>';
19510
19575
  for (var i = 0; i < items.length; i++) {
19511
19576
  var w = items[i];
19512
19577
  var lbl = w.name + (w.schedule ? ' · ' + w.schedule : '') + (w.enabled ? '' : ' · off');
@@ -21227,8 +21292,8 @@ async function refreshMemoryHealth() {
21227
21292
  html += '<div style="font-weight:600;margin-bottom:4px">Retrieval running on sparse vectors for ' + missing.toLocaleString() + ' chunks</div>';
21228
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>';
21229
21294
  html += '</div>';
21230
- html += '<button class="btn-sm" onclick="memoryHealthAction(\'reembed-dense\', { limit: 200 })" title="Embed up to 200 chunks now">Backfill 200</button>';
21231
- 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>';
21232
21297
  html += '</div></div>';
21233
21298
  }
21234
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
  }
@@ -23,6 +23,7 @@ export declare class HeartbeatScheduler {
23
23
  private lastDenseBackfillAt;
24
24
  private denseBackfillInFlight;
25
25
  private lastSalienceDecayDate;
26
+ private lastMemoryPulseDate;
26
27
  /** Wire up the cron scheduler so daily plan suggestions can be applied. */
27
28
  setCronScheduler(cs: CronScheduler): void;
28
29
  private getLastAgentSiRun;
@@ -53,6 +54,14 @@ export declare class HeartbeatScheduler {
53
54
  * HeartbeatState. Pinned chunks exempt; soft-deleted and superseded skipped.
54
55
  */
55
56
  private maybeRunSalienceDecay;
57
+ /**
58
+ * Weekly Memory Pulse — once-per-week observability report on the memory
59
+ * subsystem. Aggregates the same signals visible on Brain → Health
60
+ * (coverage, recent writes, supersedes, recall contribution) into a
61
+ * compact message and dispatches it. Skipped if nothing meaningful
62
+ * happened this week (avoids empty noise on quiet weeks).
63
+ */
64
+ private maybeSendMemoryPulse;
56
65
  private runInsightCheck;
57
66
  /** Called when user replies to a proactive message — resets cooldown. */
58
67
  recordInsightAcknowledged(): void;
@@ -33,6 +33,7 @@ export class HeartbeatScheduler {
33
33
  lastDenseBackfillAt = 0;
34
34
  denseBackfillInFlight = false;
35
35
  lastSalienceDecayDate = '';
36
+ lastMemoryPulseDate = '';
36
37
  /** Wire up the cron scheduler so daily plan suggestions can be applied. */
37
38
  setCronScheduler(cs) { this.cronScheduler = cs; }
38
39
  getLastAgentSiRun(slug) {
@@ -53,6 +54,8 @@ export class HeartbeatScheduler {
53
54
  this.lastConsolidationDate = this.lastState.lastConsolidationDate;
54
55
  if (this.lastState.lastSalienceDecayDate)
55
56
  this.lastSalienceDecayDate = this.lastState.lastSalienceDecayDate;
57
+ if (this.lastState.lastMemoryPulseDate)
58
+ this.lastMemoryPulseDate = this.lastState.lastMemoryPulseDate;
56
59
  if (this.lastState.lastAgentSiRuns) {
57
60
  this.lastAgentSiRuns = new Map(Object.entries(this.lastState.lastAgentSiRuns));
58
61
  }
@@ -305,6 +308,9 @@ export class HeartbeatScheduler {
305
308
  }).catch(err => {
306
309
  logger.warn({ err }, 'Weekly review failed');
307
310
  });
311
+ // Memory Pulse — weekly observability report on the 5-phase memory
312
+ // system. Skipped if nothing happened (avoids empty noise on quiet weeks).
313
+ this.maybeSendMemoryPulse();
308
314
  }
309
315
  // First Monday of month: monthly assessment (between 8-9 PM)
310
316
  if (now.getDay() === 1 && now.getDate() <= 7 && hour >= 20 && hour < 21) {
@@ -805,6 +811,74 @@ export class HeartbeatScheduler {
805
811
  logger.debug({ err }, 'Salience decay sweep failed (non-fatal)');
806
812
  }
807
813
  }
814
+ /**
815
+ * Weekly Memory Pulse — once-per-week observability report on the memory
816
+ * subsystem. Aggregates the same signals visible on Brain → Health
817
+ * (coverage, recent writes, supersedes, recall contribution) into a
818
+ * compact message and dispatches it. Skipped if nothing meaningful
819
+ * happened this week (avoids empty noise on quiet weeks).
820
+ */
821
+ maybeSendMemoryPulse() {
822
+ const today = todayISO();
823
+ if (this.lastMemoryPulseDate === today)
824
+ return;
825
+ const store = this.gateway.getMemoryStore();
826
+ if (!store)
827
+ return;
828
+ try {
829
+ const stats = store.getMemoryStats();
830
+ const supersedeStats = typeof store.getSupersedeStats === 'function'
831
+ ? store.getSupersedeStats()
832
+ : { superseded: 0 };
833
+ const graphStats = typeof store.getGraphStats === 'function'
834
+ ? store.getGraphStats({ lookbackHours: 24 * 7 })
835
+ : { wikilinkCount: 0, recallContributionByType: {}, tracesAnalyzed: 0 };
836
+ const recentWrites = typeof store.getRecentWrites === 'function'
837
+ ? store
838
+ .getRecentWrites(500)
839
+ : [];
840
+ const weekAgoIso = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
841
+ const writesThisWeek = recentWrites.filter(w => w.extractedAt > weekAgoIso);
842
+ const supersedesThisWeek = writesThisWeek.filter(w => w.status === 'superseded').length;
843
+ const dedupedThisWeek = writesThisWeek.filter(w => w.status === 'dedup_skipped').length;
844
+ const writesWithSalience = writesThisWeek.filter(w => w.salienceHint != null).length;
845
+ // Skip if nothing happened (no writes, no traces) — don't spam empty reports
846
+ if (writesThisWeek.length === 0 && graphStats.tracesAnalyzed === 0)
847
+ return;
848
+ const coveragePct = stats.totalChunks > 0
849
+ ? Math.round((stats.chunksWithDenseEmbeddings / stats.totalChunks) * 100)
850
+ : 0;
851
+ const cb = graphStats.recallContributionByType;
852
+ const totalMatches = Object.values(cb).reduce((a, b) => a + b, 0);
853
+ const pctOf = (n) => totalMatches > 0 ? Math.round((n / totalMatches) * 100) : 0;
854
+ const lines = [
855
+ `**Memory Pulse — last 7 days**`,
856
+ ``,
857
+ `**Coverage:** ${coveragePct}% semantic (${stats.chunksWithDenseEmbeddings.toLocaleString()}/${stats.totalChunks.toLocaleString()} chunks)`,
858
+ ];
859
+ if (writesThisWeek.length > 0) {
860
+ lines.push(`**Writes:** ${writesThisWeek.length} captured`
861
+ + (writesWithSalience > 0 ? `, ${writesWithSalience} with salience hint` : '')
862
+ + (dedupedThisWeek > 0 ? `, ${dedupedThisWeek} reinforced` : ''));
863
+ }
864
+ if (supersedesThisWeek > 0 || supersedeStats.superseded > 0) {
865
+ lines.push(`**Self-correction:** ${supersedesThisWeek} this week, ${supersedeStats.superseded} all-time`);
866
+ }
867
+ if (graphStats.tracesAnalyzed > 0 && totalMatches > 0) {
868
+ lines.push(`**Recall mix:** ${pctOf(cb.fts ?? 0)}% lexical · ${pctOf(cb.vector ?? 0)}% semantic · ${pctOf(cb.graph ?? 0)}% graph · ${pctOf(cb.recency ?? 0)}% recent`);
869
+ }
870
+ lines.push(``);
871
+ lines.push(`Full breakdown on the dashboard: Brain → Health.`);
872
+ this.dispatcher.send(lines.join('\n')).catch(err => logger.debug({ err }, 'Failed to send memory pulse'));
873
+ this.lastMemoryPulseDate = today;
874
+ this.lastState.lastMemoryPulseDate = today;
875
+ this.saveState();
876
+ logger.info({ coveragePct, writesThisWeek: writesThisWeek.length, supersedesThisWeek }, 'Memory Pulse sent');
877
+ }
878
+ catch (err) {
879
+ logger.debug({ err }, 'Memory Pulse failed (non-fatal)');
880
+ }
881
+ }
808
882
  async runInsightCheck() {
809
883
  // Initialize insight state if needed
810
884
  if (!this.lastState.insightState) {
package/dist/types.d.ts CHANGED
@@ -216,6 +216,7 @@ export interface HeartbeatState {
216
216
  lastAgentSiRuns?: Record<string, string>;
217
217
  lastSkillDecayDate?: string;
218
218
  lastSalienceDecayDate?: string;
219
+ lastMemoryPulseDate?: string;
219
220
  /** Proactive insight engine state */
220
221
  insightState?: {
221
222
  sentToday: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",