clementine-agent 1.18.9 → 1.18.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.`);
@@ -2667,8 +2768,22 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2667
2768
  }
2668
2769
  // Lone-surrogate sanitization happens at the SDK boundary (see query() wrapper).
2669
2770
  let effectivePrompt = text;
2771
+ const recentExchangesForIntent = key ? this.lastExchanges.get(key) : undefined;
2772
+ const intent = classifyIntent(text, recentExchangesForIntent);
2773
+ const turnPolicy = decideTurnPolicy({
2774
+ text,
2775
+ intent,
2776
+ hasRecentContext: !!(recentExchangesForIntent?.length || (key && this.sessions.has(key))),
2777
+ });
2778
+ const suppressContextInjection = turnPolicy.suppressContextInjection === true;
2779
+ if (key && turnPolicy.suppressSessionResume) {
2780
+ this.sessions.delete(key);
2781
+ this.exchangeCounts.set(key, 0);
2782
+ this.restoredSessions.delete(key);
2783
+ this._compactedSessions.delete(key);
2784
+ }
2670
2785
  // If session rotated, use instant local summary + handoff + kick off LLM summary in background
2671
- if (sessionRotated && key) {
2786
+ if (sessionRotated && key && !suppressContextInjection) {
2672
2787
  const summary = this.buildLocalSummary(key);
2673
2788
  const handoff = this.loadHandoff(key);
2674
2789
  const contextParts = [];
@@ -2687,7 +2802,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2687
2802
  this.summarizeSessionAsync(key).catch(err => logger.debug({ err, key }, 'Session summarization failed'));
2688
2803
  }
2689
2804
  // Resilience: inject exchange history if no session_id stored
2690
- if (key && !this.sessions.has(key) && !sessionRotated) {
2805
+ if (key && !suppressContextInjection && !this.sessions.has(key) && !sessionRotated) {
2691
2806
  const exchanges = this.lastExchanges.get(key) ?? [];
2692
2807
  if (exchanges.length > 0) {
2693
2808
  const historyLines = [];
@@ -2700,7 +2815,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2700
2815
  }
2701
2816
  }
2702
2817
  // Inject context on first message after a daemon restart (session restored from disk)
2703
- if (key && this.restoredSessions.has(key)) {
2818
+ if (key && !suppressContextInjection && this.restoredSessions.has(key)) {
2704
2819
  const exchanges = this.lastExchanges.get(key) ?? [];
2705
2820
  if (exchanges.length > 0) {
2706
2821
  const olderSummary = this.buildOlderTurnsContext(key, exchanges);
@@ -2720,7 +2835,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2720
2835
  this.restoredSessions.delete(key); // Only inject once per restored session
2721
2836
  }
2722
2837
  // Fresh session with no history — inject last conversation context
2723
- if (key && !sessionRotated && !this.restoredSessions.has(key)) {
2838
+ if (key && !suppressContextInjection && !sessionRotated && !this.restoredSessions.has(key)) {
2724
2839
  const exchanges = this.lastExchanges.get(key) ?? [];
2725
2840
  if (exchanges.length === 0 && this.memoryStore) {
2726
2841
  try {
@@ -2741,7 +2856,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2741
2856
  }
2742
2857
  }
2743
2858
  // Time-gap awareness: let the agent know how long it's been
2744
- if (key && this.sessionTimestamps.has(key)) {
2859
+ if (key && !suppressContextInjection && this.sessionTimestamps.has(key)) {
2745
2860
  const gapMs = Date.now() - this.sessionTimestamps.get(key).getTime();
2746
2861
  const gapHours = Math.round(gapMs / 3_600_000);
2747
2862
  if (gapHours >= 8) {
@@ -2753,7 +2868,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2753
2868
  // injectContext uses the base session key (e.g. discord:user:123) but
2754
2869
  // chat may use a profile-suffixed key (discord:user:123:sales-agent),
2755
2870
  // so also check any pending key that the current key starts with.
2756
- if (key) {
2871
+ if (key && !suppressContextInjection) {
2757
2872
  const allPending = [];
2758
2873
  for (const [pendingKey, pending] of this.pendingContext) {
2759
2874
  if (key === pendingKey || key.startsWith(pendingKey + ':')) {
@@ -2771,7 +2886,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2771
2886
  }
2772
2887
  }
2773
2888
  // Inject stall nudge if the previous query for this session showed stall signals
2774
- if (key && this.stallNudges.has(key)) {
2889
+ if (key && !suppressContextInjection && this.stallNudges.has(key)) {
2775
2890
  const nudge = this.stallNudges.get(key);
2776
2891
  this.stallNudges.delete(key);
2777
2892
  effectivePrompt =
@@ -2780,16 +2895,6 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2780
2895
  `Either take the action NOW using your tools, or tell the user exactly what is blocking you. ` +
2781
2896
  `If a file can't be read, say so. If you're stuck, say so. Never stall silently.]\n\n${effectivePrompt}`;
2782
2897
  }
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
2898
  logger.debug({
2794
2899
  intent: intent.type,
2795
2900
  confidence: intent.confidence,
@@ -2833,7 +2938,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2833
2938
  if (key && !isApiError) {
2834
2939
  this.exchangeCounts.set(key, (this.exchangeCounts.get(key) ?? 0) + 1);
2835
2940
  this.sessionTimestamps.set(key, new Date());
2836
- const history = this.lastExchanges.get(key) ?? [];
2941
+ const history = turnPolicy.suppressContextInjection ? [] : (this.lastExchanges.get(key) ?? []);
2837
2942
  history.push({ user: text, assistant: responseText });
2838
2943
  if (history.length > SESSION_EXCHANGE_HISTORY_SIZE) {
2839
2944
  this.lastExchanges.set(key, history.slice(-SESSION_EXCHANGE_HISTORY_SIZE));
@@ -3034,7 +3139,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3034
3139
  sdkOptions.cwd = matchedProject.path;
3035
3140
  }
3036
3141
  // Set resume session if available
3037
- if (sessionKey && this.sessions.has(sessionKey)) {
3142
+ if (sessionKey && this.sessions.has(sessionKey) && !effectiveTurnPolicy?.suppressSessionResume) {
3038
3143
  sdkOptions.resume = this.sessions.get(sessionKey);
3039
3144
  }
3040
3145
  // Context window guard: estimate token usage and bail if too tight.
@@ -3257,7 +3362,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3257
3362
  // Auth errors — throw so the gateway circuit breaker catches it
3258
3363
  throw new Error(errorText);
3259
3364
  }
3260
- else if (lower.includes('autocompact') || lower.includes('thrash') || lower.includes('context refilled to the limit')) {
3365
+ else if (looksLikeContextThrashText(errorText)) {
3261
3366
  // Autocompact thrashing — treat like the exception path
3262
3367
  logger.warn({ sessionKey }, 'Autocompact thrashing (result error) — will rotate session');
3263
3368
  // Capture mid-task state BEFORE rotating, so the retry
@@ -3297,6 +3402,25 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3297
3402
  else if ('result' in result && result.result) {
3298
3403
  // Success: use SDK result text if streaming didn't capture a substantive response
3299
3404
  const sdkResult = result.result;
3405
+ if (looksLikeContextThrashText(sdkResult)) {
3406
+ logger.warn({ sessionKey }, 'Autocompact thrashing surfaced as SDK result text — rotating session');
3407
+ preRotationSnapshot = {
3408
+ toolCalls: stallGuard?.getToolCalls() ?? [],
3409
+ partialText: responseText.slice(-1000),
3410
+ };
3411
+ if (sessionKey) {
3412
+ try {
3413
+ this.compactContext(sessionKey);
3414
+ }
3415
+ catch { /* best-effort */ }
3416
+ this.sessions.delete(sessionKey);
3417
+ this.exchangeCounts.set(sessionKey, 0);
3418
+ this._compactedSessions.delete(sessionKey);
3419
+ }
3420
+ staleSession = true;
3421
+ contextRecovery = true;
3422
+ break;
3423
+ }
3300
3424
  logger.info({ sessionKey, streamedLen: responseText.length, resultLen: sdkResult.length }, 'SDK result text available');
3301
3425
  if (!responseText.trim()) {
3302
3426
  responseText = sdkResult;
@@ -3358,7 +3482,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3358
3482
  }
3359
3483
  }
3360
3484
  }
3361
- else if (errStr.includes('autocompact') || errStr.includes('thrash') || errStr.includes('context refilled to the limit')) {
3485
+ else if (looksLikeContextThrashText(e)) {
3362
3486
  // SDK autocompact thrashing — tool outputs are too large for the context window.
3363
3487
  // Rotate session and retry with a fresh context so the agent can continue.
3364
3488
  logger.warn({ sessionKey }, 'Autocompact thrashing — rotating session and retrying');
@@ -3383,7 +3507,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3383
3507
  responseText = '';
3384
3508
  continue;
3385
3509
  }
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.';
3510
+ responseText = responseText || contextThrashRecoveryNotice();
3387
3511
  }
3388
3512
  else if (errStr.includes('prompt is too long') || errStr.includes('prompt too long') || errStr.includes('context_length')) {
3389
3513
  responseText = responseText || ('The conversation got too large to process (tool responses filled the context window). ' +
@@ -3436,6 +3560,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3436
3560
  }
3437
3561
  continue;
3438
3562
  }
3563
+ if (staleSession && contextRecovery && !responseText.trim()) {
3564
+ responseText = contextThrashRecoveryNotice();
3565
+ }
3439
3566
  if (hitRateLimit && attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES) {
3440
3567
  const base = rateLimitRetryAfterMs
3441
3568
  ?? PersonalAssistant.RATE_LIMIT_BACKOFF[Math.min(attempt, PersonalAssistant.RATE_LIMIT_BACKOFF.length - 1)];
@@ -3450,6 +3577,27 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3450
3577
  if (hitRateLimit && !responseText) {
3451
3578
  responseText = "I'm being rate limited right now. Give me a minute and try again.";
3452
3579
  }
3580
+ if (looksLikeContextThrashText(responseText)) {
3581
+ logger.warn({ sessionKey }, 'Autocompact thrashing escaped into response text — rotating session before reply');
3582
+ if (sessionKey) {
3583
+ try {
3584
+ this.compactContext(sessionKey);
3585
+ }
3586
+ catch { /* best-effort */ }
3587
+ this.sessions.delete(sessionKey);
3588
+ this.exchangeCounts.set(sessionKey, 0);
3589
+ this._compactedSessions.delete(sessionKey);
3590
+ }
3591
+ if (attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES) {
3592
+ prompt = buildContextRecoveredPrompt(prompt, {
3593
+ toolCalls: stallGuard?.getToolCalls() ?? [],
3594
+ partialText: '',
3595
+ });
3596
+ responseText = '';
3597
+ continue;
3598
+ }
3599
+ responseText = contextThrashRecoveryNotice();
3600
+ }
3453
3601
  // ── Response guarantee ─────────────────────────────────────────
3454
3602
  // The model often generates 30+ tool calls with minimal/no text. Ensure
3455
3603
  // the user always gets a substantive response after real work is done.
@@ -4894,7 +5042,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4894
5042
  if (cronGuard) {
4895
5043
  const summary = cronGuard.getSummary();
4896
5044
  const mc = summary.metacognition;
4897
- if (mc.confidenceFinal === 'low' && deliverable && deliverable !== '__NOTHING__') {
5045
+ if (mc.confidenceFinal === 'low' && deliverable && !isAutonomousNothingOutput(deliverable)) {
4898
5046
  try {
4899
5047
  const escalationsFile = path.join(BASE_DIR, 'escalations.json');
4900
5048
  const escalations = fs.existsSync(escalationsFile)
@@ -5425,6 +5573,21 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
5425
5573
  lastPhaseOutputPreview: lastOutput.slice(0, 300),
5426
5574
  });
5427
5575
  logger.info(`Unleashed task ${jobName}: phase ${phase} complete (${(phaseDurationMs / 1000).toFixed(0)}s)`);
5576
+ // The job explicitly says there is nothing to report. Treat that as a
5577
+ // clean terminal state instead of resuming the same no-op phase until
5578
+ // the max-phase guard fires.
5579
+ if (isAutonomousNothingOutput(lastOutput)) {
5580
+ appendProgress({ event: 'completed_silent', phase });
5581
+ writeStatus({ jobName, status: 'completed', phase, startedAt, finishedAt: new Date().toISOString(), silent: true });
5582
+ logger.info(`Unleashed task ${jobName} completed silently at phase ${phase}`);
5583
+ if (this.onUnleashedComplete) {
5584
+ try {
5585
+ this.onUnleashedComplete(jobName, '__NOTHING__');
5586
+ }
5587
+ catch { /* non-fatal */ }
5588
+ }
5589
+ return '__NOTHING__';
5590
+ }
5428
5591
  // Notify phase progress callback
5429
5592
  if (this.onPhaseComplete) {
5430
5593
  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
@@ -12,6 +12,31 @@ const GOAL_REF_RE = /\b(goal|goals|objective|objectives|blocker|next action|next
12
12
  const LOCAL_TOOL_RE = /\b(repo|repository|code|file|files|folder|directory|path|log|logs|config|build|test|typecheck|lint|npm|git|commit|push|pull|branch|diff|patch|edit|write|implement|fix|refactor|run)\b/i;
13
13
  const COMPLEX_RE = /\b(multiple|several|many|bulk|batch|parallel|deep mode|background|research|analyze|audit|review|across|end to end|entire)\b/i;
14
14
  const ADMIN_RE = /\b(self[- ]?update|restart|daemon|npm publish|publish to npm|doctor|integration|credential|env var|environment variable|set up|setup|configure)\b/i;
15
+ const STANDALONE_GREETINGS = new Set([
16
+ 'hi',
17
+ 'hey',
18
+ 'hey there',
19
+ 'hello',
20
+ 'hello there',
21
+ 'yo',
22
+ 'sup',
23
+ "what's up",
24
+ 'whats up',
25
+ 'good morning',
26
+ 'good afternoon',
27
+ 'good evening',
28
+ 'morning',
29
+ 'gm',
30
+ ]);
31
+ export function isStandaloneGreeting(text) {
32
+ const normalized = text
33
+ .trim()
34
+ .toLowerCase()
35
+ .replace(/^[^\w']+|[^\w']+$/g, '')
36
+ .replace(/\s+/g, ' ');
37
+ const withoutName = normalized.replace(/\s+clementine$/i, '');
38
+ return STANDALONE_GREETINGS.has(normalized) || STANDALONE_GREETINGS.has(withoutName);
39
+ }
15
40
  function wordCount(text) {
16
41
  const trimmed = text.trim();
17
42
  return trimmed ? trimmed.split(/\s+/).length : 0;
@@ -65,6 +90,20 @@ export function decideTurnPolicy(input) {
65
90
  reason: 'explicit-full-surface',
66
91
  };
67
92
  }
93
+ if (isStandaloneGreeting(text)) {
94
+ return {
95
+ retrievalTier: 'none',
96
+ disableAllTools: true,
97
+ enableTeams: false,
98
+ maxTurns: 2,
99
+ effort: 'low',
100
+ allowProactiveGoals: false,
101
+ fetchLinks: false,
102
+ suppressSessionResume: true,
103
+ suppressContextInjection: true,
104
+ reason: 'standalone-greeting',
105
+ };
106
+ }
68
107
  if (intent.type === 'casual' && !hasUrl && !referencesMemory && !routedTools && !localToolWork) {
69
108
  return {
70
109
  retrievalTier: 'none',
@@ -893,6 +893,8 @@ export class AgentBotClient {
893
893
  undefined, // maxTurns
894
894
  async (toolName, toolInput) => {
895
895
  streamer.setToolStatus(friendlyToolName(toolName, toolInput));
896
+ }, async (status) => {
897
+ streamer.setToolStatus(status);
896
898
  });
897
899
  await streamer.finalize(response);
898
900
  }
@@ -308,7 +308,7 @@ export class SlackAgentBotClient {
308
308
  await streamer.update(token);
309
309
  }, undefined, // model
310
310
  undefined, // maxTurns
311
- async (toolName, toolInput) => { streamer.setToolStatus(friendlyToolName(toolName, toolInput)); });
311
+ async (toolName, toolInput) => { streamer.setToolStatus(friendlyToolName(toolName, toolInput)); }, async (status) => { streamer.setToolStatus(status); });
312
312
  await streamer.finalize(response);
313
313
  }
314
314
  catch (err) {
@@ -123,7 +123,7 @@ export async function startSlack(gateway, dispatcher, slackBotManager) {
123
123
  try {
124
124
  const response = await gateway.handleMessage(sessionKey, text, (t) => streamer.update(t), undefined, // model
125
125
  undefined, // maxTurns
126
- async (toolName, toolInput) => { streamer.setToolStatus(friendlyToolName(toolName, toolInput)); });
126
+ async (toolName, toolInput) => { streamer.setToolStatus(friendlyToolName(toolName, toolInput)); }, async (status) => { streamer.setToolStatus(status); });
127
127
  await streamer.finalize(response);
128
128
  // Track bot message for feedback reactions
129
129
  if (streamer.messageTs) {