clementine-agent 1.18.190 → 1.18.191
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/clementine-turn-context.d.ts +7 -0
- package/dist/agent/clementine-turn-context.js +29 -3
- package/dist/agent/intent-classifier.d.ts +45 -0
- package/dist/agent/intent-classifier.js +144 -0
- package/dist/gateway/router.d.ts +25 -0
- package/dist/gateway/router.js +211 -0
- package/package.json +1 -1
|
@@ -85,6 +85,13 @@ export interface BuildTurnContextOptions {
|
|
|
85
85
|
* source/output file inventory, and deploy.json summary (if any).
|
|
86
86
|
* Set by the router's resolver before the chat call. */
|
|
87
87
|
activeProject?: ProjectMeta | null;
|
|
88
|
+
/** 1.18.191 — message shape from intent-classifier. When 'simple',
|
|
89
|
+
* we skip the heavyweight sections (memory recall, bg-task headlines,
|
|
90
|
+
* dispute gate) and only emit identity + live state. Saves ~3-4KB
|
|
91
|
+
* of injected tokens per routine chat turn. 'multi-step' and
|
|
92
|
+
* 'unknown' keep the full block. Defaults to 'unknown' = full block
|
|
93
|
+
* for back-compat. */
|
|
94
|
+
messageShape?: 'simple' | 'multi-step' | 'unknown';
|
|
88
95
|
}
|
|
89
96
|
export interface BuildTurnContextResult {
|
|
90
97
|
/** The full ready-to-prepend context block, INCLUDING outer
|
|
@@ -68,12 +68,26 @@ export function buildClementineTurnContext(opts) {
|
|
|
68
68
|
const parts = [];
|
|
69
69
|
const nowMs = (opts.now ?? Date.now)();
|
|
70
70
|
const nowDate = new Date(nowMs);
|
|
71
|
+
// 1.18.191 — intent-aware section gating. For 'simple' messages
|
|
72
|
+
// (one-line questions, casual chat, brief asks), the heavy sections
|
|
73
|
+
// (memory recall, bg-task headlines, dispute gate) are skipped so
|
|
74
|
+
// every routine turn doesn't pay 3-4KB of injected tokens for
|
|
75
|
+
// context the model doesn't need. Identity + live state still
|
|
76
|
+
// render — they're cheap and always useful. Active project still
|
|
77
|
+
// renders when the message references it. 'multi-step' and
|
|
78
|
+
// 'unknown' keep the full block.
|
|
79
|
+
const messageShape = opts.messageShape ?? 'unknown';
|
|
80
|
+
const isSimpleMessage = messageShape === 'simple';
|
|
71
81
|
// 1.18.187 — detect dispute pattern (Part E). When the owner is
|
|
72
82
|
// reporting a failure of prior work, we want to suppress "past
|
|
73
83
|
// success" recall items (they bias the model toward defending its
|
|
74
84
|
// memory instead of verifying reality) and add a verification
|
|
75
85
|
// directive at the top of the block.
|
|
76
|
-
|
|
86
|
+
// 1.18.191 — also suppressed for 'simple' shape since dispute
|
|
87
|
+
// patterns rarely co-occur with simple messages, and the gate
|
|
88
|
+
// produces a multi-paragraph directive we don't want to inject
|
|
89
|
+
// when the message is just "what time is it".
|
|
90
|
+
const disputeDetected = !isSimpleMessage && detectDisputePattern(opts.userMessage);
|
|
77
91
|
sections.disputeDetected = disputeDetected;
|
|
78
92
|
if (disputeDetected) {
|
|
79
93
|
parts.push('### Dispute mode — verification posture\n' +
|
|
@@ -103,7 +117,14 @@ export function buildClementineTurnContext(opts) {
|
|
|
103
117
|
// hits from the SQLite memory store, scored against the user's
|
|
104
118
|
// current message. Without this, Clementine has no automatic recall
|
|
105
119
|
// — she'd have to spontaneously call memory_search every turn.
|
|
106
|
-
|
|
120
|
+
//
|
|
121
|
+
// 1.18.191 — skipped for 'simple' messages. "What time is it" doesn't
|
|
122
|
+
// need 6 memory hits injected; she has the recall tools and can call
|
|
123
|
+
// them on demand when the message warrants it. Saves ~2KB/turn on
|
|
124
|
+
// routine chat. The owner's own posture directive
|
|
125
|
+
// (BEHAVIORAL_POSTURE in run-agent-context.ts) tells the model to
|
|
126
|
+
// call memory_search proactively when relevant — that still works.
|
|
127
|
+
if (!isSimpleMessage && opts.memoryStore?.searchContext && opts.userMessage.trim().length > 0) {
|
|
107
128
|
try {
|
|
108
129
|
const hits = opts.memoryStore.searchContext(opts.userMessage, {
|
|
109
130
|
limit: MAX_MEMORY_HITS,
|
|
@@ -139,7 +160,12 @@ export function buildClementineTurnContext(opts) {
|
|
|
139
160
|
// that bias the model toward "but my memory says it succeeded."
|
|
140
161
|
// Failed/aborted/interrupted tasks STAY because they're useful
|
|
141
162
|
// signal for the verification posture.
|
|
142
|
-
|
|
163
|
+
//
|
|
164
|
+
// 1.18.191 — skipped for 'simple' messages. Routine chat doesn't
|
|
165
|
+
// need to know what bg tasks landed in the last 24h. If the user
|
|
166
|
+
// asks "what happened with X" — that's a multi-step / unknown
|
|
167
|
+
// shape, this section will fire normally.
|
|
168
|
+
if (!isSimpleMessage && opts.listBackgroundTasks) {
|
|
143
169
|
try {
|
|
144
170
|
const TERMINAL = disputeDetected
|
|
145
171
|
? ['failed', 'interrupted', 'aborted']
|
|
@@ -31,6 +31,51 @@ export declare function classifyIntent(text: string, recentExchanges?: Array<{
|
|
|
31
31
|
* Injected into the system prompt to steer the agent's response style.
|
|
32
32
|
*/
|
|
33
33
|
export declare function getStrategyGuidance(strategy: ResponseStrategy): string;
|
|
34
|
+
export type MessageShape =
|
|
35
|
+
/** Single ask, single response. "what time is it", "remind me to call X". */
|
|
36
|
+
'simple'
|
|
37
|
+
/** Multiple distinct actions across phases. "send 25 emails after
|
|
38
|
+
* scraping data from Salesforce and SEO sources, then summarize". */
|
|
39
|
+
| 'multi-step'
|
|
40
|
+
/** Ambiguous — falls through to today's full chat path (safe default). */
|
|
41
|
+
| 'unknown';
|
|
42
|
+
export interface MessageShapeResult {
|
|
43
|
+
shape: MessageShape;
|
|
44
|
+
score: number;
|
|
45
|
+
reasons: string[];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Classify a chat message's structural shape (simple / multi-step).
|
|
49
|
+
*
|
|
50
|
+
* Scoring (sum of triggered signals):
|
|
51
|
+
* - 2+ shape-action verbs: +1
|
|
52
|
+
* - 3+ shape-action verbs: +2 cumulatively
|
|
53
|
+
* - each sequence marker ("and then", "after that"): +1 (up to +2)
|
|
54
|
+
* - batch marker ("for each", "25 emails"): +1
|
|
55
|
+
* - numbered list: +2
|
|
56
|
+
* - 2+ distinct integration domains in same message: +1
|
|
57
|
+
* - length > 200 chars: +1
|
|
58
|
+
* - length > 500 chars: +2 cumulatively
|
|
59
|
+
*
|
|
60
|
+
* Decision:
|
|
61
|
+
* - score >= threshold (default 3) → 'multi-step'
|
|
62
|
+
* - score === 0 AND <= 1 action verb AND length <= 200 → 'simple'
|
|
63
|
+
* - otherwise → 'unknown' (today's chat path, no change)
|
|
64
|
+
*/
|
|
65
|
+
export declare function classifyMessageShape(text: string, opts?: {
|
|
66
|
+
threshold?: number;
|
|
67
|
+
}): MessageShapeResult;
|
|
68
|
+
/**
|
|
69
|
+
* Detect whether the user's message is approving / revising / canceling
|
|
70
|
+
* a pending plan. Used by the chat-side plan-mode state machine when
|
|
71
|
+
* `sess.planAwaitingApproval` is set.
|
|
72
|
+
*
|
|
73
|
+
* Conservative: only short, clearly-affirmative messages qualify as
|
|
74
|
+
* approval. "yes but also do X" is NOT approval — it's a revision
|
|
75
|
+
* request and the state machine should re-plan with the feedback.
|
|
76
|
+
*/
|
|
77
|
+
export type PlanApprovalSignal = 'approve' | 'revise' | 'cancel' | 'other';
|
|
78
|
+
export declare function detectPlanApproval(message: string): PlanApprovalSignal;
|
|
34
79
|
/**
|
|
35
80
|
* Generate a follow-up suggestion prompt suffix based on completed work.
|
|
36
81
|
*
|
|
@@ -188,6 +188,150 @@ No tool calls needed. Just be conversational.
|
|
|
188
188
|
If there's relevant context from recent work or pending items, briefly mention it.`;
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
|
+
/** Action verbs that strongly suggest "do work" rather than "answer". */
|
|
192
|
+
const SHAPE_ACTION_VERBS = [
|
|
193
|
+
'send', 'create', 'build', 'generate', 'write', 'draft', 'compose',
|
|
194
|
+
'publish', 'deploy', 'upload', 'post', 'push',
|
|
195
|
+
'scrape', 'fetch', 'pull', 'extract', 'gather', 'collect',
|
|
196
|
+
'convert', 'merge', 'combine', 'transform', 'consolidate', 'aggregate',
|
|
197
|
+
'schedule', 'queue', 'run', 'execute', 'process',
|
|
198
|
+
'email', 'message', 'notify', 'alert', 'reply', 'forward',
|
|
199
|
+
'import', 'export', 'sync', 'backup',
|
|
200
|
+
];
|
|
201
|
+
const SHAPE_SEQUENCE_MARKERS = [
|
|
202
|
+
/\band\s+then\b/i,
|
|
203
|
+
/\b(?:after|once|when)\s+(?:that|you|done|finished|complete)/i,
|
|
204
|
+
/\b(?:then|next|finally|last)\s*[,]?\s+\w+/i,
|
|
205
|
+
/\bfollowed\s+by\b/i,
|
|
206
|
+
/\b(?:step|phase)\s+\d+/i,
|
|
207
|
+
];
|
|
208
|
+
const SHAPE_BATCH_MARKERS = [
|
|
209
|
+
/\b(?:for|on|to)\s+each\b/i,
|
|
210
|
+
/\b\d{2,}\s+\w+/, // "25 emails", "100 records"
|
|
211
|
+
/\beach\s+of\s+(?:them|the)\b/i,
|
|
212
|
+
/\b(?:all|every)\s+(?:of\s+)?(?:them|the\s+\w+)/i,
|
|
213
|
+
/\b(?:bulk|batch|mass)\b/i,
|
|
214
|
+
];
|
|
215
|
+
const SHAPE_NUMBERED_LIST = /\n\s*\d+[.)]\s+\w+/;
|
|
216
|
+
const SHAPE_DOMAIN_MARKERS = [
|
|
217
|
+
/\bsalesforce\b/i, /\bgmail\b/i, /\boutlook\b/i, /\bslack\b/i,
|
|
218
|
+
/\bdiscord\b/i, /\bnetlify\b/i, /\bvercel\b/i, /\bgithub\b/i,
|
|
219
|
+
/\bsupabase\b/i, /\bairtable\b/i, /\bhubspot\b/i, /\bnotion\b/i,
|
|
220
|
+
/\blinkedin\b/i, /\bcalendar\b/i, /\bdrive\b/i, /\bsheets\b/i,
|
|
221
|
+
];
|
|
222
|
+
/**
|
|
223
|
+
* Classify a chat message's structural shape (simple / multi-step).
|
|
224
|
+
*
|
|
225
|
+
* Scoring (sum of triggered signals):
|
|
226
|
+
* - 2+ shape-action verbs: +1
|
|
227
|
+
* - 3+ shape-action verbs: +2 cumulatively
|
|
228
|
+
* - each sequence marker ("and then", "after that"): +1 (up to +2)
|
|
229
|
+
* - batch marker ("for each", "25 emails"): +1
|
|
230
|
+
* - numbered list: +2
|
|
231
|
+
* - 2+ distinct integration domains in same message: +1
|
|
232
|
+
* - length > 200 chars: +1
|
|
233
|
+
* - length > 500 chars: +2 cumulatively
|
|
234
|
+
*
|
|
235
|
+
* Decision:
|
|
236
|
+
* - score >= threshold (default 3) → 'multi-step'
|
|
237
|
+
* - score === 0 AND <= 1 action verb AND length <= 200 → 'simple'
|
|
238
|
+
* - otherwise → 'unknown' (today's chat path, no change)
|
|
239
|
+
*/
|
|
240
|
+
export function classifyMessageShape(text, opts = {}) {
|
|
241
|
+
const reasons = [];
|
|
242
|
+
let score = 0;
|
|
243
|
+
if (!text || !text.trim()) {
|
|
244
|
+
return { shape: 'simple', score: 0, reasons: ['empty'] };
|
|
245
|
+
}
|
|
246
|
+
const trimmed = text.trim();
|
|
247
|
+
const lower = trimmed.toLowerCase();
|
|
248
|
+
const words = new Set(lower.replace(/[^\w\s-]/g, ' ').split(/\s+/).filter(Boolean));
|
|
249
|
+
// Action verbs
|
|
250
|
+
const matchedVerbs = [];
|
|
251
|
+
for (const verb of SHAPE_ACTION_VERBS) {
|
|
252
|
+
if (words.has(verb))
|
|
253
|
+
matchedVerbs.push(verb);
|
|
254
|
+
}
|
|
255
|
+
if (matchedVerbs.length >= 2) {
|
|
256
|
+
score += 1;
|
|
257
|
+
reasons.push(`2+ action verbs (${matchedVerbs.slice(0, 4).join(', ')})`);
|
|
258
|
+
}
|
|
259
|
+
if (matchedVerbs.length >= 3) {
|
|
260
|
+
score += 1;
|
|
261
|
+
reasons.push('3+ action verbs');
|
|
262
|
+
}
|
|
263
|
+
// Sequence markers
|
|
264
|
+
for (const rx of SHAPE_SEQUENCE_MARKERS) {
|
|
265
|
+
const matches = lower.match(new RegExp(rx.source, 'gi'));
|
|
266
|
+
if (matches && matches.length > 0) {
|
|
267
|
+
score += Math.min(matches.length, 2);
|
|
268
|
+
reasons.push(`sequence marker: ${matches[0]}`);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Batch markers
|
|
273
|
+
for (const rx of SHAPE_BATCH_MARKERS) {
|
|
274
|
+
if (rx.test(trimmed)) {
|
|
275
|
+
score += 1;
|
|
276
|
+
reasons.push('batch marker (for each / N items / bulk)');
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Numbered list
|
|
281
|
+
if (SHAPE_NUMBERED_LIST.test(trimmed)) {
|
|
282
|
+
score += 2;
|
|
283
|
+
reasons.push('numbered list');
|
|
284
|
+
}
|
|
285
|
+
// Cross-domain
|
|
286
|
+
let domainCount = 0;
|
|
287
|
+
for (const rx of SHAPE_DOMAIN_MARKERS) {
|
|
288
|
+
if (rx.test(trimmed))
|
|
289
|
+
domainCount += 1;
|
|
290
|
+
}
|
|
291
|
+
if (domainCount >= 2) {
|
|
292
|
+
score += 1;
|
|
293
|
+
reasons.push(`${domainCount} integration domains mentioned`);
|
|
294
|
+
}
|
|
295
|
+
// Length
|
|
296
|
+
if (trimmed.length > 200) {
|
|
297
|
+
score += 1;
|
|
298
|
+
reasons.push(`length > 200 (${trimmed.length})`);
|
|
299
|
+
}
|
|
300
|
+
if (trimmed.length > 500) {
|
|
301
|
+
score += 1;
|
|
302
|
+
reasons.push(`length > 500`);
|
|
303
|
+
}
|
|
304
|
+
// Decision
|
|
305
|
+
const threshold = opts.threshold ?? 3;
|
|
306
|
+
let shape;
|
|
307
|
+
if (score >= threshold) {
|
|
308
|
+
shape = 'multi-step';
|
|
309
|
+
}
|
|
310
|
+
else if (score === 0 && matchedVerbs.length <= 1 && trimmed.length <= 200) {
|
|
311
|
+
shape = 'simple';
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
shape = 'unknown';
|
|
315
|
+
}
|
|
316
|
+
return { shape, score, reasons };
|
|
317
|
+
}
|
|
318
|
+
const APPROVE_RE = /^(?:yes|y|yep|yeah|yup|sure|ok|okay|approve|approved|go|go ahead|run it|do it|sounds good|lgtm|ship it|👍|✅)[\s.!]*$/i;
|
|
319
|
+
const CANCEL_RE = /^(?:cancel|stop|nvm|nevermind|never\s*mind|forget it|don['']?t|abort|kill it)\b/i;
|
|
320
|
+
const REVISE_RE = /\b(?:but|except|instead|change|modify|add(?:\s+also)?|remove|skip|swap|wait|actually|hold on)\b/i;
|
|
321
|
+
export function detectPlanApproval(message) {
|
|
322
|
+
if (!message)
|
|
323
|
+
return 'other';
|
|
324
|
+
const text = message.trim();
|
|
325
|
+
if (!text)
|
|
326
|
+
return 'other';
|
|
327
|
+
if (CANCEL_RE.test(text))
|
|
328
|
+
return 'cancel';
|
|
329
|
+
if (text.length <= 30 && APPROVE_RE.test(text))
|
|
330
|
+
return 'approve';
|
|
331
|
+
if (text.length > 30 || REVISE_RE.test(text))
|
|
332
|
+
return 'revise';
|
|
333
|
+
return 'other';
|
|
334
|
+
}
|
|
191
335
|
/**
|
|
192
336
|
* Generate a follow-up suggestion prompt suffix based on completed work.
|
|
193
337
|
*
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -88,6 +88,31 @@ export declare class Gateway {
|
|
|
88
88
|
private queueBackgroundOffer;
|
|
89
89
|
private formatBackgroundQueuedResponse;
|
|
90
90
|
private queueBackgroundTaskAfterContextOverflow;
|
|
91
|
+
/**
|
|
92
|
+
* 1.18.191 — chat-side plan mode state machine.
|
|
93
|
+
*
|
|
94
|
+
* Two paths it handles:
|
|
95
|
+
*
|
|
96
|
+
* A. Approval-pending path. If sess.planAwaitingApproval is set,
|
|
97
|
+
* the user's message NOW is either approval / revision / cancel.
|
|
98
|
+
* Approval → dispatch the chain. Revision → re-plan with feedback.
|
|
99
|
+
* Cancel → clear pending state.
|
|
100
|
+
*
|
|
101
|
+
* B. Multi-step entry path. shape='multi-step' AND no pending plan:
|
|
102
|
+
* run planRequest synchronously, post the plan to chat asking
|
|
103
|
+
* for approval, set pending state. The owner's NEXT message
|
|
104
|
+
* advances via path A.
|
|
105
|
+
*
|
|
106
|
+
* Returns `{ handled: true, response }` when plan mode owns this turn
|
|
107
|
+
* (caller should return the response without running normal chat).
|
|
108
|
+
* Returns `{ handled: false }` to fall through to normal chat.
|
|
109
|
+
*
|
|
110
|
+
* Defensive on every external call — failures degrade to normal chat
|
|
111
|
+
* rather than blocking the owner's conversation.
|
|
112
|
+
*/
|
|
113
|
+
private _maybeHandlePlanMode;
|
|
114
|
+
/** Format a plan for owner approval in chat. */
|
|
115
|
+
private _formatPlanForApproval;
|
|
91
116
|
acceptBackgroundOffer(sessionKey: string, id: string): {
|
|
92
117
|
ok: boolean;
|
|
93
118
|
response: string;
|
package/dist/gateway/router.js
CHANGED
|
@@ -469,6 +469,163 @@ export class Gateway {
|
|
|
469
469
|
].join('\n'),
|
|
470
470
|
};
|
|
471
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* 1.18.191 — chat-side plan mode state machine.
|
|
474
|
+
*
|
|
475
|
+
* Two paths it handles:
|
|
476
|
+
*
|
|
477
|
+
* A. Approval-pending path. If sess.planAwaitingApproval is set,
|
|
478
|
+
* the user's message NOW is either approval / revision / cancel.
|
|
479
|
+
* Approval → dispatch the chain. Revision → re-plan with feedback.
|
|
480
|
+
* Cancel → clear pending state.
|
|
481
|
+
*
|
|
482
|
+
* B. Multi-step entry path. shape='multi-step' AND no pending plan:
|
|
483
|
+
* run planRequest synchronously, post the plan to chat asking
|
|
484
|
+
* for approval, set pending state. The owner's NEXT message
|
|
485
|
+
* advances via path A.
|
|
486
|
+
*
|
|
487
|
+
* Returns `{ handled: true, response }` when plan mode owns this turn
|
|
488
|
+
* (caller should return the response without running normal chat).
|
|
489
|
+
* Returns `{ handled: false }` to fall through to normal chat.
|
|
490
|
+
*
|
|
491
|
+
* Defensive on every external call — failures degrade to normal chat
|
|
492
|
+
* rather than blocking the owner's conversation.
|
|
493
|
+
*/
|
|
494
|
+
async _maybeHandlePlanMode(opts) {
|
|
495
|
+
const sess = this.sessions.get(opts.sessionKey);
|
|
496
|
+
const { detectPlanApproval } = await import('../agent/intent-classifier.js');
|
|
497
|
+
const { planRequest, savePlan, loadPlan } = await import('../agent/bg-planner.js');
|
|
498
|
+
const { dispatchChain } = await import('../agent/bg-orchestrator.js');
|
|
499
|
+
// ── Path A: approval-pending ────────────────────────────────────
|
|
500
|
+
if (sess?.planAwaitingApproval) {
|
|
501
|
+
const pending = sess.planAwaitingApproval;
|
|
502
|
+
const signal = detectPlanApproval(opts.userMessage);
|
|
503
|
+
logger.info({
|
|
504
|
+
sessionKey: opts.sessionKey,
|
|
505
|
+
planId: pending.planId,
|
|
506
|
+
chainId: pending.chainId,
|
|
507
|
+
signal,
|
|
508
|
+
}, 'Plan mode: approval signal received');
|
|
509
|
+
const plan = loadPlan(pending.planId, opts.activeProject?.path);
|
|
510
|
+
if (!plan) {
|
|
511
|
+
// Plan disappeared from disk — clear the pending state and
|
|
512
|
+
// let the message fall through to normal chat.
|
|
513
|
+
delete sess.planAwaitingApproval;
|
|
514
|
+
logger.warn({ planId: pending.planId }, 'Plan mode: pending plan not found on disk — clearing');
|
|
515
|
+
return { handled: false };
|
|
516
|
+
}
|
|
517
|
+
if (signal === 'approve') {
|
|
518
|
+
try {
|
|
519
|
+
const firstTask = dispatchChain(plan);
|
|
520
|
+
delete sess.planAwaitingApproval;
|
|
521
|
+
const response = [
|
|
522
|
+
`**Plan approved — starting step 1: ${plan.steps[0]?.title ?? '(first step)'}**`,
|
|
523
|
+
'',
|
|
524
|
+
`Background task **${firstTask.id}** is now running.`,
|
|
525
|
+
'I\'ll post step-by-step updates as each step completes.',
|
|
526
|
+
].join('\n');
|
|
527
|
+
return { handled: true, response };
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
logger.warn({ err, planId: plan.id }, 'Plan mode: dispatchChain failed on approval');
|
|
531
|
+
return { handled: true, response: `Couldn't start the chain: ${String(err).slice(0, 200)}. Tell me to retry or try a different approach.` };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (signal === 'cancel') {
|
|
535
|
+
delete sess.planAwaitingApproval;
|
|
536
|
+
return { handled: true, response: 'Cancelled the plan. Tell me what you\'d like to do instead.' };
|
|
537
|
+
}
|
|
538
|
+
if (signal === 'revise') {
|
|
539
|
+
// Re-plan with the user's revision as additional context.
|
|
540
|
+
try {
|
|
541
|
+
const revisedRequest = `${plan.userRequest}\n\n[Revision from owner: ${opts.userMessage}]`;
|
|
542
|
+
const newPlan = await planRequest({
|
|
543
|
+
userRequest: revisedRequest,
|
|
544
|
+
originatingSessionKey: opts.sessionKey,
|
|
545
|
+
...(opts.activeProject ? { project: opts.activeProject } : {}),
|
|
546
|
+
});
|
|
547
|
+
savePlan(newPlan, newPlan.projectPath);
|
|
548
|
+
sess.planAwaitingApproval = {
|
|
549
|
+
planId: newPlan.id,
|
|
550
|
+
chainId: newPlan.chainId,
|
|
551
|
+
proposedAt: Date.now(),
|
|
552
|
+
};
|
|
553
|
+
return {
|
|
554
|
+
handled: true,
|
|
555
|
+
response: this._formatPlanForApproval(newPlan, /* revised */ true),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
catch (err) {
|
|
559
|
+
logger.warn({ err }, 'Plan mode: revision planRequest failed');
|
|
560
|
+
return { handled: true, response: `Couldn't revise the plan: ${String(err).slice(0, 200)}. Want me to start fresh or try something else?` };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// signal='other' — let it fall through to normal chat, but keep
|
|
564
|
+
// pending state. The model will see the user message normally.
|
|
565
|
+
return { handled: false };
|
|
566
|
+
}
|
|
567
|
+
// ── Path B: multi-step entry ────────────────────────────────────
|
|
568
|
+
if (opts.shape === 'multi-step' && sess) {
|
|
569
|
+
try {
|
|
570
|
+
// Stream a "thinking..." update so the user knows planning is
|
|
571
|
+
// happening rather than seeing 30s of silence.
|
|
572
|
+
if (opts.onText) {
|
|
573
|
+
try {
|
|
574
|
+
opts.onText('🤔 Planning the steps...');
|
|
575
|
+
}
|
|
576
|
+
catch { /* non-fatal */ }
|
|
577
|
+
}
|
|
578
|
+
const plan = await planRequest({
|
|
579
|
+
userRequest: opts.userMessage,
|
|
580
|
+
originatingSessionKey: opts.sessionKey,
|
|
581
|
+
...(opts.activeProject ? { project: opts.activeProject } : {}),
|
|
582
|
+
});
|
|
583
|
+
savePlan(plan, plan.projectPath);
|
|
584
|
+
sess.planAwaitingApproval = {
|
|
585
|
+
planId: plan.id,
|
|
586
|
+
chainId: plan.chainId,
|
|
587
|
+
proposedAt: Date.now(),
|
|
588
|
+
};
|
|
589
|
+
return {
|
|
590
|
+
handled: true,
|
|
591
|
+
response: this._formatPlanForApproval(plan, /* revised */ false),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
logger.warn({ err, sessionKey: opts.sessionKey }, 'Plan mode: planRequest failed at entry');
|
|
596
|
+
// Fall through to normal chat. Better than blocking the owner.
|
|
597
|
+
return { handled: false };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Not a plan-mode case — fall through to normal chat.
|
|
601
|
+
return { handled: false };
|
|
602
|
+
}
|
|
603
|
+
/** Format a plan for owner approval in chat. */
|
|
604
|
+
_formatPlanForApproval(plan, revised) {
|
|
605
|
+
const lines = [];
|
|
606
|
+
lines.push(revised
|
|
607
|
+
? `**Revised plan (${plan.steps.length} steps)**`
|
|
608
|
+
: `**Here's how I'd do this (${plan.steps.length} steps)**`);
|
|
609
|
+
lines.push('');
|
|
610
|
+
for (const step of plan.steps) {
|
|
611
|
+
lines.push(`${step.index + 1}. **${step.title}**`);
|
|
612
|
+
if (step.scope)
|
|
613
|
+
lines.push(` ${step.scope}`);
|
|
614
|
+
if (step.deliverable)
|
|
615
|
+
lines.push(` → ${step.deliverable}`);
|
|
616
|
+
}
|
|
617
|
+
if (plan.notes) {
|
|
618
|
+
lines.push('');
|
|
619
|
+
lines.push(`_Notes_: ${plan.notes}`);
|
|
620
|
+
}
|
|
621
|
+
if (typeof plan.estimatedCostUsd === 'number') {
|
|
622
|
+
lines.push('');
|
|
623
|
+
lines.push(`Estimated cost: ~$${plan.estimatedCostUsd.toFixed(2)}`);
|
|
624
|
+
}
|
|
625
|
+
lines.push('');
|
|
626
|
+
lines.push('Say **yes / approve / go** to start, **cancel** to skip, or describe a revision (e.g., "swap step 3" or "add a verification step").');
|
|
627
|
+
return lines.join('\n');
|
|
628
|
+
}
|
|
472
629
|
// Offer-message formatter was removed in the Saturday-feel restoration —
|
|
473
630
|
// the chat path no longer asks "want me to run this in the background?".
|
|
474
631
|
// Auto-queue on explicit user intent is silent; everything else just runs.
|
|
@@ -2183,6 +2340,56 @@ export class Gateway {
|
|
|
2183
2340
|
const { buildClementineTurnContext } = await import('../agent/clementine-turn-context.js');
|
|
2184
2341
|
const { listBackgroundTasks } = await import('../agent/background-tasks.js');
|
|
2185
2342
|
const { resolveProjectFromMessage } = await import('../agent/project-resolver.js');
|
|
2343
|
+
const { classifyMessageShape } = await import('../agent/intent-classifier.js');
|
|
2344
|
+
// 1.18.191 — classify message shape early. Simple messages
|
|
2345
|
+
// get a lean turn-context block (no memory recall, no bg
|
|
2346
|
+
// headlines, no dispute gate); multi-step messages keep
|
|
2347
|
+
// the full block AND may trigger plan mode (below).
|
|
2348
|
+
// Builder sessions skip — they have their own routing.
|
|
2349
|
+
const shapeResult = !isBuilderSession
|
|
2350
|
+
? classifyMessageShape(originalText)
|
|
2351
|
+
: { shape: 'simple', score: 0, reasons: ['builder-session'] };
|
|
2352
|
+
logger.debug({
|
|
2353
|
+
sessionKey: effectiveSessionKey,
|
|
2354
|
+
shape: shapeResult.shape,
|
|
2355
|
+
score: shapeResult.score,
|
|
2356
|
+
reasons: shapeResult.reasons,
|
|
2357
|
+
}, 'Message shape classified');
|
|
2358
|
+
// 1.18.191 — plan mode state machine.
|
|
2359
|
+
//
|
|
2360
|
+
// Two entry points for plan mode:
|
|
2361
|
+
//
|
|
2362
|
+
// A. Approval-pending path. If the previous turn proposed a
|
|
2363
|
+
// plan, the user's message NOW is either approval,
|
|
2364
|
+
// revision, or cancel. Handle and return without
|
|
2365
|
+
// running normal chat.
|
|
2366
|
+
//
|
|
2367
|
+
// B. Multi-step entry path. If shape='multi-step' AND no
|
|
2368
|
+
// pending plan, generate a plan, post it, set pending
|
|
2369
|
+
// state, return. The owner's NEXT message advances via
|
|
2370
|
+
// path A.
|
|
2371
|
+
//
|
|
2372
|
+
// Builder sessions skip both paths.
|
|
2373
|
+
if (!isBuilderSession) {
|
|
2374
|
+
const planMode = await this._maybeHandlePlanMode({
|
|
2375
|
+
sessionKey: effectiveSessionKey,
|
|
2376
|
+
userMessage: originalText,
|
|
2377
|
+
shape: shapeResult.shape,
|
|
2378
|
+
activeProject: this.getSessionProject(effectiveSessionKey) ?? null,
|
|
2379
|
+
onText: wrappedOnText,
|
|
2380
|
+
});
|
|
2381
|
+
if (planMode.handled) {
|
|
2382
|
+
clearTimeout(chatTimer);
|
|
2383
|
+
if (hardWallTimer)
|
|
2384
|
+
clearTimeout(hardWallTimer);
|
|
2385
|
+
{
|
|
2386
|
+
const cs = this.sessions.get(sessionKey);
|
|
2387
|
+
if (cs)
|
|
2388
|
+
delete cs.abortController;
|
|
2389
|
+
}
|
|
2390
|
+
return planMode.response;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2186
2393
|
// 1.18.187 — auto-resolve project from the user's message.
|
|
2187
2394
|
// If a linked project's name/keyword matches with high
|
|
2188
2395
|
// confidence, set sess.project for this turn so cwd shifts,
|
|
@@ -2294,6 +2501,10 @@ export class Gateway {
|
|
|
2294
2501
|
// 1.18.187 — pass active project so the turn-context block
|
|
2295
2502
|
// can include path / STATUS.md / inventory / deploy config.
|
|
2296
2503
|
activeProject: this.getSessionProject(effectiveSessionKey) ?? null,
|
|
2504
|
+
// 1.18.191 — pass message shape so simple messages get the
|
|
2505
|
+
// lean turn-context (skip memory recall, bg-task headlines,
|
|
2506
|
+
// dispute gate). Token-optimization for routine chat.
|
|
2507
|
+
messageShape: shapeResult.shape,
|
|
2297
2508
|
});
|
|
2298
2509
|
clementineContextBlock = turnCtx.block;
|
|
2299
2510
|
logger.debug({
|