clementine-agent 1.18.16 → 1.18.18

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 CHANGED
@@ -326,7 +326,9 @@ Your overrides live in `~/.clementine/.env` — **they survive every `npm update
326
326
 
327
327
  The dashboard exposes these spend controls in Settings -> Channels & Env ->
328
328
  Spend Guards & Context Health, including direct dollar-cap editing, Default
329
- Caps, Safe Recovery, and No Caps presets.
329
+ Caps, Safe Recovery, and No Caps presets. When a dashboard change needs the
330
+ daemon to reload, Clementine shows a Restart Clementine prompt and handles the
331
+ restart from the browser.
330
332
 
331
333
  For spend/context tuning, `clementine budgets` gives a safer shortcut:
332
334
 
@@ -13,7 +13,7 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { query as rawQuery, listSubagents, getSubagentMessages, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, } from '@anthropic-ai/claude-agent-sdk';
15
15
  import pino from 'pino';
16
- import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, TASK_BUDGET_TOKENS, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, claudeCodeDisableOneMillionForModel, currentOneMillionContextMode, normalizeClaudeModelForOneMillionContext, usesOneMillionContext, envSnapshot, } from '../config.js';
16
+ import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, TASK_BUDGET_TOKENS, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, claudeCodeDisableOneMillionForModel, currentOneMillionContextMode, normalizeClaudeModelForOneMillionContext, normalizeClaudeSdkOptionsForOneMillionContext, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, usesOneMillionContext, envSnapshot, } from '../config.js';
17
17
  import { summarizeIntegrationStatus } from '../config/integrations-registry.js';
18
18
  import { loadToolPreferences, computeAvailability, buildPromptInstruction, buildComposioStatusBlock, } from '../integrations/tool-preferences.js';
19
19
  import { loadClaudeIntegrations } from './mcp-bridge.js';
@@ -288,7 +288,7 @@ const query = ((args) => {
288
288
  if (typeof opts.appendSystemPrompt === 'string') {
289
289
  newOpts.appendSystemPrompt = stripLoneSurrogates(opts.appendSystemPrompt);
290
290
  }
291
- cleaned.options = newOpts;
291
+ cleaned.options = normalizeClaudeSdkOptionsForOneMillionContext(newOpts);
292
292
  }
293
293
  return rawQuery(cleaned);
294
294
  }
@@ -341,8 +341,7 @@ function resultInputTokens(result) {
341
341
  return total;
342
342
  }
343
343
  export function looksLikeOneMillionContextError(value) {
344
- const text = String(value ?? '');
345
- return /extra usage.*1m context|1m context.*extra usage|context-1m/i.test(text);
344
+ return looksLikeClaudeOneMillionContextError(value);
346
345
  }
347
346
  export function looksLikeNoResponseRequested(value) {
348
347
  const text = String(value ?? '').trim();
@@ -3427,14 +3426,13 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3427
3426
  responseText = responseText || ('Claude says the account credit balance is too low. I paused background jobs for a few hours so they stop draining/retrying, but interactive chat will also fail until credits are available again.');
3428
3427
  }
3429
3428
  else if (looksLikeOneMillionContextError(errorText)) {
3430
- process.env.CLEMENTINE_1M_CONTEXT_MODE = 'off';
3431
- process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
3429
+ applyOneMillionContextRecovery();
3432
3430
  if (sessionKey) {
3433
3431
  this.sessions.delete(sessionKey);
3434
3432
  this.exchangeCounts.set(sessionKey, 0);
3435
3433
  this._compactedSessions.delete(sessionKey);
3436
3434
  }
3437
- responseText = responseText || ("Claude rejected 1M context for this account. I've switched this process to 200K recovery mode and reset the session. To persist the fix across restarts, run `clementine budgets safe`, then `clementine restart`.");
3435
+ responseText = responseText || ("Claude rejected 1M context for this account. I've switched Clementine to persistent 200K recovery mode and reset the session. Restart Clementine once so every background worker starts with the same safe setting.");
3438
3436
  }
3439
3437
  else if (lower.includes('rate') && lower.includes('limit')) {
3440
3438
  hitRateLimit = true;
@@ -3559,14 +3557,13 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3559
3557
  responseText = responseText || ('Claude says the account credit balance is too low. I paused background jobs for a few hours so they stop draining/retrying, but interactive chat will also fail until credits are available again.');
3560
3558
  }
3561
3559
  else if (looksLikeOneMillionContextError(e)) {
3562
- process.env.CLEMENTINE_1M_CONTEXT_MODE = 'off';
3563
- process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
3560
+ applyOneMillionContextRecovery();
3564
3561
  if (sessionKey) {
3565
3562
  this.sessions.delete(sessionKey);
3566
3563
  this.exchangeCounts.set(sessionKey, 0);
3567
3564
  this._compactedSessions.delete(sessionKey);
3568
3565
  }
3569
- responseText = responseText || ("Claude rejected 1M context for this account. I've switched this process to 200K recovery mode and reset the session. To persist the fix across restarts, run `clementine budgets safe`, then `clementine restart`.");
3566
+ responseText = responseText || ("Claude rejected 1M context for this account. I've switched Clementine to persistent 200K recovery mode and reset the session. Restart Clementine once so every background worker starts with the same safe setting.");
3570
3567
  }
3571
3568
  else if (errStr.includes('rate') && (errStr.includes('limit') || errStr.includes('rate_limit'))) {
3572
3569
  hitRateLimit = true;
@@ -4822,8 +4819,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4822
4819
  throw new Error(errText);
4823
4820
  }
4824
4821
  if (looksLikeOneMillionContextError(errText)) {
4825
- process.env.CLEMENTINE_1M_CONTEXT_MODE = 'off';
4826
- process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
4822
+ applyOneMillionContextRecovery();
4827
4823
  throw new Error(errText);
4828
4824
  }
4829
4825
  }
@@ -5171,8 +5167,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
5171
5167
  throw new Error(exitText);
5172
5168
  }
5173
5169
  if (looksLikeOneMillionContextError(exitText)) {
5174
- process.env.CLEMENTINE_1M_CONTEXT_MODE = 'off';
5175
- process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
5170
+ applyOneMillionContextRecovery();
5176
5171
  throw new Error(exitText);
5177
5172
  }
5178
5173
  }
@@ -8,7 +8,7 @@
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 { BASE_DIR, CRON_REFLECTIONS_DIR, TASKS_FILE, INBOX_DIR, MODELS, } from '../config.js';
11
+ import { BASE_DIR, CRON_REFLECTIONS_DIR, TASKS_FILE, INBOX_DIR, MODELS, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
12
12
  import { listAllGoals } from '../tools/shared.js';
13
13
  const logger = pino({ name: 'clementine.daily-planner' });
14
14
  const PLANS_DIR = path.join(BASE_DIR, 'plans', 'daily');
@@ -253,15 +253,24 @@ Rules:
253
253
  let text = '';
254
254
  const stream = query({
255
255
  prompt,
256
- options: {
256
+ options: normalizeClaudeSdkOptionsForOneMillionContext({
257
257
  model: MODELS.haiku,
258
258
  maxTurns: 1,
259
259
  systemPrompt: 'You are a planning assistant. Analyze the context and produce a prioritized daily plan as JSON. Return only valid JSON, no markdown fencing.',
260
- },
260
+ }),
261
261
  });
262
262
  for await (const msg of stream) {
263
- if (msg.type === 'result')
263
+ if (msg.type === 'result') {
264
+ if (msg.is_error) {
265
+ const errorText = Array.isArray(msg.errors)
266
+ ? msg.errors.join('; ')
267
+ : String(msg.result ?? '');
268
+ if (looksLikeClaudeOneMillionContextError(errorText))
269
+ applyOneMillionContextRecovery();
270
+ throw new Error(errorText || 'Claude SDK query failed');
271
+ }
264
272
  text = msg.result ?? '';
273
+ }
265
274
  }
266
275
  if (!text) {
267
276
  logger.warn('LLM returned empty plan — using fallback');
@@ -278,6 +287,8 @@ Rules:
278
287
  return plan;
279
288
  }
280
289
  catch (err) {
290
+ if (looksLikeClaudeOneMillionContextError(err))
291
+ applyOneMillionContextRecovery();
281
292
  logger.warn({ err }, 'LLM plan generation failed — using fallback');
282
293
  return this.fallbackPlan(today);
283
294
  }
@@ -10,7 +10,7 @@ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import pino from 'pino';
13
- import { BASE_DIR } from '../config.js';
13
+ import { BASE_DIR, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
14
14
  const logger = pino({ name: 'clementine.mcp-bridge' });
15
15
  const MCP_SERVERS_FILE = path.join(BASE_DIR, 'mcp-servers.json');
16
16
  const INTEGRATIONS_FILE = path.join(BASE_DIR, 'claude-integrations.json');
@@ -435,13 +435,13 @@ export async function probeAvailableTools(force = false) {
435
435
  const externalMcpServers = getMcpServersForAgent();
436
436
  const stream = query({
437
437
  prompt: 'ok',
438
- options: {
438
+ options: normalizeClaudeSdkOptionsForOneMillionContext({
439
439
  systemPrompt: 'Reply ok.',
440
440
  model: 'claude-haiku-4-5',
441
441
  permissionMode: 'bypassPermissions',
442
442
  allowDangerouslySkipPermissions: true,
443
443
  mcpServers: externalMcpServers,
444
- },
444
+ }),
445
445
  });
446
446
  let tools = [];
447
447
  for await (const msg of stream) {
@@ -449,8 +449,14 @@ export async function probeAvailableTools(force = false) {
449
449
  tools = msg.tools;
450
450
  break;
451
451
  }
452
- if (msg?.type === 'result')
452
+ if (msg?.type === 'result') {
453
+ if (msg.is_error) {
454
+ const errorText = Array.isArray(msg.errors) ? msg.errors.join('; ') : String(msg.result ?? '');
455
+ if (looksLikeClaudeOneMillionContextError(errorText))
456
+ applyOneMillionContextRecovery();
457
+ }
453
458
  break;
459
+ }
454
460
  }
455
461
  const inv = { probedAt: new Date().toISOString(), tools };
456
462
  saveToolInventory(inv);
@@ -471,6 +477,8 @@ export async function probeAvailableTools(force = false) {
471
477
  return inv;
472
478
  }
473
479
  catch (err) {
480
+ if (looksLikeClaudeOneMillionContextError(err))
481
+ applyOneMillionContextRecovery();
474
482
  logger.warn({ err }, 'Tool inventory probe failed — using cached or empty');
475
483
  return cached ?? { probedAt: new Date(0).toISOString(), tools: [] };
476
484
  }
@@ -12,7 +12,7 @@
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 { BASE_DIR, GOALS_DIR, MODELS } from '../config.js';
15
+ import { BASE_DIR, GOALS_DIR, MODELS, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
16
16
  import { listAllGoals } from '../tools/shared.js';
17
17
  const logger = pino({ name: 'clementine.strategic-planner' });
18
18
  const DAILY_PLANS_DIR = path.join(BASE_DIR, 'plans', 'daily');
@@ -23,15 +23,24 @@ async function llmJsonCall(prompt, systemPrompt) {
23
23
  let text = '';
24
24
  const stream = query({
25
25
  prompt,
26
- options: {
26
+ options: normalizeClaudeSdkOptionsForOneMillionContext({
27
27
  model: MODELS.haiku,
28
28
  maxTurns: 1,
29
29
  systemPrompt,
30
- },
30
+ }),
31
31
  });
32
32
  for await (const msg of stream) {
33
- if (msg.type === 'result')
33
+ if (msg.type === 'result') {
34
+ if (msg.is_error) {
35
+ const errorText = Array.isArray(msg.errors)
36
+ ? msg.errors.join('; ')
37
+ : String(msg.result ?? '');
38
+ if (looksLikeClaudeOneMillionContextError(errorText))
39
+ applyOneMillionContextRecovery();
40
+ throw new Error(errorText || 'Claude SDK query failed');
41
+ }
34
42
  text = msg.result ?? '';
43
+ }
35
44
  }
36
45
  return text;
37
46
  }
@@ -14,7 +14,7 @@ import { readFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import pdfParse from 'pdf-parse';
16
16
  import { contentHash } from './common.js';
17
- import { MODELS } from '../../config.js';
17
+ import { MODELS, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../../config.js';
18
18
  export async function* parsePdf(filePath) {
19
19
  let buf;
20
20
  try {
@@ -91,7 +91,7 @@ async function ocrPdfViaClaude(filePath) {
91
91
  const { query } = await import('@anthropic-ai/claude-agent-sdk');
92
92
  const stream = query({
93
93
  prompt: `Read the PDF at ${JSON.stringify(filePath)} using the Read tool. Transcribe every page's text verbatim — preserve the reading order, headings, lists, and paragraphs exactly as they appear. Separate pages with the form-feed character (\\f). Do NOT summarize, paraphrase, add commentary, or wrap in code fences. Output only the transcribed text.`,
94
- options: {
94
+ options: normalizeClaudeSdkOptionsForOneMillionContext({
95
95
  model: MODELS.haiku,
96
96
  maxTurns: 4, // Read tool call + response (a few turns of thinking is fine)
97
97
  systemPrompt: 'You are a faithful OCR transcriber. Copy text exactly as written. When the PDF has images or scans, read the text from them using vision. Never invent content.',
@@ -99,7 +99,7 @@ async function ocrPdfViaClaude(filePath) {
99
99
  allowedTools: ['Read'],
100
100
  permissionMode: 'bypassPermissions',
101
101
  settingSources: [],
102
- },
102
+ }),
103
103
  });
104
104
  let text = '';
105
105
  for await (const message of stream) {
@@ -113,6 +113,13 @@ async function ocrPdfViaClaude(filePath) {
113
113
  }
114
114
  }
115
115
  else if (message.type === 'result') {
116
+ const result = message;
117
+ if (result.is_error) {
118
+ const errorText = Array.isArray(result.errors) ? result.errors.join('; ') : String(result.result ?? '');
119
+ if (looksLikeClaudeOneMillionContextError(errorText))
120
+ applyOneMillionContextRecovery();
121
+ return [];
122
+ }
116
123
  break;
117
124
  }
118
125
  }
@@ -121,7 +128,9 @@ async function ocrPdfViaClaude(filePath) {
121
128
  return [];
122
129
  return splitPages(cleaned);
123
130
  }
124
- catch {
131
+ catch (err) {
132
+ if (looksLikeClaudeOneMillionContextError(err))
133
+ applyOneMillionContextRecovery();
125
134
  return [];
126
135
  }
127
136
  }
@@ -13,7 +13,7 @@
13
13
  * pipeline can be verified without spawning the SDK subprocess.
14
14
  */
15
15
  import { query } from '@anthropic-ai/claude-agent-sdk';
16
- import { MODELS } from '../config.js';
16
+ import { MODELS, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
17
17
  let override = null;
18
18
  /** Inject a deterministic override (used by tests). Pass null to restore. */
19
19
  export function setLLMOverride(fn) {
@@ -29,10 +29,11 @@ export async function callLLM(prompt, opts = {}) {
29
29
  if (opts.format === 'json') {
30
30
  systemParts.push('Respond with a single valid JSON object. No prose, no code fences, no explanation.');
31
31
  }
32
+ const model = opts.model ?? MODELS.haiku;
32
33
  const stream = query({
33
34
  prompt,
34
- options: {
35
- model: opts.model ?? MODELS.haiku,
35
+ options: normalizeClaudeSdkOptionsForOneMillionContext({
36
+ model,
36
37
  maxTurns: 1,
37
38
  systemPrompt: systemParts.join('\n\n') || undefined,
38
39
  // No built-in tools: brain calls are pure completions
@@ -42,7 +43,7 @@ export async function callLLM(prompt, opts = {}) {
42
43
  // allowed-tool lists, and statusline config that can slow or
43
44
  // fail our minimal call.
44
45
  settingSources: [],
45
- },
46
+ }),
46
47
  });
47
48
  let assistantText = '';
48
49
  for await (const message of stream) {
@@ -56,6 +57,13 @@ export async function callLLM(prompt, opts = {}) {
56
57
  }
57
58
  }
58
59
  else if (message.type === 'result') {
60
+ const result = message;
61
+ if (result.is_error) {
62
+ const errorText = Array.isArray(result.errors) ? result.errors.join('; ') : String(result.result ?? '');
63
+ if (looksLikeClaudeOneMillionContextError(errorText))
64
+ applyOneMillionContextRecovery();
65
+ throw new Error(errorText || 'Claude SDK query failed');
66
+ }
59
67
  break; // Single-turn done
60
68
  }
61
69
  }
@@ -18,7 +18,7 @@ import cron from 'node-cron';
18
18
  import { TunnelManager } from './tunnel.js';
19
19
  import { AgentManager } from '../agent/agent-manager.js';
20
20
  import { discoverMcpServers, getClaudeIntegrations } from '../agent/mcp-bridge.js';
21
- import { AGENTS_DIR, SESSIONS_FILE } from '../config.js';
21
+ import { AGENTS_DIR, SESSIONS_FILE, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
22
22
  import { parseTasks } from '../tools/shared.js';
23
23
  import { todayISO } from '../gateway/cron-scheduler.js';
24
24
  import { goalsRouter } from './routes/goals.js';
@@ -2066,7 +2066,14 @@ export async function cmdDashboard(opts) {
2066
2066
  }
2067
2067
  catch { /* ignore */ }
2068
2068
  }
2069
- oauthQuery = sdkQuery({ prompt: '', options: { permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, maxTurns: 0 } });
2069
+ oauthQuery = sdkQuery({
2070
+ prompt: '',
2071
+ options: normalizeClaudeSdkOptionsForOneMillionContext({
2072
+ permissionMode: 'bypassPermissions',
2073
+ allowDangerouslySkipPermissions: true,
2074
+ maxTurns: 0,
2075
+ }),
2076
+ });
2070
2077
  const result = await oauthQuery.claudeAuthenticate(true);
2071
2078
  res.json({ ok: true, result });
2072
2079
  }
@@ -4003,7 +4010,7 @@ export async function cmdDashboard(opts) {
4003
4010
  Return ONLY a JSON array. Each element must be an object with shape \`{"id": string, "label": string, "sublabel"?: string}\`. Use the source system's stable id for \`id\` (so the feed can reference it later). Use a short human-readable title for \`label\`. Optional \`sublabel\` can include path, email, date, or any disambiguating detail.
4004
4011
  Do NOT include prose, markdown, code fences, or explanation. Just the JSON array.
4005
4012
  If the tool returns nothing or errors, return an empty array \`[]\`.`,
4006
- options: {
4013
+ options: normalizeClaudeSdkOptionsForOneMillionContext({
4007
4014
  model: MODELS.haiku,
4008
4015
  maxTurns: 3,
4009
4016
  systemPrompt: 'You are a data enumerator. You call the given tool once, extract the items from its response, and emit a strict JSON array. No commentary.',
@@ -4011,7 +4018,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4011
4018
  mcpServers,
4012
4019
  permissionMode: 'bypassPermissions',
4013
4020
  settingSources: [],
4014
- },
4021
+ }),
4015
4022
  });
4016
4023
  let text = '';
4017
4024
  for await (const msg of stream) {
@@ -4023,6 +4030,13 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4023
4030
  }
4024
4031
  }
4025
4032
  else if (msg.type === 'result') {
4033
+ if (msg.is_error) {
4034
+ const errorText = Array.isArray(msg.errors)
4035
+ ? msg.errors.join('; ')
4036
+ : String(msg.result ?? '');
4037
+ if (looksLikeClaudeOneMillionContextError(errorText))
4038
+ applyOneMillionContextRecovery();
4039
+ }
4026
4040
  break;
4027
4041
  }
4028
4042
  }
@@ -4039,6 +4053,8 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4039
4053
  res.json({ items, cached: false, rawPreview: text.slice(0, 400) });
4040
4054
  }
4041
4055
  catch (err) {
4056
+ if (looksLikeClaudeOneMillionContextError(err))
4057
+ applyOneMillionContextRecovery();
4042
4058
  res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
4043
4059
  }
4044
4060
  });
@@ -6321,20 +6337,30 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6321
6337
  let result = '';
6322
6338
  const stream = query({
6323
6339
  prompt,
6324
- options: {
6340
+ options: normalizeClaudeSdkOptionsForOneMillionContext({
6325
6341
  model: 'claude-haiku-4-5-20251001',
6326
6342
  maxTurns: 1,
6327
6343
  systemPrompt: 'You are a memory consolidation assistant. Extract only facts directly evidenced by the corpus. Be terse. Output exactly the requested format.',
6328
- },
6344
+ }),
6329
6345
  });
6330
6346
  for await (const msg of stream) {
6331
6347
  if (msg.type === 'result') {
6348
+ if (msg.is_error) {
6349
+ const errorText = Array.isArray(msg.errors)
6350
+ ? (msg.errors ?? []).join('; ')
6351
+ : String(msg.result ?? '');
6352
+ if (looksLikeClaudeOneMillionContextError(errorText))
6353
+ applyOneMillionContextRecovery();
6354
+ return '';
6355
+ }
6332
6356
  result = msg.result ?? '';
6333
6357
  }
6334
6358
  }
6335
6359
  return result;
6336
6360
  }
6337
- catch {
6361
+ catch (err) {
6362
+ if (looksLikeClaudeOneMillionContextError(err))
6363
+ applyOneMillionContextRecovery();
6338
6364
  return '';
6339
6365
  }
6340
6366
  };
@@ -15414,7 +15440,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15414
15440
  <div class="tab-pane active" id="tab-settings-general">
15415
15441
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
15416
15442
  <p style="color:var(--text-muted);margin:0">Manage API keys and configuration. Changes are saved to <code>~/.clementine/.env</code>.</p>
15417
- <button class="btn-sm" style="white-space:nowrap;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary);padding:6px 12px;border-radius:6px;cursor:pointer" onclick="restartDashboard()">Restart Dashboard</button>
15443
+ <button class="btn-sm btn-primary" style="white-space:nowrap;padding:6px 12px;border-radius:6px;cursor:pointer" onclick="restartDaemonFromDashboard()">Restart Clementine</button>
15418
15444
  </div>
15419
15445
  <div id="budget-health-content" style="margin-bottom:16px"><div class="empty-state">Loading budget health...</div></div>
15420
15446
  <div id="settings-content"><div class="empty-state">Loading settings...</div></div>
@@ -15579,8 +15605,8 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15579
15605
  <div class="card" style="margin-bottom:16px">
15580
15606
  <div class="card-header">Diagnostics &amp; maintenance</div>
15581
15607
  <div class="card-body" style="padding:16px;display:flex;gap:8px;flex-wrap:wrap">
15608
+ <button class="btn-sm btn-primary" onclick="restartDaemonFromDashboard()">Restart Clementine</button>
15582
15609
  <button class="btn-sm" onclick="restartDashboard()">Restart Dashboard</button>
15583
- <button class="btn-sm" onclick="if(confirm('Restart the daemon? Active sessions drain first.')) apiPost('/api/restart')">Restart Daemon</button>
15584
15610
  <button class="btn-sm" onclick="apiFetch('/api/doctor').then(function(r){return r.text()}).then(function(t){alert(t)})">Run Doctor</button>
15585
15611
  <button class="btn-sm" onclick="apiFetch('/api/version').then(function(r){return r.json()}).then(function(d){alert('Version: '+(d.version||'?')+'\\nNode: '+(d.node||'?'))})">Build info</button>
15586
15612
  </div>
@@ -17633,6 +17659,7 @@ async function apiPost(url) {
17633
17659
  if (d.ok) toast(d.message, 'success');
17634
17660
  else toast(d.error || 'Error', 'error');
17635
17661
  setTimeout(refreshAll, 1000);
17662
+ return d;
17636
17663
  } catch(e) { toast(String(e), 'error'); }
17637
17664
  }
17638
17665
  async function apiJson(method, url, body) {
@@ -17656,9 +17683,84 @@ async function apiDelete(url) {
17656
17683
  if (d.ok) toast(d.message, 'success');
17657
17684
  else toast(d.error || 'Error', 'error');
17658
17685
  setTimeout(refreshAll, 500);
17686
+ return d;
17659
17687
  } catch(e) { toast(String(e), 'error'); }
17660
17688
  }
17661
17689
 
17690
+ function settingRequiresDaemonRestart(key) {
17691
+ if (!key) return true;
17692
+ if (key === 'COMPOSIO_API_KEY' || key === 'COMPOSIO_USER_ID') return false;
17693
+ if (key.indexOf('ASSISTANT_') === 0) return false;
17694
+ return true;
17695
+ }
17696
+
17697
+ function renderRestartRequiredBanner() {
17698
+ var reason = '';
17699
+ try { reason = localStorage.getItem('clem-restart-required') || ''; } catch(e) { reason = ''; }
17700
+ var existing = document.getElementById('restart-required-banner');
17701
+ if (!reason) {
17702
+ if (existing) existing.remove();
17703
+ return;
17704
+ }
17705
+ if (!existing) {
17706
+ existing = document.createElement('div');
17707
+ existing.id = 'restart-required-banner';
17708
+ existing.style.cssText = 'position:fixed;left:18px;right:18px;bottom:18px;z-index:9999;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);box-shadow:0 8px 28px rgba(0,0,0,0.28);padding:12px 14px;color:var(--text-primary)';
17709
+ document.body.appendChild(existing);
17710
+ }
17711
+ existing.innerHTML = '<div style="min-width:220px;flex:1"><div style="font-weight:700;font-size:13px">Restart required</div>'
17712
+ + '<div style="font-size:12px;color:var(--text-secondary);margin-top:2px">' + esc(reason) + '</div></div>'
17713
+ + '<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">'
17714
+ + '<button class="btn-sm btn-primary" onclick="restartDaemonFromDashboard()">Restart Clementine</button>'
17715
+ + '<button class="btn-sm" onclick="dismissRestartRequiredBanner()">Later</button>'
17716
+ + '</div>';
17717
+ }
17718
+
17719
+ function markRestartRequired(reason) {
17720
+ var msg = reason || 'This change needs a Clementine restart before the daemon and channel workers use it.';
17721
+ try { localStorage.setItem('clem-restart-required', msg); } catch(e) { /* ignore */ }
17722
+ renderRestartRequiredBanner();
17723
+ }
17724
+
17725
+ function clearRestartRequired() {
17726
+ try { localStorage.removeItem('clem-restart-required'); } catch(e) { /* ignore */ }
17727
+ var existing = document.getElementById('restart-required-banner');
17728
+ if (existing) existing.remove();
17729
+ }
17730
+
17731
+ function dismissRestartRequiredBanner() {
17732
+ var existing = document.getElementById('restart-required-banner');
17733
+ if (existing) existing.remove();
17734
+ }
17735
+
17736
+ async function restartDaemonFromDashboard(skipConfirm) {
17737
+ if (!skipConfirm && !confirm('Restart Clementine now? Active work may pause briefly while the daemon reloads.')) return;
17738
+ toast('Restarting Clementine...', 'info');
17739
+ try {
17740
+ var r = await apiFetch('/api/restart', { method: 'POST' });
17741
+ var d = {};
17742
+ try { d = await r.json(); } catch(e) { d = {}; }
17743
+ if (!r.ok || d.error) {
17744
+ var err = String(d.error || 'Restart failed');
17745
+ if (/not running/i.test(err)) {
17746
+ var launch = await apiFetch('/api/launch', { method: 'POST' });
17747
+ var launchData = await launch.json();
17748
+ if (!launch.ok || launchData.error) throw new Error(launchData.error || 'Launch failed');
17749
+ clearRestartRequired();
17750
+ toast('Clementine started', 'success');
17751
+ setTimeout(refreshAll, 2000);
17752
+ return;
17753
+ }
17754
+ throw new Error(err);
17755
+ }
17756
+ clearRestartRequired();
17757
+ toast('Clementine restart requested', 'success');
17758
+ setTimeout(refreshAll, 2500);
17759
+ } catch(e) {
17760
+ toast('Restart failed: ' + String(e), 'error');
17761
+ }
17762
+ }
17763
+
17662
17764
  // ── Status + Overview ─────────────────────
17663
17765
  let lastStatusData = {};
17664
17766
  async function refreshStatus(preloaded) {
@@ -18809,6 +18911,7 @@ async function saveHeartbeatControl() {
18809
18911
  if (!r.ok || d.error) throw new Error(d.error || 'Save failed');
18810
18912
  if (statusEl) { statusEl.textContent = 'Saved. Restart daemon for schedule changes.'; statusEl.style.color = 'var(--green)'; }
18811
18913
  toast('Heartbeat controls saved', 'success');
18914
+ markRestartRequired('Heartbeat control changes need a Clementine restart before the schedule uses them.');
18812
18915
  } catch(e) {
18813
18916
  if (statusEl) { statusEl.textContent = 'Save failed'; statusEl.style.color = 'var(--red)'; }
18814
18917
  toast(String(e), 'error');
@@ -20071,13 +20174,15 @@ async function postBudgetAction(url, body) {
20071
20174
  }
20072
20175
 
20073
20176
  async function applySafeBudgetPreset() {
20074
- await postBudgetAction('/api/budgets/safe', {});
20177
+ var d = await postBudgetAction('/api/budgets/safe', {});
20178
+ if (d && d.ok) markRestartRequired('Safe Recovery changed spend/context settings. Restart Clementine to apply them to chat and background workers.');
20075
20179
  refreshSettings();
20076
20180
  }
20077
20181
 
20078
20182
  async function applyBudgetPreset(preset) {
20079
20183
  if (preset === 'uncapped' && !confirm('Remove all spend caps? Clementine can still hit account limits or credits if a job runs long.')) return;
20080
- await postBudgetAction('/api/budgets/preset', { preset: preset });
20184
+ var d = await postBudgetAction('/api/budgets/preset', { preset: preset });
20185
+ if (d && d.ok) markRestartRequired('Spend guard changes need a Clementine restart before running workers use the new caps.');
20081
20186
  refreshSettings();
20082
20187
  }
20083
20188
 
@@ -20090,12 +20195,14 @@ async function saveBudgetCap(key) {
20090
20195
  toast('Budget must be a non-negative dollar amount. Use 0 for no cap.', 'error');
20091
20196
  return;
20092
20197
  }
20093
- await postBudgetAction('/api/budgets/set', { key: key, value: value });
20198
+ var d = await postBudgetAction('/api/budgets/set', { key: key, value: value });
20199
+ if (d && d.ok) markRestartRequired('Budget cap changes need a Clementine restart before running workers use the new value.');
20094
20200
  refreshSettings();
20095
20201
  }
20096
20202
 
20097
20203
  async function setBudgetContextMode(mode) {
20098
- await postBudgetAction('/api/budgets/1m', { mode: mode });
20204
+ var d = await postBudgetAction('/api/budgets/1m', { mode: mode });
20205
+ if (d && d.ok) markRestartRequired('1M context changes need a Clementine restart before new Claude calls use the setting.');
20099
20206
  refreshSettings();
20100
20207
  }
20101
20208
 
@@ -20105,7 +20212,10 @@ async function forceBudgetOneMillion() {
20105
20212
  }
20106
20213
 
20107
20214
  async function applyBudgetDoctorFix() {
20108
- await postBudgetAction('/api/budgets/doctor-fix', {});
20215
+ var d = await postBudgetAction('/api/budgets/doctor-fix', {});
20216
+ if (d && d.ok && d.result && d.result.changed && d.result.changed.length) {
20217
+ markRestartRequired('Doctor Fix changed Clementine configuration. Restart Clementine to apply the fixes.');
20218
+ }
20109
20219
  refreshSettings();
20110
20220
  }
20111
20221
 
@@ -20209,8 +20319,7 @@ async function refreshSettings() {
20209
20319
  + '</div></div>';
20210
20320
 
20211
20321
  html += '<div style="padding:12px;color:var(--text-muted);font-size:12px">'
20212
- + '<strong>Note:</strong> Changes to API keys require a daemon restart to take effect. '
20213
- + 'Use <code>clementine restart</code> after updating channel tokens.'
20322
+ + '<strong>Note:</strong> Changes that require a daemon restart show a Restart Clementine prompt here in the dashboard.'
20214
20323
  + '</div>';
20215
20324
  container.innerHTML = html;
20216
20325
 
@@ -20273,8 +20382,11 @@ async function toggleSetting(el) {
20273
20382
  async function saveSettingValue(key, value) {
20274
20383
  var statusEl = document.getElementById('setting-' + key + '-status');
20275
20384
  try {
20276
- await apiJson('PUT', '/api/settings/' + encodeURIComponent(key), { value: value });
20385
+ var result = await apiJson('PUT', '/api/settings/' + encodeURIComponent(key), { value: value });
20277
20386
  if (statusEl) { statusEl.textContent = 'Saved'; statusEl.style.color = 'var(--green)'; setTimeout(function(){ statusEl.textContent = ''; }, 2000); }
20387
+ if (result && result.ok && settingRequiresDaemonRestart(key)) {
20388
+ markRestartRequired(key + ' changed. Restart Clementine so the daemon and channel workers use the new value.');
20389
+ }
20278
20390
  } catch(e) {
20279
20391
  if (statusEl) { statusEl.textContent = 'Error'; statusEl.style.color = 'var(--red)'; }
20280
20392
  }
@@ -20299,8 +20411,11 @@ async function saveAssistantPreferences() {
20299
20411
  async function removeSetting(key) {
20300
20412
  if (!confirm('Remove ' + key + ' from .env?')) return;
20301
20413
  try {
20302
- await apiDelete('/api/settings/' + encodeURIComponent(key));
20414
+ var result = await apiDelete('/api/settings/' + encodeURIComponent(key));
20303
20415
  toast(key + ' removed', 'success');
20416
+ if (result && result.ok && settingRequiresDaemonRestart(key)) {
20417
+ markRestartRequired(key + ' was removed. Restart Clementine so running workers stop using the old value.');
20418
+ }
20304
20419
  refreshSettings();
20305
20420
  } catch(e) { toast('Failed: ' + e, 'error'); }
20306
20421
  }
@@ -20330,8 +20445,11 @@ async function addCustomEnv() {
20330
20445
  if (!key || !value) { toast('Both key and value are required', 'error'); return; }
20331
20446
  if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) { toast('Invalid key format — use UPPER_SNAKE_CASE', 'error'); return; }
20332
20447
  try {
20333
- await apiJson('PUT', '/api/settings/' + encodeURIComponent(key), { value: value });
20448
+ var result = await apiJson('PUT', '/api/settings/' + encodeURIComponent(key), { value: value });
20334
20449
  toast(key + ' added', 'success');
20450
+ if (result && result.ok && settingRequiresDaemonRestart(key)) {
20451
+ markRestartRequired(key + ' was added. Restart Clementine so the daemon and channel workers can use it.');
20452
+ }
20335
20453
  keyInput.value = '';
20336
20454
  valInput.value = '';
20337
20455
  refreshSettings();
@@ -27701,6 +27819,7 @@ async function refreshSalesforce() {
27701
27819
 
27702
27820
  // ── Initial load — single batch request instead of 12+ parallel fetches ──
27703
27821
  (async function initDashboard() {
27822
+ renderRestartRequiredBanner();
27704
27823
  try {
27705
27824
  var r = await apiFetch('/api/init');
27706
27825
  var d = await r.json();
@@ -27772,6 +27891,7 @@ try {
27772
27891
  if (currentPage === 'home') refreshSessions();
27773
27892
  }
27774
27893
  if (evt.type === 'daemon_restarted') {
27894
+ clearRestartRequired();
27775
27895
  toast('Daemon restarted \u2014 refreshing data...', 'info');
27776
27896
  setTimeout(function() { refreshAll(); }, 1500);
27777
27897
  }
package/dist/config.d.ts CHANGED
@@ -15,6 +15,17 @@ export type OneMillionContextMode = 'auto' | 'off' | 'on';
15
15
  export declare const CLEMENTINE_1M_CONTEXT_MODE: OneMillionContextMode;
16
16
  export declare function currentOneMillionContextMode(): OneMillionContextMode;
17
17
  export declare function claudeCodeDisableOneMillionForModel(model: string | null | undefined, mode?: OneMillionContextMode): '1' | '0' | undefined;
18
+ export declare function looksLikeClaudeOneMillionContextError(value: unknown): boolean;
19
+ export declare function applyOneMillionContextRecovery(baseDir?: string): void;
20
+ export declare function claudeOneMillionEnvForModel(model: string | null | undefined, mode?: OneMillionContextMode): Record<string, string>;
21
+ type ClaudeSdkOptionsLike = {
22
+ model?: string;
23
+ env?: Record<string, string | undefined>;
24
+ betas?: unknown[];
25
+ mcpServers?: Record<string, unknown>;
26
+ [key: string]: unknown;
27
+ };
28
+ export declare function normalizeClaudeSdkOptionsForOneMillionContext<T extends ClaudeSdkOptionsLike>(options: T): T;
18
29
  export declare function normalizeClaudeModelForOneMillionContext(model: string, mode?: OneMillionContextMode): string;
19
30
  export declare function usesOneMillionContext(model: string | null | undefined, mode?: OneMillionContextMode): boolean;
20
31
  /**
@@ -196,4 +207,5 @@ export declare function getCredential(ref: string): string | null;
196
207
  export declare function setCredential(ref: string, value: string): void;
197
208
  /** List known credential refs (not their values) for dashboard display. */
198
209
  export declare function listCredentialRefs(): string[];
210
+ export {};
199
211
  //# sourceMappingURL=config.d.ts.map
package/dist/config.js CHANGED
@@ -88,6 +88,82 @@ export function claudeCodeDisableOneMillionForModel(model, mode = currentOneMill
88
88
  return '0';
89
89
  return modelFamily(model) === 'opus' ? undefined : '1';
90
90
  }
91
+ function upsertRuntimeEnvValue(baseDir, key, value) {
92
+ const envPath = path.join(baseDir, '.env');
93
+ let text = '';
94
+ if (existsSync(envPath)) {
95
+ text = readFileSync(envPath, 'utf-8');
96
+ }
97
+ else {
98
+ fs.mkdirSync(baseDir, { recursive: true });
99
+ }
100
+ const line = `${key}=${value}`;
101
+ const re = new RegExp(`^${key}=.*$`, 'm');
102
+ const next = re.test(text)
103
+ ? text.replace(re, line)
104
+ : `${text}${text && !text.endsWith('\n') ? '\n' : ''}${line}\n`;
105
+ fs.writeFileSync(envPath, next, { mode: 0o600 });
106
+ }
107
+ export function looksLikeClaudeOneMillionContextError(value) {
108
+ const text = String(value ?? '');
109
+ return /extra usage.*1m context|1m context.*extra usage|context-1m|1m.*extra usage|requires?.*1m/i.test(text);
110
+ }
111
+ export function applyOneMillionContextRecovery(baseDir = BASE_DIR) {
112
+ process.env.CLEMENTINE_1M_CONTEXT_MODE = 'off';
113
+ process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
114
+ try {
115
+ upsertRuntimeEnvValue(baseDir, 'CLEMENTINE_1M_CONTEXT_MODE', 'off');
116
+ upsertRuntimeEnvValue(baseDir, 'CLAUDE_CODE_DISABLE_1M_CONTEXT', '1');
117
+ }
118
+ catch {
119
+ // Runtime env is already safe. Persisting is best-effort because this path
120
+ // is often called while handling an SDK failure.
121
+ }
122
+ }
123
+ export function claudeOneMillionEnvForModel(model, mode = currentOneMillionContextMode()) {
124
+ const disableValue = claudeCodeDisableOneMillionForModel(model, mode);
125
+ return {
126
+ CLEMENTINE_1M_CONTEXT_MODE: mode,
127
+ ...(disableValue !== undefined ? { CLAUDE_CODE_DISABLE_1M_CONTEXT: disableValue } : {}),
128
+ };
129
+ }
130
+ export function normalizeClaudeSdkOptionsForOneMillionContext(options) {
131
+ const rawModel = typeof options.model === 'string' ? options.model : '';
132
+ const model = rawModel ? normalizeClaudeModelForOneMillionContext(rawModel) : rawModel;
133
+ const oneMillionEnv = claudeOneMillionEnvForModel(model || rawModel || null);
134
+ const disableValue = oneMillionEnv.CLAUDE_CODE_DISABLE_1M_CONTEXT;
135
+ const next = {
136
+ ...options,
137
+ ...(rawModel ? { model } : {}),
138
+ env: { ...(options.env ?? {}), ...oneMillionEnv },
139
+ ...(disableValue === '1' ? { betas: [] } : {}),
140
+ };
141
+ if (options.mcpServers && typeof options.mcpServers === 'object') {
142
+ const servers = {};
143
+ for (const [name, server] of Object.entries(options.mcpServers)) {
144
+ if (server && typeof server === 'object' && !Array.isArray(server)) {
145
+ const serverObj = server;
146
+ const supportsEnv = serverObj.type === 'stdio' || 'env' in serverObj;
147
+ if (!supportsEnv) {
148
+ servers[name] = server;
149
+ continue;
150
+ }
151
+ const serverEnv = serverObj.env && typeof serverObj.env === 'object' && !Array.isArray(serverObj.env)
152
+ ? serverObj.env
153
+ : {};
154
+ servers[name] = {
155
+ ...serverObj,
156
+ env: { ...serverEnv, ...oneMillionEnv },
157
+ };
158
+ }
159
+ else {
160
+ servers[name] = server;
161
+ }
162
+ }
163
+ next.mcpServers = servers;
164
+ }
165
+ return next;
166
+ }
91
167
  export function normalizeClaudeModelForOneMillionContext(model, mode = currentOneMillionContextMode()) {
92
168
  if (mode === 'on')
93
169
  return model;
package/dist/index.js CHANGED
@@ -626,14 +626,32 @@ async function asyncMain() {
626
626
  const llmCall = async (prompt) => {
627
627
  try {
628
628
  let result = '';
629
- const stream = query({ prompt, options: { model: 'claude-haiku-4-5-20251001', maxTurns: 1, systemPrompt: 'You are a memory consolidation assistant. Be concise.' } });
629
+ const stream = query({
630
+ prompt,
631
+ options: config.normalizeClaudeSdkOptionsForOneMillionContext({
632
+ model: 'claude-haiku-4-5-20251001',
633
+ maxTurns: 1,
634
+ systemPrompt: 'You are a memory consolidation assistant. Be concise.',
635
+ }),
636
+ });
630
637
  for await (const msg of stream) {
631
- if (msg.type === 'result')
638
+ if (msg.type === 'result') {
639
+ if (msg.is_error) {
640
+ const errorText = Array.isArray(msg.errors)
641
+ ? msg.errors.join('; ')
642
+ : String(msg.result ?? '');
643
+ if (config.looksLikeClaudeOneMillionContextError(errorText))
644
+ config.applyOneMillionContextRecovery();
645
+ return '';
646
+ }
632
647
  result = msg.result ?? '';
648
+ }
633
649
  }
634
650
  return result;
635
651
  }
636
- catch {
652
+ catch (err) {
653
+ if (config.looksLikeClaudeOneMillionContextError(err))
654
+ config.applyOneMillionContextRecovery();
637
655
  return '';
638
656
  }
639
657
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.16",
3
+ "version": "1.18.18",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",