clementine-agent 1.18.141 → 1.18.143

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.
@@ -39309,11 +39309,23 @@ async function refreshWorkflows() {
39309
39309
  var r = await apiFetch('/api/workflows');
39310
39310
  var data = await r.json();
39311
39311
  var workflows = data.workflows || [];
39312
+ // 1.18.142 — Soft-deprecation banner. Always shown when there's at least
39313
+ // one workflow on disk, hidden when the user has fully migrated. New
39314
+ // users who never had workflows in the first place see a different empty
39315
+ // state pointing them at Skills.
39316
+ var banner = '';
39317
+ if (workflows.length > 0) {
39318
+ banner = '<div class="card" style="margin-bottom:14px;padding:12px 14px;background:var(--accent-glow);border-left:3px solid var(--accent)">' +
39319
+ '<div style="font-weight:600;margin-bottom:4px">Workflows are being phased out → Skills</div>' +
39320
+ '<div style="font-size:13px;color:var(--text-secondary);line-height:1.5">' +
39321
+ 'Skills cover the same use cases with better composability and the new builder. Use the <strong>Migrate</strong> button on any workflow row below to convert it into a vanilla Anthropic skill folder. Your existing workflows keep firing until you migrate them — nothing breaks.' +
39322
+ '</div></div>';
39323
+ }
39312
39324
  if (workflows.length === 0) {
39313
- containers.forEach(function(c) { c.innerHTML = '<div class="empty-state">No workflows defined. Create .md files in vault/00-System/workflows/</div>'; });
39325
+ containers.forEach(function(c) { c.innerHTML = '<div class="empty-state">No workflows defined. <strong>Skills are the way forward</strong> — create one from the Skills tab. Workflow .md files in vault/00-System/workflows/ still work for legacy setups.</div>'; });
39314
39326
  return;
39315
39327
  }
39316
- var html = '';
39328
+ var html = banner;
39317
39329
  workflows.forEach(function(wf) {
39318
39330
  var triggerLabel = wf.trigger && wf.trigger.schedule ? describeCron(wf.trigger.schedule) || wf.trigger.schedule : 'Manual only';
39319
39331
  var stepCount = wf.steps ? wf.steps.length : 0;
@@ -39324,6 +39336,7 @@ async function refreshWorkflows() {
39324
39336
  html += '<span class="badge ' + (wf.enabled ? 'badge-green' : 'badge-gray') + '" style="font-size:10px">' + (wf.enabled ? 'Enabled' : 'Disabled') + '</span>';
39325
39337
  html += '<button class="btn btn-sm" onclick="runWorkflow(\\x27' + esc(wf.name) + '\\x27)" style="font-size:10px;color:var(--green)">Run</button>';
39326
39338
  html += '<button class="btn btn-sm" onclick="showWorkflowRuns(\\x27' + esc(wf.name) + '\\x27)" style="font-size:10px">History</button>';
39339
+ html += '<button class="btn btn-sm" onclick="migrateWorkflowToSkill(\\x27' + esc(wf.name) + '\\x27)" style="font-size:10px;color:var(--accent)" title="Convert this workflow into a vanilla Anthropic skill folder. Original is renamed to .md.migrated, kept on disk for rollback.">Migrate → Skill</button>';
39327
39340
  html += '</div>';
39328
39341
  html += '<div class="card-body">';
39329
39342
  if (wf.description) html += '<div style="font-size:13px;color:var(--text-secondary);margin-bottom:8px">' + esc(wf.description) + '</div>';
@@ -39367,6 +39380,30 @@ async function runWorkflow(name) {
39367
39380
  } catch(e) { toast(String(e), 'error'); }
39368
39381
  }
39369
39382
 
39383
+ // 1.18.142 — Convert a workflow into a vanilla skill folder, then refresh
39384
+ // the list so the migrated workflow disappears (its .md is renamed to
39385
+ // .md.migrated server-side and parseAllWorkflows stops picking it up).
39386
+ async function migrateWorkflowToSkill(name) {
39387
+ if (!confirm('Migrate "' + name + '" into a skill folder?\n\n' +
39388
+ '• A new skill will be created at vault/00-System/skills/<slug>/SKILL.md\n' +
39389
+ '• The original workflow .md will be renamed to .md.migrated (kept for rollback)\n' +
39390
+ '• The workflow stops firing; the skill is ready to schedule from the Skills tab')) return;
39391
+ try {
39392
+ var r = await apiFetch('/api/workflows/' + encodeURIComponent(name) + '/migrate-to-skill', {
39393
+ method: 'POST',
39394
+ headers: { 'Content-Type': 'application/json' },
39395
+ body: JSON.stringify({}),
39396
+ });
39397
+ var d = await r.json();
39398
+ if (d.ok) {
39399
+ toast('Migrated "' + name + '" → skill "' + (d.skill && d.skill.name || '?') + '"' + (d.warning ? ' (' + d.warning + ')' : ''));
39400
+ refreshWorkflows();
39401
+ } else {
39402
+ toast(d.error || 'Migration failed', 'error');
39403
+ }
39404
+ } catch(e) { toast(String(e), 'error'); }
39405
+ }
39406
+
39370
39407
  async function showWorkflowRuns(name) {
39371
39408
  var safeId = 'wf-runs-' + name.replace(/[^a-zA-Z0-9]/g, '_');
39372
39409
  var panel = document.getElementById(safeId);
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { Router } from 'express';
5
5
  import express from 'express';
6
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
6
+ import { existsSync, readFileSync, readdirSync, renameSync } from 'node:fs';
7
7
  import path from 'node:path';
8
8
  export function workflowsRouter(deps) {
9
9
  const router = Router();
@@ -68,6 +68,104 @@ export function workflowsRouter(deps) {
68
68
  res.status(500).json({ ok: false, error: String(e) });
69
69
  }
70
70
  });
71
+ /**
72
+ * 1.18.142 — Migrate a workflow into a vanilla Anthropic skill folder.
73
+ *
74
+ * Skills are subsuming workflows. To let users move at their own pace
75
+ * without breaking the workflow runtime, this endpoint converts ONE
76
+ * workflow at a time and renames the original .md → .md.migrated so
77
+ * the workflow runner stops picking it up but the file is still on
78
+ * disk for rollback.
79
+ *
80
+ * Conversion: workflow.name → skill name (slugified to Anthropic
81
+ * regex), workflow.description → skill description, raw workflow
82
+ * markdown body → skill procedure body (steps + synthesis prompt are
83
+ * preserved as documentation; runtime is the SDK reading the body).
84
+ * Frontmatter is rebuilt from scratch by writeSkill, so legacy
85
+ * workflow YAML doesn't leak into the new skill.
86
+ */
87
+ router.post('/:name/migrate-to-skill', express.json(), async (req, res) => {
88
+ try {
89
+ const name = decodeURIComponent(req.params.name);
90
+ const { parseAllWorkflows } = await import('../../agent/workflow-runner.js');
91
+ const { writeSkill } = await import('../../agent/skill-store.js');
92
+ const matter = (await import('gray-matter')).default;
93
+ // Find the workflow across global + agent scopes
94
+ const candidates = [];
95
+ if (existsSync(workflowsDir)) {
96
+ for (const wf of parseAllWorkflows(workflowsDir)) {
97
+ if (wf.name === name)
98
+ candidates.push({ wf });
99
+ }
100
+ }
101
+ if (existsSync(agentsBase)) {
102
+ for (const slug of readdirSync(agentsBase).filter(d => !d.startsWith('_'))) {
103
+ const wfDir = path.join(agentsBase, slug, 'workflows');
104
+ if (!existsSync(wfDir))
105
+ continue;
106
+ for (const wf of parseAllWorkflows(wfDir)) {
107
+ if (wf.name === name)
108
+ candidates.push({ wf, agentSlug: slug });
109
+ }
110
+ }
111
+ }
112
+ if (candidates.length === 0) {
113
+ res.status(404).json({ ok: false, error: 'Workflow not found: ' + name });
114
+ return;
115
+ }
116
+ const { wf, agentSlug } = candidates[0];
117
+ // Slugify the workflow name to the Anthropic regex
118
+ const slug = name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
119
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)) {
120
+ res.status(400).json({ ok: false, error: `Workflow name "${name}" cannot be slugified to Anthropic regex` });
121
+ return;
122
+ }
123
+ // Build the skill body from the workflow's raw markdown body. The
124
+ // workflow runner's frontmatter is dropped — writeSkill rebuilds
125
+ // a clementine.* block from scratch. Steps and synthesis live as
126
+ // markdown for the SDK to read directly.
127
+ const raw = readFileSync(wf.sourceFile, 'utf-8');
128
+ const parsed = matter(raw);
129
+ const body = parsed.content.trim() || `Migrated from workflow "${name}". Add a procedure here.`;
130
+ const description = wf.description || `Migrated from workflow ${name}`;
131
+ let written;
132
+ try {
133
+ written = writeSkill({
134
+ name: slug,
135
+ title: wf.name,
136
+ description,
137
+ body,
138
+ source: 'imported',
139
+ agentSlug,
140
+ });
141
+ }
142
+ catch (err) {
143
+ res.status(409).json({ ok: false, error: String(err instanceof Error ? err.message : err) });
144
+ return;
145
+ }
146
+ // Rename the original .md → .md.migrated so parseAllWorkflows
147
+ // stops picking it up. File stays on disk for rollback.
148
+ const migratedPath = wf.sourceFile + '.migrated';
149
+ try {
150
+ renameSync(wf.sourceFile, migratedPath);
151
+ }
152
+ catch (err) {
153
+ // Skill is already written; surface the rename failure but don't
154
+ // roll back — the user can manually delete the .md if needed.
155
+ res.json({
156
+ ok: true,
157
+ skill: written,
158
+ warning: `Skill created but original workflow file rename failed: ${String(err)}`,
159
+ });
160
+ return;
161
+ }
162
+ broadcastEvent({ type: 'workflow_migrated', data: { workflowName: name, skillSlug: slug, agentSlug } });
163
+ res.json({ ok: true, skill: written, originalRenamedTo: migratedPath });
164
+ }
165
+ catch (e) {
166
+ res.status(500).json({ ok: false, error: String(e) });
167
+ }
168
+ });
71
169
  router.get('/:name/runs', (_req, res) => {
72
170
  try {
73
171
  const name = decodeURIComponent(_req.params.name);
@@ -3,6 +3,18 @@
3
3
  * the user's team. Runs autonomously alongside Clementine's own
4
4
  * HeartbeatScheduler.
5
5
  *
6
+ * Why this is its own scheduler (NOT a unified base class):
7
+ * - It has NO loop. Unlike HeartbeatScheduler (setInterval) and
8
+ * CronScheduler (node-cron), this one is ticked externally by
9
+ * whatever orchestrator manages the agent fleet. Its `tick()` is a
10
+ * pure function of state + signals.
11
+ * - Its scheduling decision is *adaptive* (computeNextInterval) based
12
+ * on the previous tick's outcome — not a fixed cadence. Forcing it
13
+ * into a generic "interval-based" base would lose this behavior.
14
+ *
15
+ * Shared with the other schedulers: only the JSON state-file load/save
16
+ * pattern, factored into ./scheduler-state.ts in 1.18.143.
17
+ *
6
18
  * Phase 2 — cheap path only. No LLM call. The tick loads state, scans
7
19
  * three signals (pending delegated tasks, recent goal updates, recent
8
20
  * cron completions), updates fingerprint, and persists state.
@@ -3,6 +3,18 @@
3
3
  * the user's team. Runs autonomously alongside Clementine's own
4
4
  * HeartbeatScheduler.
5
5
  *
6
+ * Why this is its own scheduler (NOT a unified base class):
7
+ * - It has NO loop. Unlike HeartbeatScheduler (setInterval) and
8
+ * CronScheduler (node-cron), this one is ticked externally by
9
+ * whatever orchestrator manages the agent fleet. Its `tick()` is a
10
+ * pure function of state + signals.
11
+ * - Its scheduling decision is *adaptive* (computeNextInterval) based
12
+ * on the previous tick's outcome — not a fixed cadence. Forcing it
13
+ * into a generic "interval-based" base would lose this behavior.
14
+ *
15
+ * Shared with the other schedulers: only the JSON state-file load/save
16
+ * pattern, factored into ./scheduler-state.ts in 1.18.143.
17
+ *
6
18
  * Phase 2 — cheap path only. No LLM call. The tick loads state, scans
7
19
  * three signals (pending delegated tasks, recent goal updates, recent
8
20
  * cron completions), updates fingerprint, and persists state.
@@ -11,11 +23,12 @@
11
23
  * agent's profile) when the fingerprint indicates a real signal change.
12
24
  */
13
25
  import { createHash } from 'node:crypto';
14
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from 'node:fs';
26
+ import { existsSync, readFileSync, readdirSync, statSync, } from 'node:fs';
15
27
  import path from 'node:path';
16
28
  import pino from 'pino';
17
29
  import { AGENTS_DIR, BASE_DIR } from '../config.js';
18
30
  import { listAllGoals } from '../tools/shared.js';
31
+ import { loadStateFile, saveStateFile } from './scheduler-state.js';
19
32
  const logger = pino({ name: 'clementine.agent-heartbeat' });
20
33
  const DEFAULT_INTERVAL_MIN = 30;
21
34
  const MIN_INTERVAL_MIN = 5;
@@ -81,43 +94,32 @@ export class AgentHeartbeatScheduler {
81
94
  }
82
95
  /** Read persisted state, or return a fresh state ready to tick now. */
83
96
  loadState() {
84
- try {
85
- if (existsSync(this.stateFile)) {
86
- const raw = JSON.parse(readFileSync(this.stateFile, 'utf-8'));
87
- const validKinds = ['acted', 'quiet', 'silent', 'override'];
88
- const kind = validKinds.includes(raw.lastTickKind)
89
- ? raw.lastTickKind
90
- : undefined;
91
- return {
92
- slug: this.slug,
93
- lastTickAt: String(raw.lastTickAt ?? ''),
94
- nextCheckAt: String(raw.nextCheckAt ?? new Date().toISOString()),
95
- silentTickCount: Number(raw.silentTickCount ?? 0),
96
- fingerprint: String(raw.fingerprint ?? ''),
97
- ...(raw.lastSignalSummary ? { lastSignalSummary: raw.lastSignalSummary } : {}),
98
- ...(kind ? { lastTickKind: kind } : {}),
99
- };
100
- }
101
- }
102
- catch (err) {
103
- logger.warn({ err, slug: this.slug }, 'Failed to load agent heartbeat state — starting fresh');
104
- }
105
- return {
97
+ const fresh = {
106
98
  slug: this.slug,
107
99
  lastTickAt: '',
108
100
  nextCheckAt: new Date().toISOString(),
109
101
  silentTickCount: 0,
110
102
  fingerprint: '',
111
103
  };
104
+ return loadStateFile(this.stateFile, fresh, (raw) => {
105
+ const r = raw;
106
+ const validKinds = ['acted', 'quiet', 'silent', 'override'];
107
+ const kind = validKinds.includes(r.lastTickKind)
108
+ ? r.lastTickKind
109
+ : undefined;
110
+ return {
111
+ slug: this.slug,
112
+ lastTickAt: String(r.lastTickAt ?? ''),
113
+ nextCheckAt: String(r.nextCheckAt ?? new Date().toISOString()),
114
+ silentTickCount: Number(r.silentTickCount ?? 0),
115
+ fingerprint: String(r.fingerprint ?? ''),
116
+ ...(r.lastSignalSummary ? { lastSignalSummary: r.lastSignalSummary } : {}),
117
+ ...(kind ? { lastTickKind: kind } : {}),
118
+ };
119
+ });
112
120
  }
113
121
  saveState(state) {
114
- try {
115
- mkdirSync(path.dirname(this.stateFile), { recursive: true });
116
- writeFileSync(this.stateFile, JSON.stringify(state, null, 2));
117
- }
118
- catch (err) {
119
- logger.warn({ err, slug: this.slug }, 'Failed to save agent heartbeat state — non-fatal');
120
- }
122
+ saveStateFile(this.stateFile, state);
121
123
  }
122
124
  /** True if the agent is due for a tick. */
123
125
  isDue(now = new Date()) {
@@ -1,10 +1,28 @@
1
1
  /**
2
2
  * Clementine TypeScript — Cron scheduler (autonomous execution).
3
3
  *
4
- * CronScheduler: precise scheduled tasks using node-cron
4
+ * CronScheduler: precise per-job scheduled tasks using node-cron. Each
5
+ * cron job (defined in CRON.md or via the schedule registry) gets its
6
+ * own ScheduledTask instance with that job's cron expression, so the
7
+ * scheduling primitive here is fundamentally different from the
8
+ * fixed-interval HeartbeatScheduler and the externally-ticked
9
+ * AgentHeartbeatScheduler.
5
10
  *
6
- * Also contains shared parsers (parseCronJobs, parseAgentCronJobs, validateCronYaml),
7
- * retry helpers, CronRunLog, and daily-note logging utilities used by both schedulers.
11
+ * Why this is its own scheduler (NOT a unified base class):
12
+ * - One node-cron task per job. CronScheduler maintains the
13
+ * scheduledTasks/workflowTasks maps; a generic base class would
14
+ * have to model "N independent timers" which the other two don't
15
+ * need.
16
+ * - Owns crash-safe idempotency (cron-running.json), workflow
17
+ * execution, file watching, trigger directories, broken-job
18
+ * tracking. None of these belong in a generic base.
19
+ *
20
+ * Also contains shared parsers (parseCronJobs, parseAgentCronJobs,
21
+ * validateCronYaml), retry helpers, CronRunLog, and daily-note logging
22
+ * utilities used by both schedulers.
23
+ *
24
+ * Shared with the other schedulers: only the JSON state-file load/save
25
+ * pattern, factored into ./scheduler-state.ts in 1.18.143.
8
26
  */
9
27
  import type { CronJobDefinition, CronRunEntry, SelfImproveConfig, SelfImproveExperiment, SelfImproveState, WorkflowDefinition } from '../types.js';
10
28
  import type { NotificationDispatcher } from './notifications.js';
@@ -1,13 +1,31 @@
1
1
  /**
2
2
  * Clementine TypeScript — Cron scheduler (autonomous execution).
3
3
  *
4
- * CronScheduler: precise scheduled tasks using node-cron
4
+ * CronScheduler: precise per-job scheduled tasks using node-cron. Each
5
+ * cron job (defined in CRON.md or via the schedule registry) gets its
6
+ * own ScheduledTask instance with that job's cron expression, so the
7
+ * scheduling primitive here is fundamentally different from the
8
+ * fixed-interval HeartbeatScheduler and the externally-ticked
9
+ * AgentHeartbeatScheduler.
5
10
  *
6
- * Also contains shared parsers (parseCronJobs, parseAgentCronJobs, validateCronYaml),
7
- * retry helpers, CronRunLog, and daily-note logging utilities used by both schedulers.
11
+ * Why this is its own scheduler (NOT a unified base class):
12
+ * - One node-cron task per job. CronScheduler maintains the
13
+ * scheduledTasks/workflowTasks maps; a generic base class would
14
+ * have to model "N independent timers" which the other two don't
15
+ * need.
16
+ * - Owns crash-safe idempotency (cron-running.json), workflow
17
+ * execution, file watching, trigger directories, broken-job
18
+ * tracking. None of these belong in a generic base.
19
+ *
20
+ * Also contains shared parsers (parseCronJobs, parseAgentCronJobs,
21
+ * validateCronYaml), retry helpers, CronRunLog, and daily-note logging
22
+ * utilities used by both schedulers.
23
+ *
24
+ * Shared with the other schedulers: only the JSON state-file load/save
25
+ * pattern, factored into ./scheduler-state.ts in 1.18.143.
8
26
  */
9
27
  import { execSync } from 'node:child_process';
10
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, watchFile, unwatchFile, writeFileSync, } from 'node:fs';
28
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, watchFile, unwatchFile, writeFileSync, } from 'node:fs';
11
29
  import path from 'node:path';
12
30
  import cron from 'node-cron';
13
31
  import matter from 'gray-matter';
@@ -15,6 +33,7 @@ import pino from 'pino';
15
33
  import { CRON_FILE, WORKFLOWS_DIR, AGENTS_DIR, DAILY_NOTES_DIR, BASE_DIR, DISCORD_OWNER_ID, GOALS_DIR, CRON_REFLECTIONS_DIR, ADVISOR_LOG_PATH, TIMEZONE, } from '../config.js';
16
34
  import { listAllGoals, findGoalPath, readGoalById } from '../tools/shared.js';
17
35
  import { listSchedules } from '../agent/schedule-registry.js';
36
+ import { saveStateFile } from './scheduler-state.js';
18
37
  import { scanner } from '../security/scanner.js';
19
38
  import { parseAllWorkflows as parseAllWorkflowsSync } from '../agent/workflow-runner.js';
20
39
  import { SelfImproveLoop } from '../agent/self-improve.js';
@@ -579,20 +598,13 @@ export class CronScheduler {
579
598
  * rename so a crash mid-write cannot corrupt the file.
580
599
  */
581
600
  persistRunningJobs(metaByName) {
582
- try {
583
- const entries = [...this.runningJobs].map(name => ({
584
- jobName: name,
585
- startedAt: metaByName?.get(name)?.startedAt ?? new Date().toISOString(),
586
- runId: metaByName?.get(name)?.runId ?? '',
587
- pid: process.pid,
588
- }));
589
- const tmp = CronScheduler.RUNNING_JOBS_FILE + '.tmp';
590
- writeFileSync(tmp, JSON.stringify(entries, null, 2));
591
- renameSync(tmp, CronScheduler.RUNNING_JOBS_FILE);
592
- }
593
- catch (err) {
594
- logger.debug({ err }, 'Failed to persist running-jobs file');
595
- }
601
+ const entries = [...this.runningJobs].map(name => ({
602
+ jobName: name,
603
+ startedAt: metaByName?.get(name)?.startedAt ?? new Date().toISOString(),
604
+ runId: metaByName?.get(name)?.runId ?? '',
605
+ pid: process.pid,
606
+ }));
607
+ saveStateFile(CronScheduler.RUNNING_JOBS_FILE, entries, { atomic: true });
596
608
  }
597
609
  /**
598
610
  * On startup, read the persisted running-jobs file. Any entries present
@@ -1,8 +1,21 @@
1
1
  /**
2
2
  * Clementine TypeScript — Heartbeat scheduler.
3
3
  *
4
- * HeartbeatScheduler: periodic general check-ins using setInterval.
5
- * Channel-agnostic sends notifications via the NotificationDispatcher.
4
+ * HeartbeatScheduler: periodic general check-ins using setInterval
5
+ * (one tick every HEARTBEAT_INTERVAL_MINUTES, default 30 min, gated to
6
+ * the HEARTBEAT_ACTIVE_START..ACTIVE_END window). Channel-agnostic —
7
+ * sends notifications via the NotificationDispatcher.
8
+ *
9
+ * Why this is its own scheduler (NOT a unified base class):
10
+ * - It uses a fixed setInterval. Different scheduling primitive than
11
+ * CronScheduler (which uses node-cron per-job) and AgentHeartbeat
12
+ * Scheduler (which is ticked externally — has no loop of its own).
13
+ * - It owns autonomous concerns: insight engine, proactive ledger,
14
+ * dense-vector backfill, salience decay, episodic consolidation.
15
+ * None of these belong in a generic scheduler base.
16
+ *
17
+ * Shared with the other two schedulers: only the JSON state-file
18
+ * load/save pattern, factored into ./scheduler-state.ts in 1.18.143.
6
19
  */
7
20
  import type { HeartbeatWorkItem } from '../types.js';
8
21
  import type { CronScheduler } from './cron-scheduler.js';
@@ -1,8 +1,21 @@
1
1
  /**
2
2
  * Clementine TypeScript — Heartbeat scheduler.
3
3
  *
4
- * HeartbeatScheduler: periodic general check-ins using setInterval.
5
- * Channel-agnostic sends notifications via the NotificationDispatcher.
4
+ * HeartbeatScheduler: periodic general check-ins using setInterval
5
+ * (one tick every HEARTBEAT_INTERVAL_MINUTES, default 30 min, gated to
6
+ * the HEARTBEAT_ACTIVE_START..ACTIVE_END window). Channel-agnostic —
7
+ * sends notifications via the NotificationDispatcher.
8
+ *
9
+ * Why this is its own scheduler (NOT a unified base class):
10
+ * - It uses a fixed setInterval. Different scheduling primitive than
11
+ * CronScheduler (which uses node-cron per-job) and AgentHeartbeat
12
+ * Scheduler (which is ticked externally — has no loop of its own).
13
+ * - It owns autonomous concerns: insight engine, proactive ledger,
14
+ * dense-vector backfill, salience decay, episodic consolidation.
15
+ * None of these belong in a generic scheduler base.
16
+ *
17
+ * Shared with the other two schedulers: only the JSON state-file
18
+ * load/save pattern, factored into ./scheduler-state.ts in 1.18.143.
6
19
  */
7
20
  import { createHash, randomBytes } from 'node:crypto';
8
21
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
@@ -11,6 +24,7 @@ import matter from 'gray-matter';
11
24
  import pino from 'pino';
12
25
  import { HEARTBEAT_FILE, TASKS_FILE, INBOX_DIR, DAILY_NOTES_DIR, HEARTBEAT_INTERVAL_MINUTES, HEARTBEAT_ACTIVE_START, HEARTBEAT_ACTIVE_END, BASE_DIR, GOALS_DIR, AGENTS_DIR, HEARTBEAT_WORK_QUEUE_FILE, DISCORD_OWNER_ID, } from '../config.js';
13
26
  import { findGoalPath, listAllGoals } from '../tools/shared.js';
27
+ import { loadStateFile, saveStateFile } from './scheduler-state.js';
14
28
  import { gatherInsightSignals, buildInsightPrompt, parseInsightResponse, canSendInsight, recordInsightSent, recordInsightAcked, maybeIncreaseCooldown, } from '../agent/insight-engine.js';
15
29
  import { decideDailyPlanPriority, decideDiscoveredWorkItem, decideGoalAdvancement, decisionShouldCreateGoalTrigger, decisionShouldQueueHeartbeatWork, } from '../agent/proactive-engine.js';
16
30
  import { recentDecisions, recordDecision, recordDecisionOutcome, wasRecentlyDecided, } from '../agent/proactive-ledger.js';
@@ -1144,23 +1158,10 @@ export class HeartbeatScheduler {
1144
1158
  return parsed.content;
1145
1159
  }
1146
1160
  loadState() {
1147
- if (existsSync(this.stateFile)) {
1148
- try {
1149
- return JSON.parse(readFileSync(this.stateFile, 'utf-8'));
1150
- }
1151
- catch {
1152
- logger.warn('Failed to load heartbeat state — starting fresh');
1153
- }
1154
- }
1155
- return { fingerprint: '', details: {}, timestamp: '' };
1161
+ return loadStateFile(this.stateFile, { fingerprint: '', details: {}, timestamp: '' });
1156
1162
  }
1157
1163
  saveState() {
1158
- try {
1159
- writeFileSync(this.stateFile, JSON.stringify(this.lastState, null, 2));
1160
- }
1161
- catch (err) {
1162
- logger.warn({ err }, 'Failed to save heartbeat state');
1163
- }
1164
+ saveStateFile(this.stateFile, this.lastState);
1164
1165
  }
1165
1166
  computeStateFingerprint() {
1166
1167
  const details = {};
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Scheduler state-file helpers (1.18.143)
3
+ *
4
+ * Three schedulers each persist a JSON state file (heartbeat,
5
+ * cron-running-jobs, per-agent heartbeat). Before this module they
6
+ * each reimplemented "try parse, fall back on error" + "write JSON,
7
+ * log on error". The patterns are tiny but they were drifting (some
8
+ * had `mkdirSync`, some had atomic write-then-rename, some had
9
+ * neither — and a future addition would have copied yet another
10
+ * variant).
11
+ *
12
+ * This module is the single source of truth for both shapes:
13
+ *
14
+ * loadStateFile(path, default, validator?)
15
+ * — read JSON, fall back to default on missing/invalid file,
16
+ * optionally run a validator that can clean/coerce the parsed
17
+ * payload before returning it.
18
+ *
19
+ * saveStateFile(path, state, opts)
20
+ * — ensure parent dir exists, then either plain
21
+ * writeFileSync (default) or atomic write-then-rename for
22
+ * crash-safe persistence (set `atomic: true`).
23
+ *
24
+ * Both swallow filesystem errors and log a warning — the schedulers
25
+ * treat persistence as best-effort (a missing/corrupt state file
26
+ * means "start fresh", not "crash"). Callers that need failure
27
+ * surfaced should check the boolean return on saveStateFile.
28
+ */
29
+ /**
30
+ * Read a JSON state file. Returns `defaultValue` if the file is
31
+ * missing, unreadable, or fails JSON parse. Optional validator runs
32
+ * after parse and can return a cleaned-up version (e.g. coerce
33
+ * missing fields, drop invalid values).
34
+ */
35
+ export declare function loadStateFile<T>(filePath: string, defaultValue: T, validator?: (raw: unknown) => T): T;
36
+ export interface SaveStateOptions {
37
+ /**
38
+ * If true, write to `<file>.tmp` then rename — guarantees the on-disk
39
+ * file is either the previous state or the new state, never a half-
40
+ * written file. Use for state that must survive crashes mid-write
41
+ * (cron-running.json relies on this for idempotency).
42
+ */
43
+ atomic?: boolean;
44
+ }
45
+ /**
46
+ * Write a JSON state file. Creates the parent directory if missing.
47
+ * Returns true on success, false on failure (always logs a warning
48
+ * on failure — caller doesn't need to log again).
49
+ */
50
+ export declare function saveStateFile<T>(filePath: string, state: T, opts?: SaveStateOptions): boolean;
51
+ //# sourceMappingURL=scheduler-state.d.ts.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Scheduler state-file helpers (1.18.143)
3
+ *
4
+ * Three schedulers each persist a JSON state file (heartbeat,
5
+ * cron-running-jobs, per-agent heartbeat). Before this module they
6
+ * each reimplemented "try parse, fall back on error" + "write JSON,
7
+ * log on error". The patterns are tiny but they were drifting (some
8
+ * had `mkdirSync`, some had atomic write-then-rename, some had
9
+ * neither — and a future addition would have copied yet another
10
+ * variant).
11
+ *
12
+ * This module is the single source of truth for both shapes:
13
+ *
14
+ * loadStateFile(path, default, validator?)
15
+ * — read JSON, fall back to default on missing/invalid file,
16
+ * optionally run a validator that can clean/coerce the parsed
17
+ * payload before returning it.
18
+ *
19
+ * saveStateFile(path, state, opts)
20
+ * — ensure parent dir exists, then either plain
21
+ * writeFileSync (default) or atomic write-then-rename for
22
+ * crash-safe persistence (set `atomic: true`).
23
+ *
24
+ * Both swallow filesystem errors and log a warning — the schedulers
25
+ * treat persistence as best-effort (a missing/corrupt state file
26
+ * means "start fresh", not "crash"). Callers that need failure
27
+ * surfaced should check the boolean return on saveStateFile.
28
+ */
29
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
30
+ import path from 'node:path';
31
+ import pino from 'pino';
32
+ const logger = pino({ name: 'clementine.scheduler-state' });
33
+ /**
34
+ * Read a JSON state file. Returns `defaultValue` if the file is
35
+ * missing, unreadable, or fails JSON parse. Optional validator runs
36
+ * after parse and can return a cleaned-up version (e.g. coerce
37
+ * missing fields, drop invalid values).
38
+ */
39
+ export function loadStateFile(filePath, defaultValue, validator) {
40
+ try {
41
+ if (!existsSync(filePath))
42
+ return defaultValue;
43
+ const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
44
+ return validator ? validator(raw) : raw;
45
+ }
46
+ catch (err) {
47
+ logger.warn({ err, filePath }, 'Failed to load state file — starting fresh');
48
+ return defaultValue;
49
+ }
50
+ }
51
+ /**
52
+ * Write a JSON state file. Creates the parent directory if missing.
53
+ * Returns true on success, false on failure (always logs a warning
54
+ * on failure — caller doesn't need to log again).
55
+ */
56
+ export function saveStateFile(filePath, state, opts = {}) {
57
+ try {
58
+ mkdirSync(path.dirname(filePath), { recursive: true });
59
+ const json = JSON.stringify(state, null, 2);
60
+ if (opts.atomic) {
61
+ const tmp = filePath + '.tmp';
62
+ writeFileSync(tmp, json);
63
+ renameSync(tmp, filePath);
64
+ }
65
+ else {
66
+ writeFileSync(filePath, json);
67
+ }
68
+ return true;
69
+ }
70
+ catch (err) {
71
+ logger.warn({ err, filePath }, 'Failed to save state file');
72
+ return false;
73
+ }
74
+ }
75
+ //# sourceMappingURL=scheduler-state.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.141",
3
+ "version": "1.18.143",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",