clementine-agent 1.0.92 → 1.0.94

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.
@@ -5,6 +5,23 @@
5
5
  * cron job execution parameters: turn limits, models, timeouts, prompt
6
6
  * enrichment, escalation, and circuit-breaking.
7
7
  */
8
+ import { CronRunLog } from '../gateway/heartbeat.js';
8
9
  import type { CronJobDefinition, ExecutionAdvice } from '../types.js';
10
+ interface ReflectionEntry {
11
+ jobName: string;
12
+ timestamp: string;
13
+ existence: boolean;
14
+ substance: boolean;
15
+ actionable: boolean;
16
+ communication: boolean;
17
+ criteriaMet: boolean | null;
18
+ quality: number;
19
+ gap: string;
20
+ commNote: string;
21
+ }
9
22
  export declare function getExecutionAdvice(jobName: string, job: CronJobDefinition): ExecutionAdvice;
23
+ export declare function checkTurnLimitHits(runs: ReturnType<CronRunLog['readRecent']>, job: CronJobDefinition, advice: ExecutionAdvice): void;
24
+ export declare function checkReflectionQuality(reflections: ReflectionEntry[], job: CronJobDefinition, advice: ExecutionAdvice): void;
25
+ export declare function checkTimeoutHits(runs: ReturnType<CronRunLog['readRecent']>, job: CronJobDefinition, advice: ExecutionAdvice): void;
26
+ export {};
10
27
  //# sourceMappingURL=execution-advisor.d.ts.map
@@ -102,7 +102,11 @@ export function getExecutionAdvice(jobName, job) {
102
102
  return advice;
103
103
  }
104
104
  // ── Rule helpers ────────────────────────────────────────────────────
105
- function checkTurnLimitHits(runs, job, advice) {
105
+ export function checkTurnLimitHits(runs, job, advice) {
106
+ // Unleashed jobs manage per-phase turns via UNLEASHED_PHASE_TURNS, not job.maxTurns.
107
+ // Bumping maxTurns here would override the unleashed limit with a small value.
108
+ if (job.mode === 'unleashed')
109
+ return;
106
110
  // Use precise TerminalReason when available, fall back to regex on error text
107
111
  const turnLimitHits = runs.slice(0, 5).filter(r => {
108
112
  if (r.status !== 'error' && r.status !== 'retried')
@@ -131,7 +135,9 @@ function checkTurnLimitHits(runs, job, advice) {
131
135
  logger.debug({ job: job.name, from: currentMax, to: advice.adjustedMaxTurns }, 'Adjusting maxTurns due to turn-limit hits');
132
136
  }
133
137
  }
134
- function checkReflectionQuality(reflections, job, advice) {
138
+ export function checkReflectionQuality(reflections, job, advice) {
139
+ if (job.mode === 'unleashed')
140
+ return;
135
141
  const recent = reflections.slice(0, 5); // already newest-first
136
142
  if (recent.length < 3)
137
143
  return;
@@ -166,7 +172,9 @@ function checkModelUpgrade(runs, job, advice) {
166
172
  logger.debug({ job: job.name, failures: recentFailures.length }, 'Upgrading model from haiku to sonnet due to repeated failures');
167
173
  }
168
174
  }
169
- function checkTimeoutHits(runs, job, advice) {
175
+ export function checkTimeoutHits(runs, job, advice) {
176
+ if (job.mode === 'unleashed')
177
+ return;
170
178
  const timeoutMs = DEFAULT_TIMEOUT_MS; // standard cron timeout
171
179
  const threshold = timeoutMs * 0.95;
172
180
  const timeoutHits = runs.slice(0, 5).filter(r => {
@@ -14,11 +14,15 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
14
  import { randomBytes } from 'node:crypto';
15
15
  import path from 'node:path';
16
16
  import pino from 'pino';
17
- import { BASE_DIR } from '../config.js';
17
+ import { ALLOW_SOURCE_EDITS, BASE_DIR } from '../config.js';
18
18
  import { preflightSourceChange } from './source-preflight.js';
19
19
  import { recordSourceMod } from './source-mods.js';
20
20
  const logger = pino({ name: 'clementine.safe-restart' });
21
21
  const SENTINEL_PATH = path.join(BASE_DIR, '.restart-sentinel.json');
22
+ const DEPRECATION_MESSAGE = 'Source self-editing is disabled. Use advisor-rule YAML, CRON.md frontmatter, ' +
23
+ 'or prompt-override markdown instead — those land without a restart and survive ' +
24
+ 'npm updates. Set CLEMENTINE_ALLOW_SOURCE_EDITS=1 in ~/.clementine/.env only for ' +
25
+ 'genuine engine bugs that cannot be expressed as data.';
22
26
  /** Files that cannot be self-edited (security-critical or self-referential). */
23
27
  const BLOCKLIST = new Set([
24
28
  'src/config.ts',
@@ -47,6 +51,11 @@ const BLOCKLIST = new Set([
47
51
  */
48
52
  export async function safeSourceEdit(pkgDir, changes, opts) {
49
53
  const reason = opts?.reason ?? 'source self-edit';
54
+ // Quarantine: source self-edit is deprecated. Off by default.
55
+ if (!ALLOW_SOURCE_EDITS) {
56
+ logger.warn({ fileCount: changes.length, files: changes.map(c => c.relativePath), reason }, 'Source self-edit refused — primitive is disabled');
57
+ return { success: false, error: DEPRECATION_MESSAGE };
58
+ }
50
59
  // Validate against blocklist
51
60
  for (const change of changes) {
52
61
  if (BLOCKLIST.has(change.relativePath)) {
@@ -51,12 +51,21 @@ export interface SelfImproveDispatcher {
51
51
  }>;
52
52
  }
53
53
  export interface SelfImproveLoopOptions {
54
- /** Override scan interval for tests. */
54
+ /**
55
+ * Override the fallback safety-net tick interval. The loop is primarily
56
+ * event-driven; this is just a backstop. Used by tests to disable the
57
+ * timer entirely (set to 0 or a very large number).
58
+ */
55
59
  tickMs?: number;
56
60
  /** Override directories for tests. */
57
61
  triggersDir?: string;
58
62
  pendingDir?: string;
59
63
  cronPath?: string;
64
+ /**
65
+ * Disable the fs.watch event-driven path. Tests use this so they can
66
+ * call tick() directly without racing the watcher.
67
+ */
68
+ disableWatch?: boolean;
60
69
  }
61
70
  export declare function classifyFailure(recentErrors: string[]): FixRecipe;
62
71
  export declare class SelfImproveLoop {
@@ -65,12 +74,18 @@ export declare class SelfImproveLoop {
65
74
  private readonly pendingDir;
66
75
  private readonly cronPath;
67
76
  private readonly dispatcher;
77
+ private readonly watchEnabled;
68
78
  private timer;
79
+ private watcher;
80
+ private debounceTimer;
69
81
  private running;
70
82
  private ticking;
71
83
  constructor(dispatcher: SelfImproveDispatcher, opts?: SelfImproveLoopOptions);
72
84
  start(): void;
73
85
  stop(): void;
86
+ /** Coalesce a burst of fs.watch events (multiple triggers landing in
87
+ * quick succession) into a single tick. */
88
+ private scheduleDebouncedTick;
74
89
  /**
75
90
  * Process all pending triggers. Public so tests + manual invocations
76
91
  * (e.g., a `clementine self-improve tick` CLI command) can call it.
@@ -24,13 +24,25 @@
24
24
  * Idempotent: re-applying the same fix to an already-fixed job is a
25
25
  * no-op; the trigger gets removed regardless.
26
26
  */
27
- import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
27
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, watch, writeFileSync, } from 'node:fs';
28
28
  import path from 'node:path';
29
29
  import matter from 'gray-matter';
30
30
  import pino from 'pino';
31
31
  import { BASE_DIR, SYSTEM_DIR } from '../config.js';
32
32
  const logger = pino({ name: 'clementine.self-improve-loop' });
33
- const TICK_MS = 10 * 60 * 1000;
33
+ /**
34
+ * Fallback tick interval. The loop is primarily event-driven via fs.watch
35
+ * on the triggers directory — this is just a slow safety net for cases
36
+ * where fs.watch dropped an event (rare but possible) or where the
37
+ * daemon booted with triggers already in place from before fs.watch was
38
+ * registered. 1h is plenty: the upstream cron scheduler runs at most
39
+ * once per minute, and a job needs 3+ consecutive errors to even produce
40
+ * a trigger, so the situation is already hours-stale by the time we see
41
+ * a trigger.
42
+ */
43
+ const FALLBACK_TICK_MS = 60 * 60 * 1000;
44
+ /** Coalesce a burst of fs.watch events into a single tick. */
45
+ const WATCH_DEBOUNCE_MS = 2000;
34
46
  const TRIGGERS_DIR = path.join(BASE_DIR, 'self-improve', 'triggers');
35
47
  const PENDING_CHANGES_DIR = path.join(BASE_DIR, 'self-improve', 'pending-changes');
36
48
  const CRON_PATH = path.join(SYSTEM_DIR, 'CRON.md');
@@ -141,27 +153,49 @@ export class SelfImproveLoop {
141
153
  pendingDir;
142
154
  cronPath;
143
155
  dispatcher;
156
+ watchEnabled;
144
157
  timer = null;
158
+ watcher = null;
159
+ debounceTimer = null;
145
160
  running = false;
146
161
  ticking = false;
147
162
  constructor(dispatcher, opts = {}) {
148
163
  this.dispatcher = dispatcher;
149
- this.tickMs = opts.tickMs ?? TICK_MS;
164
+ this.tickMs = opts.tickMs ?? FALLBACK_TICK_MS;
150
165
  this.triggersDir = opts.triggersDir ?? TRIGGERS_DIR;
151
166
  this.pendingDir = opts.pendingDir ?? PENDING_CHANGES_DIR;
152
167
  this.cronPath = opts.cronPath ?? CRON_PATH;
168
+ this.watchEnabled = opts.disableWatch !== true;
153
169
  }
154
170
  start() {
155
171
  if (this.running)
156
172
  return;
157
173
  this.running = true;
158
174
  // Run immediately so any backlog from the prior daemon gets handled
159
- // without a 10-minute wait.
175
+ // without a long wait.
160
176
  this.tick().catch((err) => logger.error({ err }, 'Initial self-improve tick failed'));
177
+ // Event-driven primary path: watch the triggers dir. cron-scheduler
178
+ // writes a file when a job hits consErrors >= 3; we react within
179
+ // ~2 seconds (debounce window) instead of polling every 10 minutes
180
+ // for a directory that's empty 99% of the time.
181
+ if (this.watchEnabled) {
182
+ try {
183
+ mkdirSync(this.triggersDir, { recursive: true });
184
+ this.watcher = watch(this.triggersDir, (eventType, filename) => {
185
+ if (eventType !== 'rename' || !filename || !filename.endsWith('.json'))
186
+ return;
187
+ this.scheduleDebouncedTick();
188
+ });
189
+ }
190
+ catch (err) {
191
+ logger.warn({ err, dir: this.triggersDir }, 'Failed to watch triggers dir — falling back to polling only');
192
+ }
193
+ }
194
+ // Slow fallback safety net — covers fs.watch event drops + boot-with-backlog.
161
195
  this.timer = setInterval(() => {
162
- this.tick().catch((err) => logger.error({ err }, 'Self-improve tick failed'));
196
+ this.tick().catch((err) => logger.error({ err }, 'Self-improve fallback tick failed'));
163
197
  }, this.tickMs);
164
- logger.info({ tickMs: this.tickMs }, 'Self-improve loop started');
198
+ logger.info({ fallbackTickMs: this.tickMs, watchEnabled: this.watchEnabled && this.watcher !== null }, 'Self-improve loop started');
165
199
  }
166
200
  stop() {
167
201
  if (!this.running)
@@ -171,8 +205,29 @@ export class SelfImproveLoop {
171
205
  clearInterval(this.timer);
172
206
  this.timer = null;
173
207
  }
208
+ if (this.watcher) {
209
+ try {
210
+ this.watcher.close();
211
+ }
212
+ catch { /* ignore */ }
213
+ this.watcher = null;
214
+ }
215
+ if (this.debounceTimer) {
216
+ clearTimeout(this.debounceTimer);
217
+ this.debounceTimer = null;
218
+ }
174
219
  logger.info('Self-improve loop stopped');
175
220
  }
221
+ /** Coalesce a burst of fs.watch events (multiple triggers landing in
222
+ * quick succession) into a single tick. */
223
+ scheduleDebouncedTick() {
224
+ if (this.debounceTimer)
225
+ clearTimeout(this.debounceTimer);
226
+ this.debounceTimer = setTimeout(() => {
227
+ this.debounceTimer = null;
228
+ this.tick().catch((err) => logger.error({ err }, 'Self-improve event-driven tick failed'));
229
+ }, WATCH_DEBOUNCE_MS);
230
+ }
176
231
  /**
177
232
  * Process all pending triggers. Public so tests + manual invocations
178
233
  * (e.g., a `clementine self-improve tick` CLI command) can call it.
@@ -23,9 +23,11 @@ const DEFAULT_CONFIG = {
23
23
  maxDurationMs: 3_600_000, // 1 hour
24
24
  acceptThreshold: 0.7,
25
25
  plateauLimit: 3,
26
- areas: ['soul', 'cron', 'workflow', 'memory', 'agent', 'source', 'communication', 'goal'],
26
+ // 'source' deprecated self-improvement should produce data (cron, workflow, etc.),
27
+ // not engine TS edits. Re-add only with CLEMENTINE_ALLOW_SOURCE_EDITS=1.
28
+ areas: ['soul', 'cron', 'workflow', 'memory', 'agent', 'communication', 'goal'],
27
29
  autoApply: true,
28
- sourceMode: 'propose-only',
30
+ sourceMode: 'skip',
29
31
  };
30
32
  // ── Paths ────────────────────────────────────────────────────────────
31
33
  const EXPERIMENT_LOG = path.join(SELF_IMPROVE_DIR, 'experiment-log.jsonl');
@@ -6,10 +6,13 @@
6
6
  */
7
7
  import express from 'express';
8
8
  import pino from 'pino';
9
- import { WEBHOOK_PORT, WEBHOOK_SECRET } from '../config.js';
9
+ import { WEBHOOK_BIND, WEBHOOK_PORT, WEBHOOK_SECRET } from '../config.js';
10
10
  const logger = pino({ name: 'clementine.webhook' });
11
11
  // ── Entry point ───────────────────────────────────────────────────────
12
12
  export async function startWebhook(gateway) {
13
+ if (!WEBHOOK_SECRET) {
14
+ throw new Error('WEBHOOK_ENABLED=true requires WEBHOOK_SECRET to be set. Refusing to start an unauthenticated webhook server.');
15
+ }
13
16
  const app = express();
14
17
  app.use(express.json());
15
18
  // ── Bearer token auth middleware ──────────────────────────────────
@@ -54,8 +57,8 @@ export async function startWebhook(gateway) {
54
57
  res.status(500).json({ error: 'Internal server error' });
55
58
  }
56
59
  });
57
- // ── GET /api/status — health check ────────────────────────────────
58
- app.get('/api/status', (_req, res) => {
60
+ // ── GET /api/status — health check (auth-gated to avoid uptime leakage) ──
61
+ app.get('/api/status', requireAuth, (_req, res) => {
59
62
  res.json({
60
63
  status: 'ok',
61
64
  uptime: process.uptime(),
@@ -64,9 +67,13 @@ export async function startWebhook(gateway) {
64
67
  });
65
68
  // ── Start server ──────────────────────────────────────────────────
66
69
  const port = WEBHOOK_PORT;
70
+ const bind = WEBHOOK_BIND;
71
+ if (bind !== '127.0.0.1' && bind !== 'localhost') {
72
+ logger.warn({ bind }, '⚠ Webhook bound to non-localhost address — bearer auth is your only protection. Prefer tunneling (cloudflared) over exposing directly.');
73
+ }
67
74
  await new Promise((resolve) => {
68
- app.listen(port, '0.0.0.0', () => {
69
- logger.info(`Webhook API server listening on port ${port}`);
75
+ app.listen(port, bind, () => {
76
+ logger.info({ bind, port }, 'Webhook API server listening');
70
77
  resolve();
71
78
  });
72
79
  });
package/dist/config.d.ts CHANGED
@@ -80,6 +80,7 @@ export declare const WHATSAPP_WEBHOOK_PORT: number;
80
80
  export declare const WEBHOOK_ENABLED: boolean;
81
81
  export declare const WEBHOOK_PORT: number;
82
82
  export declare const WEBHOOK_SECRET: string;
83
+ export declare const WEBHOOK_BIND: string;
83
84
  export declare const GROQ_API_KEY: string;
84
85
  export declare const ELEVENLABS_API_KEY: string;
85
86
  export declare const ELEVENLABS_VOICE_ID: string;
@@ -150,6 +151,7 @@ export declare const PLANS_DIR: string;
150
151
  export declare const ADVISOR_LOG_PATH: string;
151
152
  export declare const REMOTE_ACCESS_CONFIG: string;
152
153
  export declare const STAGING_DIR: string;
154
+ export declare const ALLOW_SOURCE_EDITS: boolean;
153
155
  export declare const CLAUDE_CODE_OAUTH_TOKEN: string;
154
156
  export declare const ANTHROPIC_API_KEY: string;
155
157
  export declare const CREDENTIALS_FILE: string;
package/dist/config.js CHANGED
@@ -172,6 +172,9 @@ export const WHATSAPP_WEBHOOK_PORT = parseInt(getEnv('WHATSAPP_WEBHOOK_PORT', '8
172
172
  export const WEBHOOK_ENABLED = getEnv('WEBHOOK_ENABLED', 'false').toLowerCase() === 'true';
173
173
  export const WEBHOOK_PORT = parseInt(getEnv('WEBHOOK_PORT', '8420'), 10);
174
174
  export const WEBHOOK_SECRET = getSecret('WEBHOOK_SECRET');
175
+ // Default bind to localhost only — flip to 0.0.0.0 explicitly via WEBHOOK_BIND
176
+ // for tunneled or LAN-exposed setups. Avoids the OpenClaw CVE-2026-25253 shape.
177
+ export const WEBHOOK_BIND = getEnv('WEBHOOK_BIND', '127.0.0.1');
175
178
  // ── Voice ────────────────────────────────────────────────────────────
176
179
  export const GROQ_API_KEY = getSecret('GROQ_API_KEY');
177
180
  export const ELEVENLABS_API_KEY = getSecret('ELEVENLABS_API_KEY');
@@ -302,6 +305,15 @@ export const ADVISOR_LOG_PATH = path.join(BASE_DIR, 'cron', 'advisor-decisions.j
302
305
  export const REMOTE_ACCESS_CONFIG = path.join(BASE_DIR, 'remote-access.json');
303
306
  // ── Source Self-Edit Staging ─────────────────────────────────────────
304
307
  export const STAGING_DIR = path.join(BASE_DIR, 'staging');
308
+ // Source self-editing is deprecated. The data-driven path (advisor rules,
309
+ // CRON.md frontmatter, prompt overrides) is the supported way to evolve
310
+ // behavior without requiring a new release. Set CLEMENTINE_ALLOW_SOURCE_EDITS=1
311
+ // in ~/.clementine/.env to re-enable for genuine engine bugs that can't be
312
+ // expressed as data — the primitive itself stays on disk for that escape hatch.
313
+ export const ALLOW_SOURCE_EDITS = (() => {
314
+ const raw = getEnv('CLEMENTINE_ALLOW_SOURCE_EDITS', '').toLowerCase().trim();
315
+ return raw === '1' || raw === 'true' || raw === 'yes';
316
+ })();
305
317
  // ── API ──────────────────────────────────────────────────────────────
306
318
  // Long-lived OAuth token from `clementine login` / `claude setup-token`.
307
319
  // Takes priority over ANTHROPIC_API_KEY in the SDK subprocess env.
@@ -15,6 +15,7 @@ import path from 'node:path';
15
15
  import Anthropic from '@anthropic-ai/sdk';
16
16
  import { z } from 'zod';
17
17
  import { BASE_DIR, CRON_FILE, SYSTEM_DIR, env, getStore, logger, textResult, } from './shared.js';
18
+ import { ALLOW_SOURCE_EDITS } from '../config.js';
18
19
  import { getInteractionSource } from '../agent/hooks.js';
19
20
  import { renameSync } from 'node:fs';
20
21
  import * as keychain from '../secrets/keychain.js';
@@ -1628,11 +1629,22 @@ export function registerAdminTools(server) {
1628
1629
  // ── Source Self-Edit Tools ──────────────────────────────────────────────
1629
1630
  const SELF_IMPROVE_DIR = path.join(BASE_DIR, 'self-improve');
1630
1631
  const PENDING_SOURCE_DIR = path.join(SELF_IMPROVE_DIR, 'pending-source-changes');
1631
- server.tool('self_edit_source', 'Edit most Clementine TypeScript source files (for new features or bug fixes). Validates in a staging worktree, compiles, and triggers restart on success. Blocked files: `src/config.ts`, `src/gateway/security-scanner.ts`, `src/security/scanner.ts`. Do NOT use this tool to change user-tunable settings (budget caps, model tier, heartbeat interval, timezone, channel IDs, etc.) those live in `~/.clementine/.env` and are managed by the user via `clementine config set KEY value`, which survives `clementine update` / `npm update -g`.', {
1632
- file: z.string().describe('Path relative to src/ (e.g., "channels/discord-agent-bot.ts")'),
1632
+ server.tool('self_edit_source', 'DEPRECATED source self-editing is disabled. Improvements should be expressed as data, not TypeScript: edit advisor rules in ~/.clementine/advisor-rules/, CRON.md frontmatter, or prompt overrides. Those land without a restart and survive npm updates. Only re-enabled (CLEMENTINE_ALLOW_SOURCE_EDITS=1) for genuine engine bugs that cannot be expressed as data.', {
1633
+ file: z.string().describe('Path relative to src/'),
1633
1634
  content: z.string().describe('Complete new file content'),
1634
1635
  reason: z.string().describe('Why this change is being made'),
1635
- }, async ({ file, content, reason }) => {
1636
+ }, async ({ file, content: _content, reason }) => {
1637
+ if (!ALLOW_SOURCE_EDITS) {
1638
+ logger.warn({ file, reason }, 'self_edit_source called while disabled — rejecting');
1639
+ return textResult('Source self-editing is disabled. The fix you want belongs in user-space data, not engine TypeScript:\n' +
1640
+ ' • Cron job behavior → edit ~/.clementine/vault/00-System/CRON.md frontmatter\n' +
1641
+ ' • Advisor logic → write a YAML file in ~/.clementine/advisor-rules/ (coming in next phase)\n' +
1642
+ ' • Prompt content → edit the per-job prompt in CRON.md\n' +
1643
+ ' • Agent behavior → edit the agent.md / SOUL.md\n\n' +
1644
+ 'Those changes land without a restart and survive `npm update -g clementine`. ' +
1645
+ 'If this is a genuine engine bug that cannot be expressed as data, ask the owner to set ' +
1646
+ 'CLEMENTINE_ALLOW_SOURCE_EDITS=1 in ~/.clementine/.env.');
1647
+ }
1636
1648
  // Security blocklist
1637
1649
  const BLOCKLIST = ['config.ts', 'gateway/security-scanner.ts', 'security/scanner.ts'];
1638
1650
  if (BLOCKLIST.some(b => file === b || file.startsWith(b))) {
@@ -1646,7 +1658,7 @@ export function registerAdminTools(server) {
1646
1658
  const pending = {
1647
1659
  id,
1648
1660
  file: `src/${file}`,
1649
- content,
1661
+ content: _content,
1650
1662
  reason,
1651
1663
  createdAt: new Date().toISOString(),
1652
1664
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.92",
3
+ "version": "1.0.94",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",