clementine-agent 1.18.19 → 1.18.21

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.
Files changed (37) hide show
  1. package/README.md +17 -0
  2. package/dist/agent/action-enforcer.d.ts +29 -0
  3. package/dist/agent/action-enforcer.js +120 -0
  4. package/dist/agent/assistant.d.ts +14 -0
  5. package/dist/agent/assistant.js +190 -35
  6. package/dist/agent/auto-update.js +46 -2
  7. package/dist/agent/local-turn.d.ts +16 -0
  8. package/dist/agent/local-turn.js +54 -1
  9. package/dist/agent/route-classifier.d.ts +1 -0
  10. package/dist/agent/route-classifier.js +30 -3
  11. package/dist/agent/toolsets.d.ts +14 -0
  12. package/dist/agent/toolsets.js +68 -0
  13. package/dist/brain/ingestion-pipeline.d.ts +7 -0
  14. package/dist/brain/ingestion-pipeline.js +107 -21
  15. package/dist/channels/discord.js +38 -7
  16. package/dist/channels/telegram.js +5 -6
  17. package/dist/cli/dashboard.js +112 -6
  18. package/dist/cli/index.js +174 -0
  19. package/dist/cli/ingest.js +8 -2
  20. package/dist/gateway/context-hygiene.d.ts +17 -0
  21. package/dist/gateway/context-hygiene.js +31 -0
  22. package/dist/gateway/heartbeat-scheduler.d.ts +20 -0
  23. package/dist/gateway/heartbeat-scheduler.js +27 -10
  24. package/dist/gateway/router.d.ts +8 -1
  25. package/dist/gateway/router.js +326 -12
  26. package/dist/gateway/turn-ledger.d.ts +32 -0
  27. package/dist/gateway/turn-ledger.js +55 -0
  28. package/dist/memory/embeddings.d.ts +2 -0
  29. package/dist/memory/embeddings.js +8 -1
  30. package/dist/memory/store.d.ts +88 -1
  31. package/dist/memory/store.js +349 -18
  32. package/dist/memory/write-queue.d.ts +16 -0
  33. package/dist/memory/write-queue.js +5 -0
  34. package/dist/tools/shared.d.ts +89 -0
  35. package/dist/types.d.ts +11 -0
  36. package/package.json +1 -1
  37. package/scripts/postinstall.js +56 -6
@@ -21,7 +21,7 @@ import { detectFrustrationSignals, detectRepeatedTopics } from './insight-engine
21
21
  import { DEFAULT_CHANNEL_CAPABILITIES } from '../types.js';
22
22
  import { enforceToolPermissions, getSecurityPrompt, getHeartbeatSecurityPrompt, getCronSecurityPrompt, getHeartbeatDisallowedTools, logToolUse, setProfileTier, setProfileAllowedTools, setAgentDir, setSendPolicy, setInteractionSource, logAuditJsonl, } from './hooks.js';
23
23
  import { scanner } from '../security/scanner.js';
24
- import { agentWorkingMemoryFile, listAllGoals } from '../tools/shared.js';
24
+ import { agentWorkingMemoryFile, capOutput, listAllGoals } from '../tools/shared.js';
25
25
  import { AgentManager } from './agent-manager.js';
26
26
  import { extractLinks } from './link-extractor.js';
27
27
  import { StallGuard } from './stall-guard.js';
@@ -33,6 +33,8 @@ import { searchSkills as searchSkillsSync } from './skill-extractor.js';
33
33
  import { classifyIntent, getStrategyGuidance } from './intent-classifier.js';
34
34
  import { getEventLog } from './session-event-log.js';
35
35
  import { routeToolSurface, TOOL_SURFACE_HARD_LIMIT, TOOL_SURFACE_WARN_THRESHOLD } from './tool-router.js';
36
+ import { isRestrictedToolset, toolsetAllowsLocalWrites } from './toolsets.js';
37
+ import { looksLikeApprovalPrompt } from './local-turn.js';
36
38
  import { decideTurn } from './turn-policy.js';
37
39
  import { loadClementineJson } from '../config/clementine-json.js';
38
40
  import { isCreditBalanceError, markBackgroundCreditBlocked } from '../gateway/credit-guard.js';
@@ -294,9 +296,23 @@ const query = ((args) => {
294
296
  }
295
297
  return rawQuery(args);
296
298
  });
299
+ function parseMemoryTimestampMs(value) {
300
+ const text = String(value ?? '').trim();
301
+ if (!text)
302
+ return NaN;
303
+ // SQLite datetime('now') returns UTC as "YYYY-MM-DD HH:mm:ss" with no zone.
304
+ // Parse it explicitly as UTC so summaries don't appear hours in the future.
305
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text)) {
306
+ return Date.parse(`${text.replace(' ', 'T')}Z`);
307
+ }
308
+ return Date.parse(text);
309
+ }
297
310
  /** Format a millisecond duration as a human-friendly "X ago" string. */
298
- function formatTimeAgo(ms) {
299
- const minutes = Math.floor(ms / 60_000);
311
+ export function formatTimeAgo(ms) {
312
+ const safeMs = Number.isFinite(ms) ? Math.max(0, ms) : 0;
313
+ if (safeMs < 60_000)
314
+ return 'just now';
315
+ const minutes = Math.floor(safeMs / 60_000);
300
316
  if (minutes < 60)
301
317
  return `${minutes}m ago`;
302
318
  const hours = Math.floor(minutes / 60);
@@ -311,6 +327,11 @@ function formatTimeAgo(ms) {
311
327
  const CONTEXT_GUARD_MIN_TOKENS = 16_000;
312
328
  /** Warn threshold — context is getting tight. */
313
329
  const CONTEXT_GUARD_WARN_TOKENS = 32_000;
330
+ const PENDING_CONTEXT_USER_MAX_CHARS = 1000;
331
+ const PENDING_CONTEXT_ASSISTANT_MAX_CHARS = 3000;
332
+ const CRON_PROGRESS_NOTES_MAX_CHARS = 2000;
333
+ const CRON_PROGRESS_PENDING_MAX_ITEMS = 20;
334
+ const CRON_PROGRESS_ITEM_MAX_CHARS = 300;
314
335
  /** Rotate SDK sessions before hidden resume history approaches the 200K cap. */
315
336
  const SESSION_ROTATE_INPUT_TOKENS = 140_000;
316
337
  /** Approximate context window sizes by model family. */
@@ -328,6 +349,12 @@ function getContextWindow(model) {
328
349
  }
329
350
  return 200_000; // safe default
330
351
  }
352
+ function capContextBlock(text, maxChars) {
353
+ return capOutput(String(text ?? ''), maxChars);
354
+ }
355
+ function capContextItem(text) {
356
+ return capContextBlock(text, CRON_PROGRESS_ITEM_MAX_CHARS).replace(/\s+/g, ' ').trim();
357
+ }
331
358
  function resultInputTokens(result) {
332
359
  let total = 0;
333
360
  const modelUsage = result.modelUsage;
@@ -343,6 +370,15 @@ function resultInputTokens(result) {
343
370
  export function looksLikeOneMillionContextError(value) {
344
371
  return looksLikeClaudeOneMillionContextError(value);
345
372
  }
373
+ export function oneMillionContextRecoveryMessage() {
374
+ return "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.";
375
+ }
376
+ export function looksLikeProviderApiErrorResponse(value) {
377
+ const text = String(value ?? '').trim();
378
+ return /^api error:/i.test(text)
379
+ || /^error:\s*api error:/i.test(text)
380
+ || looksLikeOneMillionContextError(text);
381
+ }
346
382
  export function looksLikeNoResponseRequested(value) {
347
383
  const text = String(value ?? '').trim();
348
384
  return /^no response requested\.?$/i.test(text);
@@ -1310,6 +1346,10 @@ export class PersonalAssistant {
1310
1346
  getExchangeCount(sessionKey) {
1311
1347
  return this.exchangeCounts.get(sessionKey) ?? 0;
1312
1348
  }
1349
+ hasRecentApprovalPrompt(sessionKey) {
1350
+ const lastAssistant = this.lastExchanges.get(sessionKey)?.at(-1)?.assistant ?? '';
1351
+ return looksLikeApprovalPrompt(lastAssistant);
1352
+ }
1313
1353
  getMemoryChunkCount() {
1314
1354
  if (!this.memoryStore)
1315
1355
  return 0;
@@ -1941,7 +1981,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1941
1981
  }
1942
1982
  // ── Build SDK Options ─────────────────────────────────────────────
1943
1983
  async buildOptions(opts = {}) {
1944
- const { isHeartbeat = false, cronTier = null, maxTurns = null, model = null, enableTeams = true, retrievalContext = '', profile = null, sessionKey = null, streaming = false, isPlanStep = false, isUnleashed = false, sourceOverride, disableAllTools = false, verboseLevel, abortController, effort, maxBudgetUsd, toolScopeText, thinking, outputFormat, stallGuard, intentClassification, turnPolicy, contextRoutingText, } = opts;
1984
+ const { isHeartbeat = false, cronTier = null, maxTurns = null, model = null, enableTeams = true, retrievalContext = '', profile = null, sessionKey = null, streaming = false, isPlanStep = false, isUnleashed = false, sourceOverride, disableAllTools = false, verboseLevel, abortController, effort, maxBudgetUsd, toolScopeText, thinking, outputFormat, stallGuard, intentClassification, turnPolicy, contextRoutingText, toolset = 'auto', } = opts;
1945
1985
  const isCron = cronTier !== null;
1946
1986
  const toolsDisabledForCall = disableAllTools || (isHeartbeat && !isCron);
1947
1987
  const promptScopeText = toolScopeText ?? '';
@@ -1992,7 +2032,27 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1992
2032
  const safeContextToolRoute = allowContextToolRoute && !contextToolRoute.fullSurface
1993
2033
  ? contextToolRoute
1994
2034
  : emptyToolRoute();
1995
- const toolRoute = mergeToolRoutes(promptToolRoute, mergeToolRoutes(safeProfileToolRoute, safeContextToolRoute));
2035
+ let toolRoute = mergeToolRoutes(promptToolRoute, mergeToolRoutes(safeProfileToolRoute, safeContextToolRoute));
2036
+ if (toolset === 'full') {
2037
+ toolRoute = {
2038
+ bundles: [],
2039
+ externalMcpServers: undefined,
2040
+ composioToolkits: undefined,
2041
+ inheritFullClaudeEnv: true,
2042
+ fullSurface: true,
2043
+ reason: 'full_surface',
2044
+ };
2045
+ }
2046
+ else if (isRestrictedToolset(toolset)) {
2047
+ toolRoute = {
2048
+ ...toolRoute,
2049
+ bundles: [],
2050
+ externalMcpServers: [],
2051
+ composioToolkits: [],
2052
+ inheritFullClaudeEnv: false,
2053
+ fullSurface: false,
2054
+ };
2055
+ }
1996
2056
  let allowedTools = [];
1997
2057
  const addAllowed = (...tools) => {
1998
2058
  for (const tool of tools) {
@@ -2012,9 +2072,13 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2012
2072
  const memoryNeeded = autonomousToolRun
2013
2073
  || retrievalContext.trim().length > 0
2014
2074
  || (turnPolicy?.retrievalTier !== undefined && turnPolicy.retrievalTier !== 'none');
2015
- const localReadNeeded = taskIntent || /\b(repo|repository|code|file|files|folder|directory|path|log|logs|config|read|show|grep|diff|search)\b/i.test(promptScopeLower);
2016
- const localWriteNeeded = taskIntent || /\b(write|edit|fix|implement|refactor|build|test|run|npm|git|commit|push|pull|deploy|install|configure)\b/i.test(promptScopeLower);
2017
- const adminNeeded = toolRoute.fullSurface || /\b(self[- ]?update|restart|daemon|doctor|env|credential|integration|setup|set up|configure|npm publish|publish to npm)\b/i.test(promptScopeLower);
2075
+ const localReadNeeded = taskIntent || toolset === 'diagnostic' || /\b(repo|repository|code|file|files|folder|directory|path|log|logs|config|read|show|grep|diff|search)\b/i.test(promptScopeLower);
2076
+ const diagnosticCommandNeeded = toolset === 'diagnostic'
2077
+ && /\b(run|test|npm|pnpm|yarn|node|git|logs?|tail|ps|status|diagnos(?:e|tic)|check)\b/i.test(promptScopeLower);
2078
+ const localWriteNeeded = diagnosticCommandNeeded
2079
+ || (toolsetAllowsLocalWrites(toolset) && (taskIntent || /\b(write|edit|fix|implement|refactor|build|test|run|npm|git|commit|push|pull|deploy|install|configure)\b/i.test(promptScopeLower)));
2080
+ const adminNeeded = toolRoute.fullSurface
2081
+ || (toolsetAllowsLocalWrites(toolset) && /\b(self[- ]?update|restart|daemon|doctor|env|credential|integration|setup|set up|configure|npm publish|publish to npm)\b/i.test(promptScopeLower));
2018
2082
  if (!toolsDisabledForCall) {
2019
2083
  if (toolRoute.fullSurface) {
2020
2084
  addAllowed('Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'WebSearch', 'WebFetch');
@@ -2023,8 +2087,12 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2023
2087
  else {
2024
2088
  if (localReadNeeded)
2025
2089
  addAllowed('Read', 'Glob', 'Grep');
2026
- if (localWriteNeeded)
2027
- addAllowed('Write', 'Edit', 'Bash');
2090
+ if (localWriteNeeded) {
2091
+ if (toolset === 'diagnostic')
2092
+ addAllowed('Bash');
2093
+ else
2094
+ addAllowed('Write', 'Edit', 'Bash');
2095
+ }
2028
2096
  if (toolRoute.bundles.includes('web_research') || toolRoute.bundles.includes('docs_lookup')) {
2029
2097
  addAllowed('WebSearch', 'WebFetch');
2030
2098
  }
@@ -2032,7 +2100,12 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2032
2100
  addClementineTools(CLEMENTINE_CORE_TOOL_NAMES);
2033
2101
  addClementineTools(CLEMENTINE_RELATIONSHIP_TOOL_NAMES);
2034
2102
  }
2035
- if (taskIntent || intentClassification?.type === 'correction') {
2103
+ const clementineMemoryWritesAllowed = toolset === 'auto'
2104
+ || toolset === 'full'
2105
+ || toolset === 'communications'
2106
+ || intentClassification?.type === 'feedback'
2107
+ || intentClassification?.type === 'correction';
2108
+ if ((taskIntent || intentClassification?.type === 'correction') && clementineMemoryWritesAllowed) {
2036
2109
  addClementineTools(CLEMENTINE_MEMORY_WRITE_TOOL_NAMES);
2037
2110
  addClementineTools(CLEMENTINE_WORKSPACE_TOOL_NAMES);
2038
2111
  }
@@ -2049,20 +2122,22 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2049
2122
  addClementineTools(CLEMENTINE_INTEGRATION_TOOL_NAMES);
2050
2123
  addClementineTools(CLEMENTINE_ADMIN_TOOL_NAMES);
2051
2124
  }
2052
- if (toolRoute.bundles.includes('email_outlook') || /\b(outlook|email|mailbox|inbox|calendar|follow-?up)\b/i.test(scopeText)) {
2125
+ if ((toolset === 'auto' || toolset === 'full' || toolset === 'communications')
2126
+ && (toolRoute.bundles.includes('email_outlook') || /\b(outlook|email|mailbox|inbox|calendar|follow-?up)\b/i.test(scopeText))) {
2053
2127
  addClementineTools(CLEMENTINE_COMM_TOOL_NAMES);
2054
2128
  }
2055
- if (toolRoute.bundles.includes('github') || toolRoute.bundles.includes('browser') || toolRoute.bundles.includes('web_research')) {
2129
+ if ((toolset === 'auto' || toolset === 'full')
2130
+ && (toolRoute.bundles.includes('github') || toolRoute.bundles.includes('browser') || toolRoute.bundles.includes('web_research'))) {
2056
2131
  addClementineTools(CLEMENTINE_RESEARCH_TOOL_NAMES);
2057
2132
  }
2058
- if (enableTeams) {
2133
+ if (enableTeams && (toolset === 'auto' || toolset === 'full')) {
2059
2134
  addAllowed('Task', 'Agent');
2060
2135
  addClementineTools(CLEMENTINE_TEAM_TOOL_NAMES);
2061
2136
  addClementineTools(CLEMENTINE_JOB_TOOL_NAMES);
2062
2137
  }
2063
2138
  }
2064
2139
  // Include local user scripts/plugins for task-like or explicit full-surface turns.
2065
- if (taskIntent || toolRoute.fullSurface || adminNeeded) {
2140
+ if (toolsetAllowsLocalWrites(toolset) && (taskIntent || toolRoute.fullSurface || adminNeeded)) {
2066
2141
  try {
2067
2142
  const toolsDir = path.join(BASE_DIR, 'tools');
2068
2143
  const pluginsDir = path.join(BASE_DIR, 'plugins');
@@ -2405,6 +2480,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2405
2480
  isolateClaudeConfig,
2406
2481
  inheritFullClaudeEnv: shouldInheritClaudeEnv,
2407
2482
  maxBudgetUsd: enforcedBudget,
2483
+ toolset,
2408
2484
  isCron,
2409
2485
  cronTier,
2410
2486
  isPlanStep,
@@ -2797,6 +2873,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2797
2873
  const projectOverride = options?.projectOverride;
2798
2874
  const verboseLevel = options?.verboseLevel;
2799
2875
  const abortController = options?.abortController;
2876
+ const toolset = options?.toolset ?? 'auto';
2800
2877
  const key = sessionKey ?? undefined;
2801
2878
  this._lastUserMessage = text;
2802
2879
  let sessionRotated = false;
@@ -2897,11 +2974,14 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2897
2974
  const exchanges = this.lastExchanges.get(key) ?? [];
2898
2975
  if (exchanges.length === 0 && this.memoryStore) {
2899
2976
  try {
2900
- const recentSummaries = this.memoryStore.getRecentSummaries(1);
2977
+ const recentSummaries = typeof this.memoryStore.getRecentSummariesForSession === 'function'
2978
+ ? this.memoryStore.getRecentSummariesForSession(key, 1)
2979
+ : this.memoryStore.getRecentSummaries(5).filter((s) => s.sessionKey === key).slice(0, 1);
2901
2980
  if (recentSummaries.length > 0) {
2902
2981
  const last = recentSummaries[0];
2903
- const ageMs = Date.now() - new Date(last.createdAt).getTime();
2904
- if (ageMs < 7 * 24 * 60 * 60 * 1000) { // within 7 days
2982
+ const createdAtMs = parseMemoryTimestampMs(last.createdAt);
2983
+ const ageMs = Date.now() - createdAtMs;
2984
+ if (Number.isFinite(ageMs) && ageMs >= -5 * 60_000 && ageMs < 7 * 24 * 60 * 60 * 1000) { // within 7 days
2905
2985
  const ago = formatTimeAgo(ageMs);
2906
2986
  effectivePrompt =
2907
2987
  `[Last conversation (${ago}):\n${last.summary.slice(0, 600)}]\n\n` +
@@ -2937,7 +3017,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2937
3017
  if (allPending.length > 0) {
2938
3018
  const contextLines = [];
2939
3019
  for (const ctx of allPending) {
2940
- contextLines.push(`[${ctx.user}]\n${ctx.assistant}`);
3020
+ const user = capContextBlock(ctx.user, PENDING_CONTEXT_USER_MAX_CHARS);
3021
+ const assistant = capContextBlock(ctx.assistant, PENDING_CONTEXT_ASSISTANT_MAX_CHARS);
3022
+ contextLines.push(`[${user}]\n${assistant}`);
2941
3023
  }
2942
3024
  effectivePrompt =
2943
3025
  `[Since we last talked, you did some background work. Naturally mention what happened — lead with anything that needs attention, briefly note routine completions. Don't dump raw tool calls or list job names. Be conversational.\nBackground:\n${contextLines.join('\n\n')}]\n\n${effectivePrompt}`;
@@ -2966,7 +3048,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2966
3048
  const effectiveMaxTurns = maxTurns ?? turnPolicy.maxTurns;
2967
3049
  const CHAT_TIMEOUT_MS = 30 * 60 * 1000;
2968
3050
  const guard = new StallGuard();
2969
- let [responseText, sessionId] = await this.runQuery(effectivePrompt, key, onText, model, profile, securityAnnotation, effectiveMaxTurns, projectOverride, onToolActivity, verboseLevel, abortController, guard, CHAT_TIMEOUT_MS, intent, turnPolicy);
3051
+ let [responseText, sessionId] = await this.runQuery(effectivePrompt, key, onText, model, profile, securityAnnotation, effectiveMaxTurns, projectOverride, onToolActivity, verboseLevel, abortController, guard, CHAT_TIMEOUT_MS, intent, turnPolicy, toolset);
2970
3052
  // If we got a context-length / prompt-too-long error, retry with a fresh session
2971
3053
  const errLower = responseText.toLowerCase();
2972
3054
  const isContextOverflow = errLower.includes('prompt is too long') ||
@@ -2987,12 +3069,12 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2987
3069
  `If this task involves pulling data for multiple entities, delegate each to a sub-agent using the Agent tool ` +
2988
3070
  `instead of calling data-heavy tools directly.\n\n${text}`;
2989
3071
  }
2990
- [responseText, sessionId] = await this.runQuery(retryPrompt, key, onText, model, profile, securityAnnotation, maxTurns, undefined, onToolActivity, verboseLevel, abortController, undefined, CHAT_TIMEOUT_MS, intent, turnPolicy);
3072
+ [responseText, sessionId] = await this.runQuery(retryPrompt, key, onText, model, profile, securityAnnotation, maxTurns, undefined, onToolActivity, verboseLevel, abortController, undefined, CHAT_TIMEOUT_MS, intent, turnPolicy, toolset);
2991
3073
  }
2992
3074
  // Track exchange count, timestamp, and last exchange.
2993
3075
  // Never store API error responses — they poison session history and create
2994
3076
  // a self-reinforcing loop where every subsequent request replays the errors.
2995
- const isApiError = responseText.startsWith('Error:') && responseText.includes('API Error:');
3077
+ const isApiError = looksLikeProviderApiErrorResponse(responseText);
2996
3078
  if (key && !isApiError) {
2997
3079
  this.exchangeCounts.set(key, (this.exchangeCounts.get(key) ?? 0) + 1);
2998
3080
  this.sessionTimestamps.set(key, new Date());
@@ -3081,7 +3163,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3081
3163
  // ── Run Query ─────────────────────────────────────────────────────
3082
3164
  static RATE_LIMIT_MAX_RETRIES = 3;
3083
3165
  static RATE_LIMIT_BACKOFF = [5000, 15000, 30000];
3084
- async runQuery(prompt, sessionKey, onText, model, profile, securityAnnotation, maxTurnsOverride, projectOverride, onToolActivity, verboseLevel, abortController, stallGuard, timeoutMs, intentClassification, turnPolicy) {
3166
+ async runQuery(prompt, sessionKey, onText, model, profile, securityAnnotation, maxTurnsOverride, projectOverride, onToolActivity, verboseLevel, abortController, stallGuard, timeoutMs, intentClassification, turnPolicy, toolset = 'auto') {
3085
3167
  // Parallelize context retrieval and project matching — they're independent
3086
3168
  // If a project override is set, skip auto-matching entirely
3087
3169
  const hasActiveSession = !!(sessionKey && this.sessions.has(sessionKey));
@@ -3188,6 +3270,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3188
3270
  intentClassification,
3189
3271
  turnPolicy: effectiveTurnPolicy,
3190
3272
  effort: effectiveTurnPolicy?.effort ?? intentClassification?.suggestedEffort,
3273
+ toolset,
3191
3274
  // Route destructive/admin/local write decisions from the direct user
3192
3275
  // request only. Retrieved memory may still contribute integration
3193
3276
  // continuity via contextRoutingText, but stale memories should not
@@ -3432,7 +3515,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3432
3515
  this.exchangeCounts.set(sessionKey, 0);
3433
3516
  this._compactedSessions.delete(sessionKey);
3434
3517
  }
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.");
3518
+ responseText = responseText || (oneMillionContextRecoveryMessage());
3436
3519
  }
3437
3520
  else if (lower.includes('rate') && lower.includes('limit')) {
3438
3521
  hitRateLimit = true;
@@ -3485,7 +3568,19 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3485
3568
  else if ('result' in result && result.result) {
3486
3569
  // Success: use SDK result text if streaming didn't capture a substantive response
3487
3570
  const sdkResult = result.result;
3488
- if (looksLikeContextThrashText(sdkResult)) {
3571
+ if (looksLikeOneMillionContextError(sdkResult)) {
3572
+ logger.warn({ sessionKey }, '1M context error surfaced as SDK result text — forcing recovery');
3573
+ applyOneMillionContextRecovery();
3574
+ if (sessionKey) {
3575
+ this.sessions.delete(sessionKey);
3576
+ this.exchangeCounts.set(sessionKey, 0);
3577
+ this._compactedSessions.delete(sessionKey);
3578
+ }
3579
+ responseText = oneMillionContextRecoveryMessage();
3580
+ if (onText)
3581
+ await onText(responseText);
3582
+ }
3583
+ else if (looksLikeContextThrashText(sdkResult)) {
3489
3584
  logger.warn({ sessionKey }, 'Autocompact thrashing surfaced as SDK result text — rotating session');
3490
3585
  preRotationSnapshot = {
3491
3586
  toolCalls: stallGuard?.getToolCalls() ?? [],
@@ -3563,7 +3658,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3563
3658
  this.exchangeCounts.set(sessionKey, 0);
3564
3659
  this._compactedSessions.delete(sessionKey);
3565
3660
  }
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.");
3661
+ responseText = responseText || (oneMillionContextRecoveryMessage());
3567
3662
  }
3568
3663
  else if (errStr.includes('rate') && (errStr.includes('limit') || errStr.includes('rate_limit'))) {
3569
3664
  hitRateLimit = true;
@@ -3960,18 +4055,20 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3960
4055
  *
3961
4056
  * No LLM call — uses buildLocalSummary for instant summarization.
3962
4057
  */
3963
- compactContext(sessionKey) {
3964
- const summary = this.buildLocalSummary(sessionKey);
4058
+ compactContext(sessionKey, reason = 'context_guard') {
4059
+ const summary = this.buildStructuredCompactionSummary(sessionKey);
3965
4060
  if (!summary)
3966
- return;
4061
+ return null;
3967
4062
  // Build compaction block for working memory
3968
4063
  const exchangeCount = this.exchangeCounts.get(sessionKey) ?? 0;
4064
+ const parentSessionId = this.sessions.get(sessionKey) ?? null;
3969
4065
  const COMPACTION_START = '<!-- COMPACTION_START -->';
3970
4066
  const COMPACTION_END = '<!-- COMPACTION_END -->';
3971
4067
  const compactionBlock = [
3972
4068
  COMPACTION_START,
3973
4069
  `## Session Compaction (auto-generated)`,
3974
4070
  `Session ${sessionKey} compacted at ${exchangeCount} exchanges.`,
4071
+ `Reason: ${reason}.`,
3975
4072
  ``,
3976
4073
  summary,
3977
4074
  ``,
@@ -4009,6 +4106,20 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4009
4106
  catch {
4010
4107
  // If working memory write fails, still rotate — better than hitting the hard limit
4011
4108
  }
4109
+ try {
4110
+ this.memoryStore?.saveSessionSummary?.(sessionKey, summary, exchangeCount);
4111
+ this.memoryStore?.recordSessionLineage?.({
4112
+ sessionKey,
4113
+ parentSessionId,
4114
+ childSessionId: null,
4115
+ reason,
4116
+ summary,
4117
+ exchangeCount,
4118
+ });
4119
+ }
4120
+ catch {
4121
+ // Durable lineage is helpful, not required for compaction safety.
4122
+ }
4012
4123
  // Rotate session — clear the session ID so next query starts fresh
4013
4124
  // The working memory summary will provide continuity
4014
4125
  this.sessions.delete(sessionKey);
@@ -4017,6 +4128,14 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4017
4128
  this.sessionTimestamps.delete(sessionKey);
4018
4129
  this.stallNudges.delete(sessionKey);
4019
4130
  this.saveSessions();
4131
+ return summary;
4132
+ }
4133
+ compactSessionForGateway(sessionKey, reason = 'gateway_preflight') {
4134
+ const exchangeCount = this.exchangeCounts.get(sessionKey) ?? 0;
4135
+ const summary = this.compactContext(sessionKey, reason);
4136
+ return summary
4137
+ ? { compacted: true, exchangeCount, summary, reason }
4138
+ : { compacted: false, exchangeCount, reason };
4020
4139
  }
4021
4140
  /**
4022
4141
  * Expire sessions inactive for more than 24 hours.
@@ -4038,7 +4157,39 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4038
4157
  * to avoid blocking the user's query.
4039
4158
  */
4040
4159
  buildLocalSummary(sessionKey) {
4041
- return this.buildLocalSummaryFromTurns(this.lastExchanges.get(sessionKey) ?? []);
4160
+ let exchanges = this.lastExchanges.get(sessionKey) ?? [];
4161
+ if (exchanges.length === 0 && this.memoryStore && typeof this.memoryStore.getTranscriptTail === 'function') {
4162
+ try {
4163
+ const recent = this.memoryStore.getTranscriptTail(sessionKey, 0, SESSION_EXCHANGE_HISTORY_SIZE * 2);
4164
+ exchanges = this.pairTranscriptTurns(recent ?? []);
4165
+ }
4166
+ catch {
4167
+ exchanges = [];
4168
+ }
4169
+ }
4170
+ return this.buildLocalSummaryFromTurns(exchanges);
4171
+ }
4172
+ buildStructuredCompactionSummary(sessionKey) {
4173
+ const exchanges = this.lastExchanges.get(sessionKey) ?? [];
4174
+ const summary = this.buildLocalSummary(sessionKey);
4175
+ if (!summary)
4176
+ return '';
4177
+ const latest = exchanges.at(-1);
4178
+ const lastUser = latest?.user
4179
+ ? latest.user.slice(0, 400).replace(/\s+/g, ' ')
4180
+ : '';
4181
+ const continuity = [
4182
+ '- Exact details remain in transcripts; use transcript_search before relying on this handoff for names, dates, IDs, files, or sent-message status.',
4183
+ '- Keep tool outputs bounded and prefer targeted reads over full log dumps.',
4184
+ lastUser ? `- Last visible user request: ${lastUser}` : '',
4185
+ ].filter(Boolean);
4186
+ return [
4187
+ '### Recent Conversation',
4188
+ summary,
4189
+ '',
4190
+ '### Continuity Notes',
4191
+ continuity.join('\n'),
4192
+ ].join('\n');
4042
4193
  }
4043
4194
  buildLocalSummaryFromTurns(turns, opts) {
4044
4195
  if (turns.length === 0)
@@ -4950,13 +5101,17 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4950
5101
  const progress = JSON.parse(fs.readFileSync(progressFile, 'utf-8'));
4951
5102
  const parts = [`## Previous Progress (run #${progress.runCount}, ${progress.lastRunAt})`];
4952
5103
  if (progress.completedItems?.length > 0) {
4953
- parts.push(`Completed: ${progress.completedItems.slice(-10).join(', ')}`);
5104
+ parts.push(`Completed: ${progress.completedItems.slice(-10).map(capContextItem).join(', ')}`);
4954
5105
  }
4955
5106
  if (progress.pendingItems?.length > 0) {
4956
- parts.push(`Pending: ${progress.pendingItems.join(', ')}`);
5107
+ const pendingItems = progress.pendingItems.slice(0, CRON_PROGRESS_PENDING_MAX_ITEMS).map(capContextItem);
5108
+ const suffix = progress.pendingItems.length > CRON_PROGRESS_PENDING_MAX_ITEMS
5109
+ ? ` (${progress.pendingItems.length - CRON_PROGRESS_PENDING_MAX_ITEMS} more omitted)`
5110
+ : '';
5111
+ parts.push(`Pending: ${pendingItems.join(', ')}${suffix}`);
4957
5112
  }
4958
5113
  if (progress.notes) {
4959
- parts.push(`Notes: ${progress.notes}`);
5114
+ parts.push(`Notes: ${capContextBlock(progress.notes, CRON_PROGRESS_NOTES_MAX_CHARS)}`);
4960
5115
  }
4961
5116
  progressContext = parts.join('\n') + '\n\n' +
4962
5117
  'Continue from where you left off. Use `cron_progress_write` at the end to save what you completed and what\'s pending.\n\n';
@@ -5978,8 +6133,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
5978
6133
  * so follow-up conversation has context.
5979
6134
  */
5980
6135
  injectContext(sessionKey, userText, assistantText) {
5981
- const trimmedUser = userText.slice(0, INJECTED_CONTEXT_MAX_CHARS);
5982
- const trimmedAssistant = assistantText.slice(0, INJECTED_CONTEXT_MAX_CHARS);
6136
+ const trimmedUser = capContextBlock(userText, INJECTED_CONTEXT_MAX_CHARS);
6137
+ const trimmedAssistant = capContextBlock(assistantText, INJECTED_CONTEXT_MAX_CHARS);
5983
6138
  // Add to in-memory exchange history
5984
6139
  const history = this.lastExchanges.get(sessionKey) ?? [];
5985
6140
  history.push({ user: trimmedUser, assistant: trimmedAssistant });
@@ -5,14 +5,41 @@
5
5
  * Source modifications from self-improve are tracked in ~/.clementine/ (not git),
6
6
  * so git pull is always clean. After pulling, source mods are reconciled.
7
7
  */
8
- import { execSync } from 'node:child_process';
9
- import { writeFileSync } from 'node:fs';
8
+ import { execFileSync, execSync } from 'node:child_process';
9
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import pino from 'pino';
12
12
  import { BASE_DIR } from '../config.js';
13
13
  import { reconcileSourceMods } from './source-mods.js';
14
14
  const logger = pino({ name: 'clementine.auto-update' });
15
15
  const SENTINEL_PATH = path.join(BASE_DIR, '.restart-sentinel.json');
16
+ function readDataEnv() {
17
+ const envPath = path.join(BASE_DIR, '.env');
18
+ if (!existsSync(envPath))
19
+ return {};
20
+ try {
21
+ return Object.fromEntries(readFileSync(envPath, 'utf-8')
22
+ .split(/\r?\n/)
23
+ .map((line) => line.trim())
24
+ .filter((line) => line && !line.startsWith('#') && line.includes('='))
25
+ .map((line) => {
26
+ const idx = line.indexOf('=');
27
+ return [line.slice(0, idx).trim(), line.slice(idx + 1).trim().replace(/^["']|["']$/g, '')];
28
+ }));
29
+ }
30
+ catch {
31
+ return {};
32
+ }
33
+ }
34
+ function flagEnabled(name, envFile) {
35
+ const raw = process.env[name] ?? envFile[name];
36
+ return /^(1|true|yes|on)$/i.test(String(raw ?? ''));
37
+ }
38
+ function shouldPrefetchEmbeddings() {
39
+ const envFile = readDataEnv();
40
+ return flagEnabled('CLEMENTINE_INSTALL_EMBEDDINGS', envFile)
41
+ || flagEnabled('CLEMENTINE_PREFETCH_EMBEDDINGS', envFile);
42
+ }
16
43
  /**
17
44
  * Check if upstream has new commits. Safe to call from cron — no side effects.
18
45
  */
@@ -121,6 +148,23 @@ export async function applyUpdate(pkgDir) {
121
148
  logger.error({ err }, 'Build failed after update');
122
149
  return { success: false, error: `Build failed after update: ${String(err)}` };
123
150
  }
151
+ // 4b. Optional embedding model prefetch. npm postinstall may run before
152
+ // the freshly pulled TypeScript has been built; this second pass uses the
153
+ // just-built CLI so repo updates and npm-style updates behave the same.
154
+ if (shouldPrefetchEmbeddings()) {
155
+ try {
156
+ execFileSync(process.execPath, [path.join(pkgDir, 'dist', 'cli', 'index.js'), 'memory', 'model', 'install'], {
157
+ cwd: pkgDir,
158
+ stdio: 'pipe',
159
+ env: { ...process.env, CLEMENTINE_HOME: BASE_DIR },
160
+ timeout: 10 * 60_000,
161
+ });
162
+ logger.info('Local embedding model prefetch succeeded after update');
163
+ }
164
+ catch (err) {
165
+ logger.warn({ err }, 'Local embedding model prefetch failed after update');
166
+ }
167
+ }
124
168
  // 5. Reconcile source modifications
125
169
  const reconcileResult = reconcileSourceMods(pkgDir);
126
170
  logger.info({
@@ -1,4 +1,5 @@
1
1
  import type { ClementineJson } from '../config/clementine-json.js';
2
+ import { type ToolsetName } from './toolsets.js';
2
3
  export type ProactivityMode = 'quiet' | 'balanced' | 'proactive' | 'operator';
3
4
  export type ResponseStyle = 'concise' | 'balanced' | 'detailed';
4
5
  export type ProgressVisibility = 'quiet' | 'normal' | 'detailed';
@@ -19,14 +20,29 @@ export type LocalTurnIntent = {
19
20
  kind: 'stop';
20
21
  } | {
21
22
  kind: 'status';
23
+ } | {
24
+ kind: 'last_action';
25
+ } | {
26
+ kind: 'compress_context';
27
+ } | {
28
+ kind: 'debug_status';
29
+ } | {
30
+ kind: 'toolset';
31
+ toolset: ToolsetName;
22
32
  } | {
23
33
  kind: 'preference_update';
24
34
  updates: AssistantExperienceUpdate;
25
35
  summary: string;
26
36
  };
37
+ export type ApprovalReply = true | false | 'always' | null;
27
38
  export declare function isStopRequest(text: string): boolean;
28
39
  export declare function isStatusRequest(text: string): boolean;
40
+ export declare function isLastActionRequest(text: string): boolean;
41
+ export declare function isCompressContextRequest(text: string): boolean;
42
+ export declare function isDebugStatusRequest(text: string): boolean;
29
43
  export declare function isTinyAcknowledgment(text: string): boolean;
44
+ export declare function detectApprovalReply(text: string): ApprovalReply;
45
+ export declare function looksLikeApprovalPrompt(text: string): boolean;
30
46
  export declare function detectLocalTurn(text: string): LocalTurnIntent;
31
47
  export declare function applyAssistantExperienceUpdate(cfg: ClementineJson, updates: AssistantExperienceUpdate): ClementineJson;
32
48
  //# sourceMappingURL=local-turn.d.ts.map
@@ -1,8 +1,10 @@
1
1
  import { isStandaloneGreeting } from './turn-policy.js';
2
+ import { normalizeToolsetName } from './toolsets.js';
2
3
  function normalize(text) {
3
4
  return text
4
5
  .trim()
5
6
  .toLowerCase()
7
+ .replace(/[‘’`]/g, "'")
6
8
  .replace(/[.!?]+$/g, '')
7
9
  .replace(/\s+/g, ' ');
8
10
  }
@@ -20,7 +22,31 @@ export function isStatusRequest(text) {
20
22
  const n = normalize(text);
21
23
  if (wordCount(n) > 8)
22
24
  return false;
23
- return /^(status|task status|deep status|progress|what'?s happening|what'?s going on|what are you doing|are you working|anything running|what'?s running|background status|check status|where are we)$/.test(n);
25
+ return /^(status|task status|deep status|progress|what'?s happening|what'?s going on|what are you doing|what are you working on|what are you running|are you working|anything running|what'?s runnin?g?(?: now| right now)?|what is runnin?g?(?: now| right now)?|background status|check status|where are we)$/.test(n);
26
+ }
27
+ export function isLastActionRequest(text) {
28
+ const n = normalize(text);
29
+ if (wordCount(n) > 10)
30
+ return false;
31
+ return /^(last action|last turn|what happened last turn|what did you do|did you do it|did that actually run|did you actually do it|why didn'?t you do it|why did that not run|what happened)$/.test(n);
32
+ }
33
+ export function isCompressContextRequest(text) {
34
+ const n = normalize(text);
35
+ if (wordCount(n) > 8)
36
+ return false;
37
+ return /^(compress context|compact context|compress session|compact session|context compact|context compress|save and reset context|reset context but keep memory)$/.test(n);
38
+ }
39
+ export function isDebugStatusRequest(text) {
40
+ const n = normalize(text);
41
+ if (wordCount(n) > 6)
42
+ return false;
43
+ return /^(debug|debug status|session debug|agent debug|diagnostics|show diagnostics)$/.test(n);
44
+ }
45
+ function parseToolsetRequest(text) {
46
+ const n = normalize(text);
47
+ const match = n.match(/^(?:set |switch |use |enable )?(?:toolset|tool set|tools mode|tool mode)(?: to|:)? ([a-z _-]+)$/)
48
+ ?? n.match(/^toolset ([a-z _-]+)$/);
49
+ return match ? normalizeToolsetName(match[1]) : null;
24
50
  }
25
51
  export function isTinyAcknowledgment(text) {
26
52
  const n = normalize(text);
@@ -28,6 +54,24 @@ export function isTinyAcknowledgment(text) {
28
54
  return false;
29
55
  return /^(thanks|thank you|thx|ty|nice|great|perfect|awesome|cool|ok|okay|sounds good|got it|makes sense|love it)$/.test(n);
30
56
  }
57
+ export function detectApprovalReply(text) {
58
+ const n = normalize(text);
59
+ if (wordCount(n) > 4)
60
+ return null;
61
+ if (/^(always)$/.test(n))
62
+ return 'always';
63
+ if (/^(no|nope|deny|denied|skip)$/.test(n))
64
+ return false;
65
+ if (/^(yes|y|yep|yeah|ok|okay|approve|approved|go|go ahead|do it|send it|perfect|sounds good|looks good|lgtm)$/.test(n)) {
66
+ return true;
67
+ }
68
+ return null;
69
+ }
70
+ export function looksLikeApprovalPrompt(text) {
71
+ const n = normalize(text);
72
+ return /\b(good to go|okay to send|ok to send|ready to send|should i send|want me to send|approve|confirm|fire it off)\b/.test(n)
73
+ || /\b(send|email|message|post|publish|delete|change|update|run|execute)\b[\s\S]{0,120}\?$/i.test(text.trim());
74
+ }
31
75
  function parseProactivity(text) {
32
76
  if (/\b(operator mode|operator)\b/i.test(text))
33
77
  return 'operator';
@@ -71,6 +115,15 @@ export function detectLocalTurn(text) {
71
115
  return { kind: 'stop' };
72
116
  if (isStatusRequest(text))
73
117
  return { kind: 'status' };
118
+ if (isLastActionRequest(text))
119
+ return { kind: 'last_action' };
120
+ if (isCompressContextRequest(text))
121
+ return { kind: 'compress_context' };
122
+ if (isDebugStatusRequest(text))
123
+ return { kind: 'debug_status' };
124
+ const toolset = parseToolsetRequest(text);
125
+ if (toolset)
126
+ return { kind: 'toolset', toolset };
74
127
  if (isStandaloneGreeting(text))
75
128
  return { kind: 'greeting' };
76
129
  if (isTinyAcknowledgment(text))