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.
- package/dist/agent/self-improve-loop.d.ts +19 -0
- package/dist/agent/self-improve-loop.js +106 -22
- package/dist/cli/dashboard.js +78 -13
- package/dist/gateway/cron-scheduler.js +10 -2
- package/dist/gateway/heartbeat-scheduler.d.ts +9 -0
- package/dist/gateway/heartbeat-scheduler.js +74 -0
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
*
|
|
125
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -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
|
|
15267
|
-
//
|
|
15268
|
-
//
|
|
15269
|
-
//
|
|
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
|
|
15283
|
-
|
|
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 (
|
|
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(
|
|
15303
|
+
await refreshBuilderCanvasPicker('workflow');
|
|
15292
15304
|
await openBuilderWorkflow(r.id);
|
|
15293
|
-
toast('Created ' +
|
|
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) {
|
|
19509
|
-
|
|
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(
|
|
21231
|
-
html += '<button class="btn-sm" onclick="memoryHealthAction(
|
|
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[];
|