clementine-agent 1.0.3 → 1.0.5

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.
@@ -8,54 +8,9 @@
8
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import pino from 'pino';
11
- import Anthropic from '@anthropic-ai/sdk';
12
11
  import { BASE_DIR, GOALS_DIR, CRON_REFLECTIONS_DIR, TASKS_FILE, INBOX_DIR, MODELS, } from '../config.js';
13
12
  const logger = pino({ name: 'clementine.daily-planner' });
14
13
  const PLANS_DIR = path.join(BASE_DIR, 'plans', 'daily');
15
- // ── .env reader (self-contained — no config.ts secret imports) ───────
16
- function getEnvValue(key) {
17
- // Check process env first (already loaded by the daemon)
18
- if (process.env[key])
19
- return process.env[key];
20
- // Fall back to .env file
21
- const envPath = path.join(BASE_DIR, '.env');
22
- if (!existsSync(envPath))
23
- return '';
24
- for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
25
- const trimmed = line.trim();
26
- if (!trimmed || trimmed.startsWith('#'))
27
- continue;
28
- const eqIndex = trimmed.indexOf('=');
29
- if (eqIndex === -1)
30
- continue;
31
- if (trimmed.slice(0, eqIndex) !== key)
32
- continue;
33
- let value = trimmed.slice(eqIndex + 1);
34
- if ((value.startsWith('"') && value.endsWith('"')) ||
35
- (value.startsWith("'") && value.endsWith("'"))) {
36
- value = value.slice(1, -1);
37
- }
38
- return value;
39
- }
40
- return '';
41
- }
42
- /**
43
- * Build Anthropic client credentials.
44
- * Priority: ANTHROPIC_AUTH_TOKEN (OAuth) > ANTHROPIC_API_KEY (legacy raw key).
45
- * Returns null if neither is configured.
46
- */
47
- function getAnthropicCredentials() {
48
- const oauthToken = getEnvValue('CLAUDE_CODE_OAUTH_TOKEN');
49
- if (oauthToken)
50
- return { authToken: oauthToken };
51
- const authToken = getEnvValue('ANTHROPIC_AUTH_TOKEN');
52
- if (authToken)
53
- return { authToken };
54
- const apiKey = getEnvValue('ANTHROPIC_API_KEY');
55
- if (apiKey)
56
- return { apiKey };
57
- return null;
58
- }
59
14
  // ── Helpers ──────────────────────────────────────────────────────────
60
15
  function todayStr() {
61
16
  const d = new Date();
@@ -303,24 +258,27 @@ Rules:
303
258
  - Limit to at most 10 priorities, 5 cron changes, 5 new work items
304
259
  - Focus on actionable items, not status reports
305
260
  - If everything is on track, return minimal priorities`;
306
- const creds = getAnthropicCredentials();
307
- if (!creds) {
308
- logger.warn('No Anthropic credentials found — generating fallback plan. Run `clementine login` to authenticate.');
309
- return this.fallbackPlan(today);
310
- }
311
261
  try {
312
- const client = new Anthropic(creds.authToken ? { authToken: creds.authToken } : { apiKey: creds.apiKey });
313
- const response = await client.messages.create({
314
- model: MODELS.haiku,
315
- max_tokens: 2000,
316
- messages: [{ role: 'user', content: prompt }],
317
- system: 'You are a planning assistant. Analyze the context and produce a prioritized daily plan as JSON. Return only valid JSON, no markdown fencing.',
262
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
263
+ let text = '';
264
+ const stream = query({
265
+ prompt,
266
+ options: {
267
+ model: MODELS.haiku,
268
+ maxTurns: 1,
269
+ systemPrompt: 'You are a planning assistant. Analyze the context and produce a prioritized daily plan as JSON. Return only valid JSON, no markdown fencing.',
270
+ },
318
271
  });
319
- const text = response.content
320
- .filter(block => block.type === 'text')
321
- .map(block => block.text)
322
- .join('');
323
- const plan = JSON.parse(text);
272
+ for await (const msg of stream) {
273
+ if (msg.type === 'result')
274
+ text = msg.result ?? '';
275
+ }
276
+ if (!text) {
277
+ logger.warn('LLM returned empty plan — using fallback');
278
+ return this.fallbackPlan(today);
279
+ }
280
+ const cleaned = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim();
281
+ const plan = JSON.parse(cleaned);
324
282
  plan.date = today;
325
283
  plan.createdAt = new Date().toISOString();
326
284
  plan.priorities = plan.priorities ?? [];
@@ -12,54 +12,27 @@
12
12
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import pino from 'pino';
15
- import Anthropic from '@anthropic-ai/sdk';
16
15
  import { BASE_DIR, GOALS_DIR, MODELS } from '../config.js';
17
16
  const logger = pino({ name: 'clementine.strategic-planner' });
18
17
  const DAILY_PLANS_DIR = path.join(BASE_DIR, 'plans', 'daily');
19
18
  const WEEKLY_PLANS_DIR = path.join(BASE_DIR, 'plans', 'weekly');
20
19
  const MONTHLY_PLANS_DIR = path.join(BASE_DIR, 'plans', 'monthly');
21
- // ── .env reader ──────────────────────────────────────────────────────
22
- function getEnvValue(key) {
23
- if (process.env[key])
24
- return process.env[key];
25
- const envPath = path.join(BASE_DIR, '.env');
26
- if (!existsSync(envPath))
27
- return '';
28
- for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
29
- const trimmed = line.trim();
30
- if (!trimmed || trimmed.startsWith('#'))
31
- continue;
32
- const eqIndex = trimmed.indexOf('=');
33
- if (eqIndex === -1)
34
- continue;
35
- if (trimmed.slice(0, eqIndex) !== key)
36
- continue;
37
- let value = trimmed.slice(eqIndex + 1);
38
- if ((value.startsWith('"') && value.endsWith('"')) ||
39
- (value.startsWith("'") && value.endsWith("'"))) {
40
- value = value.slice(1, -1);
41
- }
42
- return value;
20
+ async function llmJsonCall(prompt, systemPrompt) {
21
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
22
+ let text = '';
23
+ const stream = query({
24
+ prompt,
25
+ options: {
26
+ model: MODELS.haiku,
27
+ maxTurns: 1,
28
+ systemPrompt,
29
+ },
30
+ });
31
+ for await (const msg of stream) {
32
+ if (msg.type === 'result')
33
+ text = msg.result ?? '';
43
34
  }
44
- return '';
45
- }
46
- function getAnthropicCredentials() {
47
- const oauthToken = getEnvValue('CLAUDE_CODE_OAUTH_TOKEN');
48
- if (oauthToken)
49
- return { authToken: oauthToken };
50
- const authToken = getEnvValue('ANTHROPIC_AUTH_TOKEN');
51
- if (authToken)
52
- return { authToken };
53
- const apiKey = getEnvValue('ANTHROPIC_API_KEY');
54
- if (apiKey)
55
- return { apiKey };
56
- return null;
57
- }
58
- function makeAnthropicClient() {
59
- const creds = getAnthropicCredentials();
60
- if (!creds)
61
- return null;
62
- return new Anthropic(creds.authToken ? { authToken: creds.authToken } : { apiKey: creds.apiKey });
35
+ return text;
63
36
  }
64
37
  // ── Strategic Planner ────────────────────────────────────────────────
65
38
  export class StrategicPlanner {
@@ -153,16 +126,8 @@ export class StrategicPlanner {
153
126
  })),
154
127
  summary: 'No data available for weekly review.',
155
128
  };
156
- const client = makeAnthropicClient();
157
- if (!client)
158
- return defaultReview;
159
129
  try {
160
- const response = await client.messages.create({
161
- model: MODELS.haiku,
162
- max_tokens: 1000,
163
- messages: [{ role: 'user', content: prompt }],
164
- });
165
- const text = response.content[0]?.type === 'text' ? response.content[0].text : '';
130
+ const text = await llmJsonCall(prompt, 'You synthesize weekly reviews. Return only valid JSON, no markdown fencing.');
166
131
  const jsonMatch = text.match(/\{[\s\S]*\}/);
167
132
  if (jsonMatch) {
168
133
  const parsed = JSON.parse(jsonMatch[0]);
@@ -235,9 +200,6 @@ export class StrategicPlanner {
235
200
  proposedGoals: [],
236
201
  summary: 'No data available for monthly assessment.',
237
202
  };
238
- const client2 = makeAnthropicClient();
239
- if (!client2)
240
- return defaultAssessment;
241
203
  const prompt = `You are generating a monthly strategic assessment for ${monthId}.\n\n` +
242
204
  `${context}\n\n` +
243
205
  `Output ONLY a JSON object:\n` +
@@ -248,12 +210,7 @@ export class StrategicPlanner {
248
210
  ` "summary": "2-3 sentence strategic assessment"\n` +
249
211
  `}`;
250
212
  try {
251
- const response = await client2.messages.create({
252
- model: MODELS.haiku,
253
- max_tokens: 1000,
254
- messages: [{ role: 'user', content: prompt }],
255
- });
256
- const text = response.content[0]?.type === 'text' ? response.content[0].text : '';
213
+ const text = await llmJsonCall(prompt, 'You produce monthly strategic assessments. Return only valid JSON, no markdown fencing.');
257
214
  const jsonMatch = text.match(/\{[\s\S]*\}/);
258
215
  if (jsonMatch) {
259
216
  const parsed = JSON.parse(jsonMatch[0]);
package/dist/cli/index.js CHANGED
@@ -57,6 +57,13 @@ function getLaunchdPlistPath() {
57
57
  const home = process.env.HOME ?? '';
58
58
  return path.join(home, 'Library', 'LaunchAgents', `${getLaunchdLabel()}.plist`);
59
59
  }
60
+ function getSystemdServiceName() {
61
+ return `${getAssistantName().toLowerCase()}.service`;
62
+ }
63
+ function getSystemdServicePath() {
64
+ const home = process.env.HOME ?? '';
65
+ return path.join(home, '.config', 'systemd', 'user', getSystemdServiceName());
66
+ }
60
67
  function readPid() {
61
68
  const pidFile = getPidFilePath();
62
69
  if (!existsSync(pidFile))
@@ -104,10 +111,10 @@ function killPid(pid) {
104
111
  // already dead
105
112
  }
106
113
  }
107
- /** Stop the daemon safely: unload LaunchAgent first (prevents respawn), then kill the process. */
114
+ /** Stop the daemon safely: disable the service manager first (prevents respawn), then kill the process. */
108
115
  function stopDaemon(pid) {
109
- // Unload LaunchAgent BEFORE killing — otherwise launchd respawns it immediately
110
116
  if (process.platform === 'darwin') {
117
+ // Unload LaunchAgent BEFORE killing — otherwise launchd respawns it immediately
111
118
  const plist = getLaunchdPlistPath();
112
119
  if (existsSync(plist)) {
113
120
  try {
@@ -118,6 +125,18 @@ function stopDaemon(pid) {
118
125
  }
119
126
  }
120
127
  }
128
+ else if (process.platform === 'linux') {
129
+ // Stop systemd service BEFORE killing — otherwise systemd respawns it
130
+ const servicePath = getSystemdServicePath();
131
+ if (existsSync(servicePath)) {
132
+ try {
133
+ execSync(`systemctl --user stop ${getSystemdServiceName()}`, { stdio: 'pipe' });
134
+ }
135
+ catch {
136
+ // not active — that's fine
137
+ }
138
+ }
139
+ }
121
140
  killPid(pid);
122
141
  }
123
142
  /** Bootstrap ~/.clementine/ on first run — create data dir and copy vault templates. */
@@ -136,43 +155,68 @@ function ensureDataHome() {
136
155
  // ── Commands ─────────────────────────────────────────────────────────
137
156
  function cmdLaunch(options) {
138
157
  if (options.uninstall) {
139
- const plistPath = getLaunchdPlistPath();
140
- if (existsSync(plistPath)) {
141
- try {
142
- execSync(`launchctl unload "${plistPath}"`, { stdio: 'ignore' });
158
+ if (process.platform === 'darwin') {
159
+ const plistPath = getLaunchdPlistPath();
160
+ if (existsSync(plistPath)) {
161
+ try {
162
+ execSync(`launchctl unload "${plistPath}"`, { stdio: 'ignore' });
163
+ }
164
+ catch {
165
+ // not loaded
166
+ }
167
+ unlinkSync(plistPath);
168
+ console.log(` Uninstalled LaunchAgent: ${getLaunchdLabel()}`);
143
169
  }
144
- catch {
145
- // not loaded
170
+ else {
171
+ console.log(' LaunchAgent not installed.');
146
172
  }
147
- unlinkSync(plistPath);
148
- console.log(` Uninstalled LaunchAgent: ${getLaunchdLabel()}`);
149
173
  }
150
- else {
151
- console.log(' LaunchAgent not installed.');
174
+ else if (process.platform === 'linux') {
175
+ const servicePath = getSystemdServicePath();
176
+ const serviceName = getSystemdServiceName();
177
+ if (existsSync(servicePath)) {
178
+ try {
179
+ execSync(`systemctl --user stop ${serviceName}`, { stdio: 'ignore' });
180
+ execSync(`systemctl --user disable ${serviceName}`, { stdio: 'ignore' });
181
+ }
182
+ catch {
183
+ // not active
184
+ }
185
+ unlinkSync(servicePath);
186
+ try {
187
+ execSync('systemctl --user daemon-reload', { stdio: 'ignore' });
188
+ }
189
+ catch { /* ignore */ }
190
+ console.log(` Uninstalled systemd service: ${serviceName}`);
191
+ }
192
+ else {
193
+ console.log(' Systemd service not installed.');
194
+ }
152
195
  }
153
196
  return;
154
197
  }
155
198
  if (options.install) {
156
- const plistPath = getLaunchdPlistPath();
157
- const plistDir = path.dirname(plistPath);
158
- if (!existsSync(plistDir)) {
159
- mkdirSync(plistDir, { recursive: true });
160
- }
161
- // Unload existing plist if already installed (idempotent reinstall)
162
- if (existsSync(plistPath)) {
163
- try {
164
- execSync(`launchctl unload "${plistPath}"`, { stdio: 'ignore' });
199
+ if (process.platform === 'darwin') {
200
+ const plistPath = getLaunchdPlistPath();
201
+ const plistDir = path.dirname(plistPath);
202
+ if (!existsSync(plistDir)) {
203
+ mkdirSync(plistDir, { recursive: true });
165
204
  }
166
- catch {
167
- // not loaded — fine
205
+ // Unload existing plist if already installed (idempotent reinstall)
206
+ if (existsSync(plistPath)) {
207
+ try {
208
+ execSync(`launchctl unload "${plistPath}"`, { stdio: 'ignore' });
209
+ }
210
+ catch {
211
+ // not loaded — fine
212
+ }
168
213
  }
169
- }
170
- const nodePath = process.execPath;
171
- const logDir = path.join(BASE_DIR, 'logs');
172
- if (!existsSync(logDir)) {
173
- mkdirSync(logDir, { recursive: true });
174
- }
175
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
214
+ const nodePath = process.execPath;
215
+ const logDir = path.join(BASE_DIR, 'logs');
216
+ if (!existsSync(logDir)) {
217
+ mkdirSync(logDir, { recursive: true });
218
+ }
219
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
176
220
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
177
221
  <plist version="1.0">
178
222
  <dict>
@@ -204,15 +248,80 @@ function cmdLaunch(options) {
204
248
  </dict>
205
249
  </dict>
206
250
  </plist>`;
207
- writeFileSync(plistPath, plist);
208
- try {
209
- execSync(`launchctl load "${plistPath}"`);
210
- console.log(` Installed and loaded LaunchAgent: ${getLaunchdLabel()}`);
211
- console.log(` Plist: ${plistPath}`);
212
- console.log(` Logs: ${logDir}/`);
251
+ writeFileSync(plistPath, plist);
252
+ try {
253
+ execSync(`launchctl load "${plistPath}"`);
254
+ console.log(` Installed and loaded LaunchAgent: ${getLaunchdLabel()}`);
255
+ console.log(` Plist: ${plistPath}`);
256
+ console.log(` Logs: ${logDir}/`);
257
+ }
258
+ catch (err) {
259
+ console.error(` Failed to load LaunchAgent: ${err}`);
260
+ }
213
261
  }
214
- catch (err) {
215
- console.error(` Failed to load LaunchAgent: ${err}`);
262
+ else if (process.platform === 'linux') {
263
+ const servicePath = getSystemdServicePath();
264
+ const serviceName = getSystemdServiceName();
265
+ const serviceDir = path.dirname(servicePath);
266
+ if (!existsSync(serviceDir)) {
267
+ mkdirSync(serviceDir, { recursive: true });
268
+ }
269
+ // Stop existing service if already installed (idempotent reinstall)
270
+ if (existsSync(servicePath)) {
271
+ try {
272
+ execSync(`systemctl --user stop ${serviceName}`, { stdio: 'ignore' });
273
+ }
274
+ catch {
275
+ // not active — fine
276
+ }
277
+ }
278
+ const nodePath = process.execPath;
279
+ const logDir = path.join(BASE_DIR, 'logs');
280
+ if (!existsSync(logDir)) {
281
+ mkdirSync(logDir, { recursive: true });
282
+ }
283
+ const envPath = path.join(BASE_DIR, '.env');
284
+ const servicePATH = [path.dirname(nodePath), '/usr/local/bin', '/usr/bin', '/bin']
285
+ .join(':');
286
+ const unit = `[Unit]
287
+ Description=${getAssistantName()} AI Assistant
288
+ After=network-online.target
289
+ Wants=network-online.target
290
+
291
+ [Service]
292
+ Type=simple
293
+ ExecStart=${nodePath} ${DIST_ENTRY}
294
+ WorkingDirectory=${BASE_DIR}
295
+ Environment=PATH=${servicePATH}
296
+ Environment=CLEMENTINE_HOME=${BASE_DIR}
297
+ EnvironmentFile=-${envPath}
298
+ Restart=always
299
+ RestartSec=5
300
+ StandardOutput=append:${path.join(logDir, 'clementine.log')}
301
+ StandardError=append:${path.join(logDir, 'clementine-error.log')}
302
+
303
+ [Install]
304
+ WantedBy=default.target
305
+ `;
306
+ writeFileSync(servicePath, unit);
307
+ try {
308
+ execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
309
+ execSync(`systemctl --user enable --now ${serviceName}`, { stdio: 'pipe' });
310
+ // Enable lingering so the service runs even when the user is not logged in (VPS)
311
+ try {
312
+ execSync(`loginctl enable-linger $(whoami)`, { stdio: 'pipe' });
313
+ }
314
+ catch {
315
+ console.log(' Note: Could not enable linger. Run as root: loginctl enable-linger $(whoami)');
316
+ }
317
+ console.log(` Installed and started systemd service: ${serviceName}`);
318
+ console.log(` Service: ${servicePath}`);
319
+ console.log(` Logs: ${logDir}/`);
320
+ console.log(` Status: systemctl --user status ${serviceName}`);
321
+ }
322
+ catch (err) {
323
+ console.error(` Failed to enable systemd service: ${err}`);
324
+ }
216
325
  }
217
326
  // Also install the cron scheduler alongside the daemon
218
327
  console.log();
@@ -681,7 +790,7 @@ function cmdDoctor(opts = {}) {
681
790
  else {
682
791
  console.log(` ${DIM} ○ Daemon not running${RESET}`);
683
792
  }
684
- // LaunchAgent health check (macOS only)
793
+ // Service health check (platform-specific)
685
794
  if (process.platform === 'darwin') {
686
795
  const plistPath = getLaunchdPlistPath();
687
796
  if (existsSync(plistPath)) {
@@ -700,6 +809,25 @@ function cmdDoctor(opts = {}) {
700
809
  issues++;
701
810
  }
702
811
  }
812
+ else if (process.platform === 'linux') {
813
+ const servicePath = getSystemdServicePath();
814
+ const serviceName = getSystemdServiceName();
815
+ if (existsSync(servicePath)) {
816
+ try {
817
+ execSync(`systemctl --user is-active ${serviceName}`, { stdio: 'pipe' });
818
+ console.log(` ${GREEN}OK${RESET} Systemd service installed and active`);
819
+ }
820
+ catch {
821
+ console.log(` ${YELLOW}WARN${RESET} Systemd service installed but not active`);
822
+ console.log(` Start it: systemctl --user start ${serviceName}`);
823
+ issues++;
824
+ }
825
+ }
826
+ else {
827
+ console.log(` ${YELLOW}WARN${RESET} Systemd service not installed (run: clementine launch --install)`);
828
+ issues++;
829
+ }
830
+ }
703
831
  console.log();
704
832
  if (issues === 0 && fixed === 0) {
705
833
  console.log(` ${GREEN}All checks passed.${RESET}`);
@@ -925,6 +1053,16 @@ program
925
1053
  .description('Restart the assistant (daemon by default)')
926
1054
  .option('-f, --foreground', 'Run in foreground after restart')
927
1055
  .action(cmdRestart);
1056
+ program
1057
+ .command('setup')
1058
+ .description('Run interactive setup wizard')
1059
+ .action(() => {
1060
+ ensureDataHome();
1061
+ runSetup().catch((err) => {
1062
+ console.error('Setup failed:', err);
1063
+ process.exit(1);
1064
+ });
1065
+ });
928
1066
  program
929
1067
  .command('rebuild')
930
1068
  .description('Rebuild from source and restart all processes (daemon + dashboard)')
package/dist/cli/setup.js CHANGED
@@ -629,19 +629,20 @@ export async function runSetup() {
629
629
  console.log(` All users: ${allowAll ? 'yes' : 'no (owner only)'}`);
630
630
  console.log();
631
631
  // ── Step 8: Auto-start on login ───────────────────────────────────
632
- if (process.platform === 'darwin') {
632
+ if (process.platform === 'darwin' || process.platform === 'linux') {
633
633
  sectionHeader('Step 8: Auto-Start');
634
- console.log(` ${DIM}Install a login service so ${entries['ASSISTANT_NAME'] || 'Clementine'} starts${RESET}`);
635
- console.log(` ${DIM}automatically when you turn on your computer.${RESET}`);
634
+ const serviceName = process.platform === 'darwin' ? 'LaunchAgent' : 'systemd service';
635
+ console.log(` ${DIM}Install a ${serviceName} so ${entries['ASSISTANT_NAME'] || 'Clementine'} starts${RESET}`);
636
+ console.log(` ${DIM}automatically when the system boots.${RESET}`);
636
637
  console.log();
637
638
  const installService = await confirm({
638
- message: 'Start automatically on login? (recommended)',
639
+ message: 'Start automatically on boot? (recommended)',
639
640
  default: true,
640
641
  });
641
642
  if (installService) {
642
- // Signal to the caller that LaunchAgent should be installed
643
- writeFileSync(path.join(BASE_DIR, '.install-launchagent'), '');
644
- console.log(` ${GREEN}✔ Will install login service after first launch${RESET}`);
643
+ // Signal to the caller that the service should be installed
644
+ writeFileSync(path.join(BASE_DIR, '.install-service'), '');
645
+ console.log(` ${GREEN}✔ Will install ${serviceName} after first launch${RESET}`);
645
646
  }
646
647
  }
647
648
  console.log();
@@ -990,6 +990,19 @@ export class CronScheduler {
990
990
  }
991
991
  catch { /* non-fatal */ }
992
992
  }
993
+ // Auto-disable after too many consecutive failures — prevents zombie jobs
994
+ // from burning resources indefinitely. Re-enable from the dashboard.
995
+ if (consErrors >= 10 && !this.disabledJobs.has(job.name)) {
996
+ this.disabledJobs.add(job.name);
997
+ const scheduledTask = this.scheduledTasks.get(job.name);
998
+ if (scheduledTask) {
999
+ scheduledTask.stop();
1000
+ this.scheduledTasks.delete(job.name);
1001
+ }
1002
+ logger.error({ job: job.name, consErrors }, `Auto-disabled cron after ${consErrors} consecutive failures`);
1003
+ this.logAdvisorEvent('auto-disabled', job.name, `Auto-disabled after ${consErrors} consecutive failures`);
1004
+ this.dispatcher.send(`🛑 **Cron auto-disabled** — \`${job.name}\` failed ${consErrors} times in a row. Fix the job and re-enable it from the dashboard.`, { agentSlug: job.agentSlug }).catch(err => logger.debug({ err }, 'Failed to send auto-disable notification'));
1005
+ }
993
1006
  }
994
1007
  }
995
1008
  /**
@@ -204,7 +204,7 @@ export class HeartbeatScheduler {
204
204
  '(preferences, decisions, people info, project updates) to long-term memory using ' +
205
205
  'memory_write. Skip anything already in MEMORY.md. Be selective — only save facts ' +
206
206
  'that will be useful in future conversations. Do not create duplicate entries.', 1, // tier 1 (vault-only)
207
- 3, // max 3 turns
207
+ 10, // max 10 turns — workflow needs read + candidates + read + write + mark_consolidated
208
208
  'haiku').catch(err => {
209
209
  logger.error({ err }, 'Evening memory consolidation failed');
210
210
  });
package/install.sh CHANGED
@@ -87,15 +87,15 @@ step "Checking prerequisites"
87
87
  if command_exists node; then
88
88
  NODE_VERSION=$(node --version)
89
89
  NODE_MAJOR=$(echo "$NODE_VERSION" | sed 's/v//' | cut -d. -f1)
90
- if [ "$NODE_MAJOR" -ge 20 ] && [ "$NODE_MAJOR" -le 24 ]; then
90
+ if [ "$NODE_MAJOR" -ge 20 ]; then
91
91
  ok "Node.js ${NODE_VERSION}"
92
92
  else
93
- fail "Node.js ${NODE_VERSION} — need v20-24 LTS.
93
+ fail "Node.js ${NODE_VERSION} — need v20 or newer.
94
94
  Switch version: nvm install 22 && nvm use 22
95
95
  Then re-run: bash install.sh"
96
96
  fi
97
97
  else
98
- fail "Node.js not found. Clementine requires Node.js 20-24 LTS.
98
+ fail "Node.js not found. Clementine requires Node.js 20 or newer.
99
99
  Install via nvm:
100
100
  curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
101
101
  nvm install 22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",