clementine-agent 1.0.8 → 1.0.10
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/README.md +37 -0
- package/dist/agent/assistant.js +42 -8
- package/dist/agent/metacognition.js +16 -1
- package/dist/agent/stall-guard.d.ts +4 -0
- package/dist/agent/stall-guard.js +4 -0
- package/dist/config.d.ts +8 -8
- package/dist/config.js +6 -4
- package/dist/gateway/cron-scheduler.js +2 -1
- package/dist/tools/admin-tools.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -434,6 +434,43 @@ ENABLE_1M_CONTEXT=false # Enable 1M token context for Sonnet (toggle in dashb
|
|
|
434
434
|
|
|
435
435
|
Secrets can also be stored in macOS Keychain (`security find-generic-password`) — Clementine checks Keychain as a fallback for any missing `.env` value.
|
|
436
436
|
|
|
437
|
+
### Tuning Clementine
|
|
438
|
+
|
|
439
|
+
Clementine ships with sensible defaults. To change anything, use:
|
|
440
|
+
|
|
441
|
+
```bash
|
|
442
|
+
clementine config set <KEY> <value> # writes to ~/.clementine/.env
|
|
443
|
+
clementine config get <KEY>
|
|
444
|
+
clementine config list # show all overrides
|
|
445
|
+
clementine restart # apply changes
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Your overrides live in `~/.clementine/.env` — **they survive every `npm update -g` / `clementine update`** because they're in your data home, not the package directory.
|
|
449
|
+
|
|
450
|
+
**Commonly tuned knobs:**
|
|
451
|
+
|
|
452
|
+
| Key | Default | What it does |
|
|
453
|
+
|-----|---------|--------------|
|
|
454
|
+
| `BUDGET_CHAT_USD` | `5.00` | Max spend per interactive chat message |
|
|
455
|
+
| `BUDGET_CRON_T1_USD` | `2.00` | Max spend per tier-1 cron job |
|
|
456
|
+
| `BUDGET_CRON_T2_USD` | `5.00` | Max spend per tier-2 cron job |
|
|
457
|
+
| `BUDGET_HEARTBEAT_USD` | `0.50` | Max spend per heartbeat tick |
|
|
458
|
+
| `DEFAULT_MODEL_TIER` | `sonnet` | Default model: `haiku` / `sonnet` / `opus` |
|
|
459
|
+
| `ENABLE_1M_CONTEXT` | `false` | Enable Sonnet 1M-token context (beta) |
|
|
460
|
+
| `HEARTBEAT_INTERVAL_MINUTES` | `30` | How often the agent auto-checks in |
|
|
461
|
+
| `HEARTBEAT_ACTIVE_START` | `8` | First hour of the active window (0–23) |
|
|
462
|
+
| `HEARTBEAT_ACTIVE_END` | `22` | Last hour of the active window |
|
|
463
|
+
| `TIMEZONE` | system TZ | IANA timezone string (e.g., `America/Los_Angeles`) |
|
|
464
|
+
| `ALLOW_ALL_USERS` | `false` | `true` = skip owner-only gate (trust all DMs) |
|
|
465
|
+
| `ASSISTANT_NAME` | `Clementine` | Display name across channels |
|
|
466
|
+
|
|
467
|
+
Example — raise the chat budget to `$10` without ever touching source:
|
|
468
|
+
|
|
469
|
+
```bash
|
|
470
|
+
clementine config set BUDGET_CHAT_USD 10
|
|
471
|
+
clementine restart
|
|
472
|
+
```
|
|
473
|
+
|
|
437
474
|
---
|
|
438
475
|
|
|
439
476
|
## Models
|
package/dist/agent/assistant.js
CHANGED
|
@@ -1357,6 +1357,13 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1357
1357
|
if (stallGuard) {
|
|
1358
1358
|
const stallCheck = stallGuard.shouldBlockTool(toolName);
|
|
1359
1359
|
if (stallCheck.block) {
|
|
1360
|
+
// When the breaker engages we also abort the whole query —
|
|
1361
|
+
// denying a single tool isn't enough for a runaway loop,
|
|
1362
|
+
// the agent will just try the next read-only tool.
|
|
1363
|
+
if (abortController && !abortController.signal.aborted) {
|
|
1364
|
+
logger.warn({ sessionKey, toolName }, 'StallGuard breaker engaged — aborting query');
|
|
1365
|
+
abortController.abort();
|
|
1366
|
+
}
|
|
1360
1367
|
return { behavior: 'deny', message: stallCheck.message ?? 'Stall breaker.' };
|
|
1361
1368
|
}
|
|
1362
1369
|
}
|
|
@@ -1966,7 +1973,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1966
1973
|
const lower = errorText.toLowerCase();
|
|
1967
1974
|
if (lower.includes('max_budget_usd') || lower.includes('budget')) {
|
|
1968
1975
|
logger.warn({ sessionKey }, 'Chat query hit budget cap');
|
|
1969
|
-
responseText = responseText ||
|
|
1976
|
+
responseText = responseText || (`I hit the $${BUDGET.chat.toFixed(2)} cost cap for this query. Options:\n` +
|
|
1977
|
+
`• Break it into smaller requests\n` +
|
|
1978
|
+
`• Reply "deep mode" to queue this as a background task with a bigger budget\n` +
|
|
1979
|
+
`• Raise the cap permanently: \`clementine config set BUDGET_CHAT_USD 10\` then \`clementine restart\``);
|
|
1970
1980
|
}
|
|
1971
1981
|
else if (lower.includes('rate') && lower.includes('limit')) {
|
|
1972
1982
|
hitRateLimit = true;
|
|
@@ -2031,9 +2041,19 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2031
2041
|
catch (e) {
|
|
2032
2042
|
const errStr = String(e).toLowerCase();
|
|
2033
2043
|
if (errStr.includes('abort') || errStr.includes('cancel')) {
|
|
2034
|
-
// Query was aborted
|
|
2035
|
-
|
|
2036
|
-
|
|
2044
|
+
// Query was aborted. Three sources: timeout, user cancel, or
|
|
2045
|
+
// StallGuard tripped (runaway loop detected).
|
|
2046
|
+
const stallAbort = !!stallGuard?.isBreakerActive();
|
|
2047
|
+
logger.warn({ sessionKey, stallAbort }, 'Chat query aborted');
|
|
2048
|
+
if (stallAbort) {
|
|
2049
|
+
const reason = stallGuard?.getBreakerReason() ?? 'runaway loop';
|
|
2050
|
+
const stallMsg = `I got stuck in a loop — ${reason} ` +
|
|
2051
|
+
`I stopped to save budget. Options:\n` +
|
|
2052
|
+
`• Rephrase your request more specifically\n` +
|
|
2053
|
+
`• Reply "deep mode" to queue this as a background task with a bigger budget`;
|
|
2054
|
+
responseText = responseText ? responseText + '\n\n' + stallMsg : stallMsg;
|
|
2055
|
+
}
|
|
2056
|
+
else if (!responseText) {
|
|
2037
2057
|
responseText = 'I ran out of time on this one. Let me know if you want me to pick it back up.';
|
|
2038
2058
|
}
|
|
2039
2059
|
else {
|
|
@@ -2070,7 +2090,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2070
2090
|
responseText = responseText || 'The conversation context filled up from large tool outputs. I\'ve reset the session — please try again, and I\'ll keep query results smaller this time.';
|
|
2071
2091
|
}
|
|
2072
2092
|
else if (errStr.includes('prompt is too long') || errStr.includes('prompt too long') || errStr.includes('context_length')) {
|
|
2073
|
-
responseText = responseText || '
|
|
2093
|
+
responseText = responseText || ('The conversation got too large to process (tool responses filled the context window). ' +
|
|
2094
|
+
"I've reset the session. Try again — I'll keep result sets smaller this time.");
|
|
2074
2095
|
}
|
|
2075
2096
|
else if (errStr.includes('no conversation found') || errStr.includes('conversation not found') || errStr.includes('session not found')) {
|
|
2076
2097
|
// Stale session — clear and retry
|
|
@@ -2091,9 +2112,20 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2091
2112
|
else {
|
|
2092
2113
|
logger.error({ err: e, sessionKey }, 'SDK query failed');
|
|
2093
2114
|
if (!responseText) {
|
|
2094
|
-
//
|
|
2115
|
+
// Classify so the user gets a useful suggestion instead of raw error text.
|
|
2095
2116
|
const shortErr = String(e).replace(/\n.*$/s, '').slice(0, 200);
|
|
2096
|
-
|
|
2117
|
+
const lowerErr = String(e).toLowerCase();
|
|
2118
|
+
let hint = '';
|
|
2119
|
+
if (lowerErr.includes('econnrefused') || lowerErr.includes('socket') || lowerErr.includes('network')) {
|
|
2120
|
+
hint = 'Looks like a network issue — check your internet and try again.';
|
|
2121
|
+
}
|
|
2122
|
+
else if (lowerErr.includes('spawn') || lowerErr.includes('enoent')) {
|
|
2123
|
+
hint = 'A required binary seems to be missing. Try `clementine doctor` to diagnose.';
|
|
2124
|
+
}
|
|
2125
|
+
else {
|
|
2126
|
+
hint = 'Try again, or `!clear` to reset the session. If it keeps happening, check `~/.clementine/logs/clementine.log`.';
|
|
2127
|
+
}
|
|
2128
|
+
responseText = `I hit an error: ${shortErr}\n\n${hint}`;
|
|
2097
2129
|
}
|
|
2098
2130
|
}
|
|
2099
2131
|
}
|
|
@@ -3641,7 +3673,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3641
3673
|
appendProgress({ event: 'aborted', phase, reason: `${MAX_CONSECUTIVE_ERRORS} consecutive phase errors` });
|
|
3642
3674
|
writeStatus({ jobName, status: 'error', phase, startedAt, finishedAt: new Date().toISOString() });
|
|
3643
3675
|
logger.error(`Unleashed task ${jobName} aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive errors`);
|
|
3644
|
-
const errorResult = lastOutput || `Task "${jobName}" aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive phase errors
|
|
3676
|
+
const errorResult = lastOutput || (`Task "${jobName}" aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive phase errors. ` +
|
|
3677
|
+
`Check \`clementine cron runs ${jobName}\` for the failing phase, or retry with ` +
|
|
3678
|
+
`\`clementine cron run ${jobName}\`.`);
|
|
3645
3679
|
if (this.onUnleashedComplete) {
|
|
3646
3680
|
try {
|
|
3647
3681
|
this.onUnleashedComplete(jobName, errorResult);
|
|
@@ -94,7 +94,22 @@ export class MetacognitiveMonitor {
|
|
|
94
94
|
this.interventionCount++;
|
|
95
95
|
return signal;
|
|
96
96
|
}
|
|
97
|
-
// Signal: excessive tool calls
|
|
97
|
+
// Signal: excessive tool calls with near-zero output.
|
|
98
|
+
// Warn at 20, intervene (hard stop) at 60 — beyond 60 the agent is
|
|
99
|
+
// almost certainly in a runaway loop that will burn through the
|
|
100
|
+
// budget cap with nothing to show for it.
|
|
101
|
+
if (this.toolCalls.length >= 60 && this.outputCharCount < 200) {
|
|
102
|
+
this.confidence = 'low';
|
|
103
|
+
if (!this.signals.includes('high_effort_low_output')) {
|
|
104
|
+
this.signals.push('high_effort_low_output');
|
|
105
|
+
}
|
|
106
|
+
this.interventionCount++;
|
|
107
|
+
return {
|
|
108
|
+
type: 'intervene',
|
|
109
|
+
reason: 'high_effort_low_output',
|
|
110
|
+
guidance: `You've made ${this.toolCalls.length} tool calls across ${this.uniqueTools.size} tools with only ${this.outputCharCount} chars of output. This is a runaway loop. Stopping now to prevent budget waste.`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
98
113
|
if (this.toolCalls.length > 20 && this.outputCharCount < 200) {
|
|
99
114
|
this.confidence = 'low';
|
|
100
115
|
if (!this.signals.includes('high_effort_low_output')) {
|
|
@@ -33,6 +33,10 @@ export declare class StallGuard {
|
|
|
33
33
|
block: boolean;
|
|
34
34
|
message?: string;
|
|
35
35
|
};
|
|
36
|
+
/** True when the stall breaker has been engaged during this query. */
|
|
37
|
+
isBreakerActive(): boolean;
|
|
38
|
+
/** Reason string set when the breaker engaged (empty if not active). */
|
|
39
|
+
getBreakerReason(): string;
|
|
36
40
|
/**
|
|
37
41
|
* Record a tool call. Runs loop detection and metacognition.
|
|
38
42
|
* Activates the breaker if either detector fires.
|
|
@@ -41,6 +41,10 @@ export class StallGuard {
|
|
|
41
41
|
}
|
|
42
42
|
return { block: false };
|
|
43
43
|
}
|
|
44
|
+
/** True when the stall breaker has been engaged during this query. */
|
|
45
|
+
isBreakerActive() { return this.breakerActive; }
|
|
46
|
+
/** Reason string set when the breaker engaged (empty if not active). */
|
|
47
|
+
getBreakerReason() { return this.breakerReason; }
|
|
44
48
|
/**
|
|
45
49
|
* Record a tool call. Runs loop detection and metacognition.
|
|
46
50
|
* Activates the breaker if either detector fires.
|
package/dist/config.d.ts
CHANGED
|
@@ -39,14 +39,14 @@ export declare const OWNER_NAME: string;
|
|
|
39
39
|
export declare function shellEscape(s: string): string;
|
|
40
40
|
export declare const MODELS: Models;
|
|
41
41
|
export declare const BUDGET: {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
heartbeat: number;
|
|
43
|
+
cronT1: number;
|
|
44
|
+
cronT2: number;
|
|
45
|
+
chat: number;
|
|
46
|
+
unleashedPhase: undefined;
|
|
47
|
+
memoryExtraction: undefined;
|
|
48
|
+
summarization: undefined;
|
|
49
|
+
reflection: undefined;
|
|
50
50
|
};
|
|
51
51
|
export declare const DEFAULT_MODEL_TIER: keyof Models;
|
|
52
52
|
export declare const MODEL: string;
|
package/dist/config.js
CHANGED
|
@@ -98,11 +98,13 @@ export const MODELS = {
|
|
|
98
98
|
opus: 'claude-opus-4-6',
|
|
99
99
|
};
|
|
100
100
|
// ── Budget caps (USD per query) ──────────────────────────────────────
|
|
101
|
+
// User-tunable via `clementine config set BUDGET_<NAME>_USD <value>`
|
|
102
|
+
// (writes to ~/.clementine/.env, survives npm update -g).
|
|
101
103
|
export const BUDGET = {
|
|
102
|
-
heartbeat: 0.50, //
|
|
103
|
-
cronT1: 2.00, //
|
|
104
|
-
cronT2: 5.00, //
|
|
105
|
-
chat: 5.00, //
|
|
104
|
+
heartbeat: Number(getEnv('BUDGET_HEARTBEAT_USD', '0.50')), // per heartbeat (Haiku)
|
|
105
|
+
cronT1: Number(getEnv('BUDGET_CRON_T1_USD', '2.00')), // per tier-1 cron job
|
|
106
|
+
cronT2: Number(getEnv('BUDGET_CRON_T2_USD', '5.00')), // per tier-2 cron job
|
|
107
|
+
chat: Number(getEnv('BUDGET_CHAT_USD', '5.00')), // per interactive chat
|
|
106
108
|
unleashedPhase: undefined,
|
|
107
109
|
memoryExtraction: undefined,
|
|
108
110
|
summarization: undefined,
|
|
@@ -1184,7 +1184,8 @@ export class CronScheduler {
|
|
|
1184
1184
|
// Truncate
|
|
1185
1185
|
if (msg.length > 300)
|
|
1186
1186
|
msg = msg.slice(0, 297) + '...';
|
|
1187
|
-
return
|
|
1187
|
+
return (`Cron \`${jobName}\` failed: ${msg.trim()}\n` +
|
|
1188
|
+
`Check \`clementine cron runs ${jobName}\` for details, or retry with \`clementine cron run ${jobName}\`.`);
|
|
1188
1189
|
}
|
|
1189
1190
|
listJobs() {
|
|
1190
1191
|
if (this.jobs.length === 0) {
|
|
@@ -1137,7 +1137,7 @@ export function registerAdminTools(server) {
|
|
|
1137
1137
|
// ── Source Self-Edit Tools ──────────────────────────────────────────────
|
|
1138
1138
|
const SELF_IMPROVE_DIR = path.join(BASE_DIR, 'self-improve');
|
|
1139
1139
|
const PENDING_SOURCE_DIR = path.join(SELF_IMPROVE_DIR, 'pending-source-changes');
|
|
1140
|
-
server.tool('self_edit_source', 'Edit Clementine source
|
|
1140
|
+
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`.', {
|
|
1141
1141
|
file: z.string().describe('Path relative to src/ (e.g., "channels/discord-agent-bot.ts")'),
|
|
1142
1142
|
content: z.string().describe('Complete new file content'),
|
|
1143
1143
|
reason: z.string().describe('Why this change is being made'),
|