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.
- package/dist/agent/assistant.d.ts +5 -0
- package/dist/agent/assistant.js +187 -24
- package/dist/agent/local-turn.d.ts +32 -0
- package/dist/agent/local-turn.js +107 -0
- package/dist/agent/self-improve-loop.js +1 -0
- package/dist/agent/turn-policy.d.ts +5 -0
- package/dist/agent/turn-policy.js +39 -0
- package/dist/channels/discord-agent-bot.js +2 -0
- package/dist/channels/slack-agent-bot.js +1 -1
- package/dist/channels/slack.js +1 -1
- package/dist/channels/telegram.js +10 -3
- package/dist/cli/dashboard.js +540 -51
- package/dist/config/clementine-json.d.ts +30 -0
- package/dist/config/clementine-json.js +39 -1
- package/dist/config/effective-config.js +5 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +6 -0
- package/dist/gateway/cron-scheduler.d.ts +1 -1
- package/dist/gateway/cron-scheduler.js +17 -3
- package/dist/gateway/router.d.ts +8 -0
- package/dist/gateway/router.js +253 -7
- package/package.json +2 -2
|
@@ -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;
|
package/dist/agent/assistant.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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 (
|
|
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 ||
|
|
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
|
|
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) {
|
package/dist/channels/slack.js
CHANGED
|
@@ -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) {
|