clementine-agent 1.18.9 → 1.18.11

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.
@@ -27,6 +27,11 @@ import { AgentManager } from './agent-manager.js';
27
27
  * SDK result; this function is for pre-flight planning only.
28
28
  */
29
29
  export declare function estimateTokens(text: string): number;
30
+ export declare function looksLikeContextThrashText(value: unknown): boolean;
31
+ export declare function contextThrashRecoveryNotice(): string;
32
+ export declare function buildContextThrashRecoveryPrompt(userRequest: string, priorFailureText?: string): string;
33
+ /** Autonomous jobs use this sentinel to mean "completed, but do not notify the owner." */
34
+ export declare function isAutonomousNothingOutput(response: string): boolean;
30
35
  export interface ProjectMeta {
31
36
  path: string;
32
37
  description?: string;
@@ -35,6 +35,7 @@ import { classifyIntent, getStrategyGuidance } from './intent-classifier.js';
35
35
  import { getEventLog } from './session-event-log.js';
36
36
  import { routeToolSurface, TOOL_SURFACE_WARN_THRESHOLD } from './tool-router.js';
37
37
  import { decideTurnPolicy } from './turn-policy.js';
38
+ import { loadClementineJson } from '../config/clementine-json.js';
38
39
  // ── Channel capabilities ────────────────────────────────────────────
39
40
  /** Map channel label to its capabilities so the agent adapts its responses. */
40
41
  function getChannelCapabilities(channel) {
@@ -172,6 +173,37 @@ export function estimateTokens(text) {
172
173
  return 0;
173
174
  return Math.ceil(text.length / 3.3);
174
175
  }
176
+ export function looksLikeContextThrashText(value) {
177
+ const text = String(value ?? '');
178
+ return /autocompact\s+is\s+thrashing|context\s+refilled\s+to\s+the\s+limit|refilled\s+to\s+the\s+limit\s+within/i.test(text);
179
+ }
180
+ export function contextThrashRecoveryNotice() {
181
+ return [
182
+ 'I hit a context-size recovery issue while working on that.',
183
+ 'I saved the request and reset the session so I can continue with smaller reads instead of repeating the same large-output path.',
184
+ ].join(' ');
185
+ }
186
+ export function buildContextThrashRecoveryPrompt(userRequest, priorFailureText = '') {
187
+ const parts = [
188
+ '[CONTEXT-THRASH RECOVERY]',
189
+ '',
190
+ 'The previous interactive attempt failed because tool output filled the context window and SDK autocompact thrashed. Continue the user request, but use a small diagnostic pass.',
191
+ '',
192
+ 'User request:',
193
+ userRequest,
194
+ '',
195
+ 'Recovery rules:',
196
+ '- Do not repeat broad reads, full log dumps, full JSON dumps, or unbounded API/list commands.',
197
+ '- Prefer status files, summaries, indexes, `rg`, `tail -80`, `head -80`, and `sed -n` slices.',
198
+ '- For cron or unleashed jobs, inspect only `status.json`, the tail of `progress.jsonl`, and the latest run preview first. Do not read full run logs unless a short slice identifies the exact file and range.',
199
+ '- Preserve the user intent. Identify what failed, what you changed or verified, and the next action.',
200
+ '- Finish with `TASK_COMPLETE:` followed by a concise user-facing summary.',
201
+ ];
202
+ if (priorFailureText.trim()) {
203
+ parts.push('', 'Prior failure excerpt:', priorFailureText.trim().slice(0, 1200));
204
+ }
205
+ return parts.join('\n');
206
+ }
175
207
  /**
176
208
  * Strip lone Unicode surrogates (U+D800–U+DFFF) from a string so it can be
177
209
  * safely serialized to JSON. Lone surrogates are valid in JS strings but
@@ -640,6 +672,27 @@ function yesterdayISO() {
640
672
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
641
673
  }
642
674
  // ── Cron Output Extraction ──────────────────────────────────────────
675
+ /** Autonomous jobs use this sentinel to mean "completed, but do not notify the owner." */
676
+ export function isAutonomousNothingOutput(response) {
677
+ const trimmed = response.trim();
678
+ if (!trimmed)
679
+ return false;
680
+ if (trimmed === '__NOTHING__')
681
+ return true;
682
+ if (/^_*NOTHING_*$/i.test(trimmed))
683
+ return true;
684
+ if (/^_*NOTHING_*\s*(\(|$)/im.test(trimmed))
685
+ return true;
686
+ if (/^(_*NOTHING_*\s*)?\[MONITORING\]\s*$/i.test(trimmed))
687
+ return true;
688
+ if (trimmed.length > 80)
689
+ return false;
690
+ const lower = trimmed.toLowerCase();
691
+ return lower === 'nothing to report'
692
+ || lower === 'nothing new to report'
693
+ || lower === 'no updates'
694
+ || lower === 'all clear';
695
+ }
643
696
  /** Return the last non-empty text block that came after the last tool call, or '' if nothing/sentinel. */
644
697
  function extractDeliverable(trace) {
645
698
  if (trace.length === 0)
@@ -657,7 +710,7 @@ function extractDeliverable(trace) {
657
710
  for (let i = trace.length - 1; i > lastToolIdx; i--) {
658
711
  if (trace[i].type === 'text') {
659
712
  const text = trace[i].content.trim();
660
- if (text === '__NOTHING__')
713
+ if (isAutonomousNothingOutput(text))
661
714
  return '';
662
715
  if (text.length > 0)
663
716
  return text;
@@ -1609,6 +1662,54 @@ Never spawn a sub-agent with vague instructions like "handle this brief."
1609
1662
 
1610
1663
  When ${owner} expresses satisfaction ("nice", "perfect", "great job", "thanks") or dissatisfaction ("no", "wrong", "that's not right", "ugh"), call \`feedback_log\` with an appropriate rating ('positive' or 'negative') and a brief comment summarizing the context. This helps me learn from interactions.`);
1611
1664
  }
1665
+ try {
1666
+ const jsonExperience = loadClementineJson(BASE_DIR).assistant ?? {};
1667
+ const pick = (value, allowed) => allowed.includes(value) ? value : undefined;
1668
+ const experience = {
1669
+ proactivity: pick(process.env.ASSISTANT_PROACTIVITY, ['quiet', 'balanced', 'proactive', 'operator']) ?? jsonExperience.proactivity,
1670
+ responseStyle: pick(process.env.ASSISTANT_RESPONSE_STYLE, ['concise', 'balanced', 'detailed']) ?? jsonExperience.responseStyle,
1671
+ progressVisibility: pick(process.env.ASSISTANT_PROGRESS_VISIBILITY, ['quiet', 'normal', 'detailed']) ?? jsonExperience.progressVisibility,
1672
+ autonomy: pick(process.env.ASSISTANT_AUTONOMY, ['ask_first', 'balanced', 'act_when_safe']) ?? jsonExperience.autonomy,
1673
+ };
1674
+ const lines = [];
1675
+ if (experience.proactivity) {
1676
+ const guidance = {
1677
+ quiet: 'Only interrupt for urgent or explicitly requested work. Avoid unsolicited next steps.',
1678
+ balanced: 'Offer useful next steps when natural, but do not create extra work without a clear reason.',
1679
+ proactive: 'Surface likely next actions, risks, and background-work opportunities before the owner has to ask.',
1680
+ operator: 'Operate forward: propose plans, queue safe background work, monitor progress, and keep the owner informed.',
1681
+ };
1682
+ lines.push(`- Proactivity: ${experience.proactivity}. ${guidance[experience.proactivity]}`);
1683
+ }
1684
+ if (experience.responseStyle) {
1685
+ const guidance = {
1686
+ concise: 'Default to short, direct answers. Expand only when the task needs it.',
1687
+ balanced: 'Match detail to task complexity.',
1688
+ detailed: 'Include more reasoning, context, and verification detail for substantive work.',
1689
+ };
1690
+ lines.push(`- Response style: ${experience.responseStyle}. ${guidance[experience.responseStyle]}`);
1691
+ }
1692
+ if (experience.progressVisibility) {
1693
+ const guidance = {
1694
+ quiet: 'Minimize process narration unless work is slow, blocked, or risky.',
1695
+ normal: 'Share important progress and decision points.',
1696
+ detailed: 'Keep the owner posted during background or multi-tool work, including failures and recoveries.',
1697
+ };
1698
+ lines.push(`- Progress visibility: ${experience.progressVisibility}. ${guidance[experience.progressVisibility]}`);
1699
+ }
1700
+ if (experience.autonomy) {
1701
+ const guidance = {
1702
+ ask_first: 'Ask before taking actions that change external systems or user data.',
1703
+ balanced: 'Act on low-risk reversible steps; ask on irreversible, costly, or ambiguous steps.',
1704
+ act_when_safe: 'Use judgment and proceed on safe, reversible, clearly beneficial work.',
1705
+ };
1706
+ lines.push(`- Autonomy: ${experience.autonomy}. ${guidance[experience.autonomy]}`);
1707
+ }
1708
+ if (lines.length > 0) {
1709
+ parts.push(`## Owner Experience Preferences\n\n${lines.join('\n')}`);
1710
+ }
1711
+ }
1712
+ catch { /* config preferences are optional */ }
1612
1713
  // Verbose level overrides
1613
1714
  if (verboseLevel === 'quiet') {
1614
1715
  parts.push(`## Verbosity: Quiet\n\nGive results directly. Skip reasoning and progress updates unless asked.`);
@@ -1858,11 +1959,18 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1858
1959
  reason: bundles.length > 0 ? 'matched' : 'empty',
1859
1960
  };
1860
1961
  };
1962
+ const autonomousToolRun = isHeartbeat || isCron || isPlanStep || isUnleashed;
1861
1963
  const promptToolRoute = routeToolSurface(promptScopeText);
1862
1964
  const profileToolRoute = routeToolSurface(profileScopeText);
1863
1965
  const contextToolRoute = routeToolSurface(contextRoutingText);
1966
+ const promptHasToolRoute = promptToolRoute.fullSurface || promptToolRoute.bundles.length > 0;
1967
+ const directFollowupNeedsContextTools = intentClassification?.type === 'followup'
1968
+ || /^(yes|yep|yeah|go|go ahead|do it|continue|pick up|use that|run it|send it|same thing)\b/i.test(promptScopeText.trim());
1969
+ const allowContextToolRoute = autonomousToolRun || (!promptHasToolRoute && directFollowupNeedsContextTools);
1864
1970
  const safeProfileToolRoute = profileToolRoute.fullSurface ? emptyToolRoute() : profileToolRoute;
1865
- const safeContextToolRoute = contextToolRoute.fullSurface ? emptyToolRoute() : contextToolRoute;
1971
+ const safeContextToolRoute = allowContextToolRoute && !contextToolRoute.fullSurface
1972
+ ? contextToolRoute
1973
+ : emptyToolRoute();
1866
1974
  const toolRoute = mergeToolRoutes(promptToolRoute, mergeToolRoutes(safeProfileToolRoute, safeContextToolRoute));
1867
1975
  let allowedTools = [];
1868
1976
  const addAllowed = (...tools) => {
@@ -1876,10 +1984,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1876
1984
  };
1877
1985
  const scopeText = [
1878
1986
  directScopeText,
1879
- contextRoutingText,
1987
+ allowContextToolRoute ? contextRoutingText : '',
1880
1988
  ].filter(Boolean).join('\n').toLowerCase();
1881
1989
  const promptScopeLower = promptScopeText.toLowerCase();
1882
- const autonomousToolRun = isHeartbeat || isCron || isPlanStep || isUnleashed;
1883
1990
  const taskIntent = intentClassification?.type === 'task' || autonomousToolRun;
1884
1991
  const memoryNeeded = autonomousToolRun
1885
1992
  || retrievalContext.trim().length > 0
@@ -2667,8 +2774,22 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2667
2774
  }
2668
2775
  // Lone-surrogate sanitization happens at the SDK boundary (see query() wrapper).
2669
2776
  let effectivePrompt = text;
2777
+ const recentExchangesForIntent = key ? this.lastExchanges.get(key) : undefined;
2778
+ const intent = classifyIntent(text, recentExchangesForIntent);
2779
+ const turnPolicy = decideTurnPolicy({
2780
+ text,
2781
+ intent,
2782
+ hasRecentContext: !!(recentExchangesForIntent?.length || (key && this.sessions.has(key))),
2783
+ });
2784
+ const suppressContextInjection = turnPolicy.suppressContextInjection === true;
2785
+ if (key && turnPolicy.suppressSessionResume) {
2786
+ this.sessions.delete(key);
2787
+ this.exchangeCounts.set(key, 0);
2788
+ this.restoredSessions.delete(key);
2789
+ this._compactedSessions.delete(key);
2790
+ }
2670
2791
  // If session rotated, use instant local summary + handoff + kick off LLM summary in background
2671
- if (sessionRotated && key) {
2792
+ if (sessionRotated && key && !suppressContextInjection) {
2672
2793
  const summary = this.buildLocalSummary(key);
2673
2794
  const handoff = this.loadHandoff(key);
2674
2795
  const contextParts = [];
@@ -2687,7 +2808,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2687
2808
  this.summarizeSessionAsync(key).catch(err => logger.debug({ err, key }, 'Session summarization failed'));
2688
2809
  }
2689
2810
  // Resilience: inject exchange history if no session_id stored
2690
- if (key && !this.sessions.has(key) && !sessionRotated) {
2811
+ if (key && !suppressContextInjection && !this.sessions.has(key) && !sessionRotated) {
2691
2812
  const exchanges = this.lastExchanges.get(key) ?? [];
2692
2813
  if (exchanges.length > 0) {
2693
2814
  const historyLines = [];
@@ -2700,7 +2821,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2700
2821
  }
2701
2822
  }
2702
2823
  // Inject context on first message after a daemon restart (session restored from disk)
2703
- if (key && this.restoredSessions.has(key)) {
2824
+ if (key && !suppressContextInjection && this.restoredSessions.has(key)) {
2704
2825
  const exchanges = this.lastExchanges.get(key) ?? [];
2705
2826
  if (exchanges.length > 0) {
2706
2827
  const olderSummary = this.buildOlderTurnsContext(key, exchanges);
@@ -2720,7 +2841,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2720
2841
  this.restoredSessions.delete(key); // Only inject once per restored session
2721
2842
  }
2722
2843
  // Fresh session with no history — inject last conversation context
2723
- if (key && !sessionRotated && !this.restoredSessions.has(key)) {
2844
+ if (key && !suppressContextInjection && !sessionRotated && !this.restoredSessions.has(key)) {
2724
2845
  const exchanges = this.lastExchanges.get(key) ?? [];
2725
2846
  if (exchanges.length === 0 && this.memoryStore) {
2726
2847
  try {
@@ -2741,7 +2862,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2741
2862
  }
2742
2863
  }
2743
2864
  // Time-gap awareness: let the agent know how long it's been
2744
- if (key && this.sessionTimestamps.has(key)) {
2865
+ if (key && !suppressContextInjection && this.sessionTimestamps.has(key)) {
2745
2866
  const gapMs = Date.now() - this.sessionTimestamps.get(key).getTime();
2746
2867
  const gapHours = Math.round(gapMs / 3_600_000);
2747
2868
  if (gapHours >= 8) {
@@ -2753,7 +2874,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2753
2874
  // injectContext uses the base session key (e.g. discord:user:123) but
2754
2875
  // chat may use a profile-suffixed key (discord:user:123:sales-agent),
2755
2876
  // so also check any pending key that the current key starts with.
2756
- if (key) {
2877
+ if (key && !suppressContextInjection) {
2757
2878
  const allPending = [];
2758
2879
  for (const [pendingKey, pending] of this.pendingContext) {
2759
2880
  if (key === pendingKey || key.startsWith(pendingKey + ':')) {
@@ -2771,7 +2892,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2771
2892
  }
2772
2893
  }
2773
2894
  // Inject stall nudge if the previous query for this session showed stall signals
2774
- if (key && this.stallNudges.has(key)) {
2895
+ if (key && !suppressContextInjection && this.stallNudges.has(key)) {
2775
2896
  const nudge = this.stallNudges.get(key);
2776
2897
  this.stallNudges.delete(key);
2777
2898
  effectivePrompt =
@@ -2780,16 +2901,6 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2780
2901
  `Either take the action NOW using your tools, or tell the user exactly what is blocking you. ` +
2781
2902
  `If a file can't be read, say so. If you're stuck, say so. Never stall silently.]\n\n${effectivePrompt}`;
2782
2903
  }
2783
- // ── Intent classification ─────────────────────────────────────
2784
- // Classify intent before the main query to dynamically tune response
2785
- // strategy, maxTurns, and effort level
2786
- const recentExchanges = key ? this.lastExchanges.get(key) : undefined;
2787
- const intent = classifyIntent(text, recentExchanges);
2788
- const turnPolicy = decideTurnPolicy({
2789
- text,
2790
- intent,
2791
- hasRecentContext: !!(recentExchanges?.length || (key && this.sessions.has(key))),
2792
- });
2793
2904
  logger.debug({
2794
2905
  intent: intent.type,
2795
2906
  confidence: intent.confidence,
@@ -2833,7 +2944,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2833
2944
  if (key && !isApiError) {
2834
2945
  this.exchangeCounts.set(key, (this.exchangeCounts.get(key) ?? 0) + 1);
2835
2946
  this.sessionTimestamps.set(key, new Date());
2836
- const history = this.lastExchanges.get(key) ?? [];
2947
+ const history = turnPolicy.suppressContextInjection ? [] : (this.lastExchanges.get(key) ?? []);
2837
2948
  history.push({ user: text, assistant: responseText });
2838
2949
  if (history.length > SESSION_EXCHANGE_HISTORY_SIZE) {
2839
2950
  this.lastExchanges.set(key, history.slice(-SESSION_EXCHANGE_HISTORY_SIZE));
@@ -3005,6 +3116,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3005
3116
  // Flipped true on the first intervention; subsequent replies go through
3006
3117
  // un-validated (but still logged).
3007
3118
  let contradictionRetried = false;
3119
+ let contextRecoveryRetries = 0;
3008
3120
  try {
3009
3121
  for (let attempt = 0; attempt <= PersonalAssistant.RATE_LIMIT_MAX_RETRIES; attempt++) {
3010
3122
  const sdkOptions = await this.buildOptions({
@@ -3034,7 +3146,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3034
3146
  sdkOptions.cwd = matchedProject.path;
3035
3147
  }
3036
3148
  // Set resume session if available
3037
- if (sessionKey && this.sessions.has(sessionKey)) {
3149
+ if (sessionKey && this.sessions.has(sessionKey) && !effectiveTurnPolicy?.suppressSessionResume) {
3038
3150
  sdkOptions.resume = this.sessions.get(sessionKey);
3039
3151
  }
3040
3152
  // Context window guard: estimate token usage and bail if too tight.
@@ -3257,7 +3369,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3257
3369
  // Auth errors — throw so the gateway circuit breaker catches it
3258
3370
  throw new Error(errorText);
3259
3371
  }
3260
- else if (lower.includes('autocompact') || lower.includes('thrash') || lower.includes('context refilled to the limit')) {
3372
+ else if (looksLikeContextThrashText(errorText)) {
3261
3373
  // Autocompact thrashing — treat like the exception path
3262
3374
  logger.warn({ sessionKey }, 'Autocompact thrashing (result error) — will rotate session');
3263
3375
  // Capture mid-task state BEFORE rotating, so the retry
@@ -3297,6 +3409,25 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3297
3409
  else if ('result' in result && result.result) {
3298
3410
  // Success: use SDK result text if streaming didn't capture a substantive response
3299
3411
  const sdkResult = result.result;
3412
+ if (looksLikeContextThrashText(sdkResult)) {
3413
+ logger.warn({ sessionKey }, 'Autocompact thrashing surfaced as SDK result text — rotating session');
3414
+ preRotationSnapshot = {
3415
+ toolCalls: stallGuard?.getToolCalls() ?? [],
3416
+ partialText: responseText.slice(-1000),
3417
+ };
3418
+ if (sessionKey) {
3419
+ try {
3420
+ this.compactContext(sessionKey);
3421
+ }
3422
+ catch { /* best-effort */ }
3423
+ this.sessions.delete(sessionKey);
3424
+ this.exchangeCounts.set(sessionKey, 0);
3425
+ this._compactedSessions.delete(sessionKey);
3426
+ }
3427
+ staleSession = true;
3428
+ contextRecovery = true;
3429
+ break;
3430
+ }
3300
3431
  logger.info({ sessionKey, streamedLen: responseText.length, resultLen: sdkResult.length }, 'SDK result text available');
3301
3432
  if (!responseText.trim()) {
3302
3433
  responseText = sdkResult;
@@ -3358,7 +3489,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3358
3489
  }
3359
3490
  }
3360
3491
  }
3361
- else if (errStr.includes('autocompact') || errStr.includes('thrash') || errStr.includes('context refilled to the limit')) {
3492
+ else if (looksLikeContextThrashText(e)) {
3362
3493
  // SDK autocompact thrashing — tool outputs are too large for the context window.
3363
3494
  // Rotate session and retry with a fresh context so the agent can continue.
3364
3495
  logger.warn({ sessionKey }, 'Autocompact thrashing — rotating session and retrying');
@@ -3377,13 +3508,14 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3377
3508
  this.exchangeCounts.set(sessionKey, 0);
3378
3509
  this._compactedSessions.delete(sessionKey);
3379
3510
  }
3380
- if (attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES) {
3511
+ if (attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES && contextRecoveryRetries < 1) {
3512
+ contextRecoveryRetries++;
3381
3513
  prompt = buildContextRecoveredPrompt(prompt, preRotationSnapshot);
3382
3514
  preRotationSnapshot = null;
3383
3515
  responseText = '';
3384
3516
  continue;
3385
3517
  }
3386
- 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.';
3518
+ responseText = responseText || contextThrashRecoveryNotice();
3387
3519
  }
3388
3520
  else if (errStr.includes('prompt is too long') || errStr.includes('prompt too long') || errStr.includes('context_length')) {
3389
3521
  responseText = responseText || ('The conversation got too large to process (tool responses filled the context window). ' +
@@ -3430,11 +3562,25 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3430
3562
  if (staleSession && attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES) {
3431
3563
  responseText = '';
3432
3564
  if (contextRecovery) {
3433
- prompt = buildContextRecoveredPrompt(prompt, preRotationSnapshot);
3434
- preRotationSnapshot = null;
3435
- contextRecovery = false;
3565
+ if (contextRecoveryRetries >= 1) {
3566
+ responseText = contextThrashRecoveryNotice();
3567
+ staleSession = false;
3568
+ contextRecovery = false;
3569
+ }
3570
+ else {
3571
+ contextRecoveryRetries++;
3572
+ prompt = buildContextRecoveredPrompt(prompt, preRotationSnapshot);
3573
+ preRotationSnapshot = null;
3574
+ contextRecovery = false;
3575
+ continue;
3576
+ }
3436
3577
  }
3437
- continue;
3578
+ else {
3579
+ continue;
3580
+ }
3581
+ }
3582
+ if (staleSession && contextRecovery && !responseText.trim()) {
3583
+ responseText = contextThrashRecoveryNotice();
3438
3584
  }
3439
3585
  if (hitRateLimit && attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES) {
3440
3586
  const base = rateLimitRetryAfterMs
@@ -3450,6 +3596,26 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3450
3596
  if (hitRateLimit && !responseText) {
3451
3597
  responseText = "I'm being rate limited right now. Give me a minute and try again.";
3452
3598
  }
3599
+ if (looksLikeContextThrashText(responseText)) {
3600
+ logger.warn({ sessionKey }, 'Autocompact thrashing escaped into response text — rotating session before reply');
3601
+ if (sessionKey) {
3602
+ try {
3603
+ this.compactContext(sessionKey);
3604
+ }
3605
+ catch { /* best-effort */ }
3606
+ this.sessions.delete(sessionKey);
3607
+ this.exchangeCounts.set(sessionKey, 0);
3608
+ this._compactedSessions.delete(sessionKey);
3609
+ }
3610
+ if (attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES && contextRecoveryRetries < 1) {
3611
+ contextRecoveryRetries++;
3612
+ prompt = buildContextRecoveredPrompt(prompt, preRotationSnapshot);
3613
+ preRotationSnapshot = null;
3614
+ responseText = '';
3615
+ continue;
3616
+ }
3617
+ responseText = contextThrashRecoveryNotice();
3618
+ }
3453
3619
  // ── Response guarantee ─────────────────────────────────────────
3454
3620
  // The model often generates 30+ tool calls with minimal/no text. Ensure
3455
3621
  // the user always gets a substantive response after real work is done.
@@ -4894,7 +5060,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4894
5060
  if (cronGuard) {
4895
5061
  const summary = cronGuard.getSummary();
4896
5062
  const mc = summary.metacognition;
4897
- if (mc.confidenceFinal === 'low' && deliverable && deliverable !== '__NOTHING__') {
5063
+ if (mc.confidenceFinal === 'low' && deliverable && !isAutonomousNothingOutput(deliverable)) {
4898
5064
  try {
4899
5065
  const escalationsFile = path.join(BASE_DIR, 'escalations.json');
4900
5066
  const escalations = fs.existsSync(escalationsFile)
@@ -5425,6 +5591,21 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
5425
5591
  lastPhaseOutputPreview: lastOutput.slice(0, 300),
5426
5592
  });
5427
5593
  logger.info(`Unleashed task ${jobName}: phase ${phase} complete (${(phaseDurationMs / 1000).toFixed(0)}s)`);
5594
+ // The job explicitly says there is nothing to report. Treat that as a
5595
+ // clean terminal state instead of resuming the same no-op phase until
5596
+ // the max-phase guard fires.
5597
+ if (isAutonomousNothingOutput(lastOutput)) {
5598
+ appendProgress({ event: 'completed_silent', phase });
5599
+ writeStatus({ jobName, status: 'completed', phase, startedAt, finishedAt: new Date().toISOString(), silent: true });
5600
+ logger.info(`Unleashed task ${jobName} completed silently at phase ${phase}`);
5601
+ if (this.onUnleashedComplete) {
5602
+ try {
5603
+ this.onUnleashedComplete(jobName, '__NOTHING__');
5604
+ }
5605
+ catch { /* non-fatal */ }
5606
+ }
5607
+ return '__NOTHING__';
5608
+ }
5428
5609
  // Notify phase progress callback
5429
5610
  if (this.onPhaseComplete) {
5430
5611
  try {
@@ -0,0 +1,32 @@
1
+ import type { ClementineJson } from '../config/clementine-json.js';
2
+ export type ProactivityMode = 'quiet' | 'balanced' | 'proactive' | 'operator';
3
+ export type ResponseStyle = 'concise' | 'balanced' | 'detailed';
4
+ export type ProgressVisibility = 'quiet' | 'normal' | 'detailed';
5
+ export type AutonomyMode = 'ask_first' | 'balanced' | 'act_when_safe';
6
+ export interface AssistantExperienceUpdate {
7
+ proactivity?: ProactivityMode;
8
+ responseStyle?: ResponseStyle;
9
+ progressVisibility?: ProgressVisibility;
10
+ autonomy?: AutonomyMode;
11
+ }
12
+ export type LocalTurnIntent = {
13
+ kind: 'none';
14
+ } | {
15
+ kind: 'ack';
16
+ } | {
17
+ kind: 'greeting';
18
+ } | {
19
+ kind: 'stop';
20
+ } | {
21
+ kind: 'status';
22
+ } | {
23
+ kind: 'preference_update';
24
+ updates: AssistantExperienceUpdate;
25
+ summary: string;
26
+ };
27
+ export declare function isStopRequest(text: string): boolean;
28
+ export declare function isStatusRequest(text: string): boolean;
29
+ export declare function isTinyAcknowledgment(text: string): boolean;
30
+ export declare function detectLocalTurn(text: string): LocalTurnIntent;
31
+ export declare function applyAssistantExperienceUpdate(cfg: ClementineJson, updates: AssistantExperienceUpdate): ClementineJson;
32
+ //# sourceMappingURL=local-turn.d.ts.map
@@ -0,0 +1,107 @@
1
+ import { isStandaloneGreeting } from './turn-policy.js';
2
+ function normalize(text) {
3
+ return text
4
+ .trim()
5
+ .toLowerCase()
6
+ .replace(/[.!?]+$/g, '')
7
+ .replace(/\s+/g, ' ');
8
+ }
9
+ function wordCount(text) {
10
+ const t = text.trim();
11
+ return t ? t.split(/\s+/).length : 0;
12
+ }
13
+ export function isStopRequest(text) {
14
+ const n = normalize(text);
15
+ if (wordCount(n) > 5)
16
+ return false;
17
+ return /^(stop|cancel|abort|halt|pause|nevermind|never mind|wait stop|stop please|cancel that|stop that)$/.test(n);
18
+ }
19
+ export function isStatusRequest(text) {
20
+ const n = normalize(text);
21
+ if (wordCount(n) > 8)
22
+ 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);
24
+ }
25
+ export function isTinyAcknowledgment(text) {
26
+ const n = normalize(text);
27
+ if (wordCount(n) > 4)
28
+ return false;
29
+ return /^(thanks|thank you|thx|ty|nice|great|perfect|awesome|cool|ok|okay|sounds good|got it|makes sense|love it)$/.test(n);
30
+ }
31
+ function parseProactivity(text) {
32
+ if (/\b(operator mode|operator)\b/i.test(text))
33
+ return 'operator';
34
+ if (/\b(more proactive|be proactive|proactive mode|set proactivity to proactive)\b/i.test(text))
35
+ return 'proactive';
36
+ if (/\b(less proactive|quieter|quiet mode|be quiet|only urgent|do not interrupt)\b/i.test(text))
37
+ return 'quiet';
38
+ if (/\b(balanced proactivity|balanced mode|normal proactivity)\b/i.test(text))
39
+ return 'balanced';
40
+ return undefined;
41
+ }
42
+ function parseResponseStyle(text) {
43
+ if (/\b(be concise|keep it concise|shorter replies|brief replies|reply briefly|less verbose)\b/i.test(text))
44
+ return 'concise';
45
+ if (/\b(more detail|detailed replies|be detailed|explain more|more verbose)\b/i.test(text))
46
+ return 'detailed';
47
+ if (/\b(balanced replies|normal replies|balanced detail)\b/i.test(text))
48
+ return 'balanced';
49
+ return undefined;
50
+ }
51
+ function parseProgressVisibility(text) {
52
+ if (/\b(show more progress|keep me posted|more updates|detailed progress|tell me what'?s happening)\b/i.test(text))
53
+ return 'detailed';
54
+ if (/\b(less progress|fewer updates|quiet progress|don'?t narrate)\b/i.test(text))
55
+ return 'quiet';
56
+ if (/\b(normal progress|balanced progress)\b/i.test(text))
57
+ return 'normal';
58
+ return undefined;
59
+ }
60
+ function parseAutonomy(text) {
61
+ if (/\b(ask first|ask me first|ask before acting|do not act without asking)\b/i.test(text))
62
+ return 'ask_first';
63
+ if (/\b(act when safe|more autonomous|use your judgment|handle it when safe)\b/i.test(text))
64
+ return 'act_when_safe';
65
+ if (/\b(balanced autonomy|normal autonomy)\b/i.test(text))
66
+ return 'balanced';
67
+ return undefined;
68
+ }
69
+ export function detectLocalTurn(text) {
70
+ if (isStopRequest(text))
71
+ return { kind: 'stop' };
72
+ if (isStatusRequest(text))
73
+ return { kind: 'status' };
74
+ if (isStandaloneGreeting(text))
75
+ return { kind: 'greeting' };
76
+ if (isTinyAcknowledgment(text))
77
+ return { kind: 'ack' };
78
+ const updates = {};
79
+ const proactivity = parseProactivity(text);
80
+ const responseStyle = parseResponseStyle(text);
81
+ const progressVisibility = parseProgressVisibility(text);
82
+ const autonomy = parseAutonomy(text);
83
+ if (proactivity)
84
+ updates.proactivity = proactivity;
85
+ if (responseStyle)
86
+ updates.responseStyle = responseStyle;
87
+ if (progressVisibility)
88
+ updates.progressVisibility = progressVisibility;
89
+ if (autonomy)
90
+ updates.autonomy = autonomy;
91
+ const entries = Object.entries(updates);
92
+ if (entries.length === 0)
93
+ return { kind: 'none' };
94
+ const summary = entries.map(([k, v]) => `${k}: ${v}`).join(', ');
95
+ return { kind: 'preference_update', updates, summary };
96
+ }
97
+ export function applyAssistantExperienceUpdate(cfg, updates) {
98
+ return {
99
+ ...cfg,
100
+ schemaVersion: 1,
101
+ assistant: {
102
+ ...(cfg.assistant ?? {}),
103
+ ...updates,
104
+ },
105
+ };
106
+ }
107
+ //# sourceMappingURL=local-turn.js.map
@@ -76,6 +76,7 @@ const PATTERNS = [
76
76
  recipe: () => ({
77
77
  category: 'safe-cron-config',
78
78
  description: 'Context window blowing up mid-run. Switching to unleashed mode so each phase starts with a fresh context.',
79
+ fields: ['mode', 'max_hours'],
79
80
  apply: (job) => {
80
81
  let changed = false;
81
82
  if (job.mode !== 'unleashed') {
@@ -15,6 +15,10 @@ export interface TurnPolicy {
15
15
  effort: 'low' | 'medium' | 'high';
16
16
  allowProactiveGoals: boolean;
17
17
  fetchLinks: boolean;
18
+ /** Do not resume the prior Claude SDK session for this turn. */
19
+ suppressSessionResume?: boolean;
20
+ /** Do not inject restored/pending/background context for this turn. */
21
+ suppressContextInjection?: boolean;
18
22
  reason: string;
19
23
  }
20
24
  export interface TurnPolicyInput {
@@ -23,5 +27,6 @@ export interface TurnPolicyInput {
23
27
  hasRecentContext: boolean;
24
28
  isAutonomous?: boolean;
25
29
  }
30
+ export declare function isStandaloneGreeting(text: string): boolean;
26
31
  export declare function decideTurnPolicy(input: TurnPolicyInput): TurnPolicy;
27
32
  //# sourceMappingURL=turn-policy.d.ts.map