smart-context-mcp 1.6.2 → 1.7.1
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/README.md +2 -2
- package/package.json +1 -1
- package/scripts/headless-wrapper.js +2 -1
- package/scripts/report-metrics.js +2 -2
- package/scripts/task-runner.js +2 -1
- package/src/analytics/product-quality.js +176 -1
- package/src/hooks/claude-hooks.js +1 -423
- package/src/hooks/cursor-hooks.js +1 -0
- package/src/orchestration/adapters/claude-adapter.js +426 -0
- package/src/orchestration/adapters/cursor-adapter.js +429 -0
- package/src/orchestration/base-orchestrator.js +242 -0
- package/src/orchestration/headless-wrapper.js +39 -195
- package/src/orchestration/policy/event-policy.js +297 -0
- package/src/task-runner.js +33 -247
- package/src/utils/client-detection.js +33 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { persistMetrics } from '../../metrics.js';
|
|
2
|
+
import { buildOperationalContextLines } from '../../client-contract.js';
|
|
3
|
+
import { getRepoMutationSafety } from '../../repo-safety.js';
|
|
4
|
+
import { countTokens } from '../../tokenCounter.js';
|
|
5
|
+
import { smartSummary } from '../../tools/smart-summary.js';
|
|
6
|
+
import { smartTurn } from '../../tools/smart-turn.js';
|
|
7
|
+
import {
|
|
8
|
+
deleteHookTurnState,
|
|
9
|
+
getHookTurnState,
|
|
10
|
+
setHookTurnState,
|
|
11
|
+
} from '../../storage/sqlite.js';
|
|
12
|
+
import { DEFAULT_START_MAX_TOKENS, resolveManagedStart } from '../base-orchestrator.js';
|
|
13
|
+
import { extractNextStep, normalizeWhitespace, truncate } from '../policy/event-policy.js';
|
|
14
|
+
|
|
15
|
+
export const HOOK_CLIENT = 'cursor';
|
|
16
|
+
export const STOP_MAX_TOKENS = 300;
|
|
17
|
+
export const MAX_CONTEXT_LINES = 7;
|
|
18
|
+
export const MAX_CONTEXT_CHARS = 420;
|
|
19
|
+
export const MAX_PROMPT_PREVIEW = 160;
|
|
20
|
+
export const MAX_TOUCHED_FILES = 12;
|
|
21
|
+
export const MIN_MEANINGFUL_PROMPT_LENGTH = 20;
|
|
22
|
+
export const MIN_PROMPT_TERMS = 4;
|
|
23
|
+
export const SIGNIFICANT_RESPONSE_LENGTH = 140;
|
|
24
|
+
export const WRITE_TOOLS = new Set(['Write', 'StrReplace', 'Delete', 'EditNotebook']);
|
|
25
|
+
|
|
26
|
+
const uniq = (values) => [...new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0))];
|
|
27
|
+
|
|
28
|
+
export const buildCursorHookKey = ({ conversationId, agentId = null }) =>
|
|
29
|
+
agentId ? `${HOOK_CLIENT}:subagent:${conversationId}:${agentId}` : `${HOOK_CLIENT}:main:${conversationId}`;
|
|
30
|
+
|
|
31
|
+
const countPromptTerms = (value) =>
|
|
32
|
+
normalizeWhitespace(value)
|
|
33
|
+
.split(/[^a-z0-9_.-]+/i)
|
|
34
|
+
.map((term) => term.trim())
|
|
35
|
+
.filter((term) => term.length >= 3)
|
|
36
|
+
.length;
|
|
37
|
+
|
|
38
|
+
export const isMeaningfulPrompt = (value) => {
|
|
39
|
+
const normalized = normalizeWhitespace(value);
|
|
40
|
+
return normalized.length >= MIN_MEANINGFUL_PROMPT_LENGTH && countPromptTerms(normalized) >= MIN_PROMPT_TERMS;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const buildCursorAdditionalContext = ({ result, sessionStart = false }) =>
|
|
44
|
+
buildOperationalContextLines(result, {
|
|
45
|
+
sessionStart,
|
|
46
|
+
maxLineLength: 110,
|
|
47
|
+
maxLines: MAX_CONTEXT_LINES,
|
|
48
|
+
maxChars: MAX_CONTEXT_CHARS,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const buildCursorHookContextResponse = (hookEventName, additionalContext) => {
|
|
52
|
+
if (!additionalContext) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
hookSpecificOutput: {
|
|
58
|
+
hookEventName,
|
|
59
|
+
additionalContext,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const isSmartTurnTool = (toolName) => /^mcp__.+__smart_turn$/i.test(toolName ?? '');
|
|
65
|
+
const isSmartSummaryTool = (toolName) => /^mcp__.+__smart_summary$/i.test(toolName ?? '');
|
|
66
|
+
|
|
67
|
+
export const isCheckpointToolUse = ({ toolName, toolInput }) => {
|
|
68
|
+
if (isSmartTurnTool(toolName)) {
|
|
69
|
+
return toolInput?.phase === 'end'
|
|
70
|
+
? { matched: true, event: toolInput?.event ?? 'manual' }
|
|
71
|
+
: { matched: false, event: null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (isSmartSummaryTool(toolName)) {
|
|
75
|
+
const action = toolInput?.action;
|
|
76
|
+
if (action === 'checkpoint') {
|
|
77
|
+
return { matched: true, event: toolInput?.event ?? 'manual' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (action === 'append' || action === 'auto_append' || action === 'update') {
|
|
81
|
+
return { matched: true, event: action };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { matched: false, event: null };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const extractTouchedFilesFromToolUse = ({ toolName, toolInput, toolResponse }) => {
|
|
89
|
+
if (!WRITE_TOOLS.has(toolName)) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return uniq([
|
|
94
|
+
toolInput?.path,
|
|
95
|
+
toolInput?.file_path,
|
|
96
|
+
toolInput?.filePath,
|
|
97
|
+
toolInput?.target_notebook,
|
|
98
|
+
toolResponse?.path,
|
|
99
|
+
toolResponse?.file_path,
|
|
100
|
+
toolResponse?.filePath,
|
|
101
|
+
]).slice(0, MAX_TOUCHED_FILES);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const buildCarryoverUpdate = (state, lastAssistantMessage) => {
|
|
105
|
+
const promptPreview = truncate(state.promptPreview, 140);
|
|
106
|
+
const nextStep = extractNextStep(lastAssistantMessage);
|
|
107
|
+
const pinnedContext = promptPreview ? [`Uncheckpointed turn: ${promptPreview}`] : [];
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
...(promptPreview ? { currentFocus: promptPreview } : {}),
|
|
111
|
+
...(pinnedContext.length > 0 ? { pinnedContext } : {}),
|
|
112
|
+
...(state.touchedFiles.length > 0 ? { touchedFiles: state.touchedFiles } : {}),
|
|
113
|
+
...(nextStep ? { nextStep } : {}),
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const computeStopEnforcement = (state, lastAssistantMessage) => {
|
|
118
|
+
const nextStep = extractNextStep(lastAssistantMessage);
|
|
119
|
+
const responseLength = normalizeWhitespace(lastAssistantMessage).length;
|
|
120
|
+
let score = 0;
|
|
121
|
+
|
|
122
|
+
if (state.meaningfulWriteCount > 0) {
|
|
123
|
+
score += 3;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (state.touchedFiles.length > 0) {
|
|
127
|
+
score += 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (nextStep) {
|
|
131
|
+
score += 2;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (responseLength >= SIGNIFICANT_RESPONSE_LENGTH) {
|
|
135
|
+
score += 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (state.continuityState === 'task_switch' || state.continuityState === 'possible_shift') {
|
|
139
|
+
score += 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
shouldBlock: score >= 3,
|
|
144
|
+
score,
|
|
145
|
+
nextStep,
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const createCursorAdapter = ({
|
|
150
|
+
startTurn = smartTurn,
|
|
151
|
+
summaryTool = smartSummary,
|
|
152
|
+
resolveStart = resolveManagedStart,
|
|
153
|
+
persistMetric = persistMetrics,
|
|
154
|
+
getMutationSafety = getRepoMutationSafety,
|
|
155
|
+
readHookState = null,
|
|
156
|
+
writeHookState = ({ hookKey, state }) => setHookTurnState({ hookKey, state }),
|
|
157
|
+
removeHookState = ({ hookKey }) => deleteHookTurnState({ hookKey }),
|
|
158
|
+
} = {}) => {
|
|
159
|
+
const readTrackedHookState = async (hookKey) => getHookTurnState({
|
|
160
|
+
hookKey,
|
|
161
|
+
readOnly: getMutationSafety().shouldBlock,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const readTrackedState = readHookState ?? readTrackedHookState;
|
|
165
|
+
|
|
166
|
+
const maybeSetTrackedTurnState = async ({ hookKey, state }) => {
|
|
167
|
+
if (getMutationSafety().shouldBlock) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return writeHookState({ hookKey, state });
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const maybeDeleteTrackedTurnState = async ({ hookKey }) => {
|
|
175
|
+
if (getMutationSafety().shouldBlock) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return removeHookState({ hookKey });
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const recordHookMetrics = async ({
|
|
183
|
+
action,
|
|
184
|
+
sessionId,
|
|
185
|
+
additionalContext = '',
|
|
186
|
+
blocked = false,
|
|
187
|
+
autoAppended = false,
|
|
188
|
+
continuityState = null,
|
|
189
|
+
} = {}) => {
|
|
190
|
+
const overheadTokens = additionalContext ? countTokens(additionalContext) : 0;
|
|
191
|
+
const autoStartTriggered = action === 'ConversationStart' || action === 'UserMessageSubmit';
|
|
192
|
+
const autoCheckpointTriggered = action === 'ConversationEnd' && autoAppended;
|
|
193
|
+
|
|
194
|
+
await persistMetric({
|
|
195
|
+
tool: 'cursor_hook',
|
|
196
|
+
action,
|
|
197
|
+
sessionId,
|
|
198
|
+
rawTokens: 0,
|
|
199
|
+
compressedTokens: 0,
|
|
200
|
+
savedTokens: 0,
|
|
201
|
+
savingsPct: 0,
|
|
202
|
+
metadata: {
|
|
203
|
+
client: HOOK_CLIENT,
|
|
204
|
+
adapterClient: HOOK_CLIENT,
|
|
205
|
+
managedByClientAdapter: true,
|
|
206
|
+
autoStartTriggered,
|
|
207
|
+
autoCheckpointTriggered,
|
|
208
|
+
isContextOverhead: overheadTokens > 0,
|
|
209
|
+
overheadTokens,
|
|
210
|
+
blocked,
|
|
211
|
+
autoAppended,
|
|
212
|
+
continuityState,
|
|
213
|
+
},
|
|
214
|
+
timestamp: new Date().toISOString(),
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const maybeTrackTurn = async ({
|
|
219
|
+
hookKey,
|
|
220
|
+
cursorConversationId,
|
|
221
|
+
projectSessionId,
|
|
222
|
+
prompt,
|
|
223
|
+
continuityState,
|
|
224
|
+
}) => {
|
|
225
|
+
const promptMeaningful = isMeaningfulPrompt(prompt);
|
|
226
|
+
const shouldTrack = Boolean(projectSessionId) && promptMeaningful;
|
|
227
|
+
|
|
228
|
+
if (!shouldTrack) {
|
|
229
|
+
await maybeDeleteTrackedTurnState({ hookKey });
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return maybeSetTrackedTurnState({
|
|
234
|
+
hookKey,
|
|
235
|
+
state: {
|
|
236
|
+
client: HOOK_CLIENT,
|
|
237
|
+
cursorConversationId,
|
|
238
|
+
projectSessionId,
|
|
239
|
+
turnId: `${cursorConversationId}:${Date.now()}`,
|
|
240
|
+
promptPreview: truncate(prompt, MAX_PROMPT_PREVIEW),
|
|
241
|
+
continuityState,
|
|
242
|
+
requireCheckpoint: true,
|
|
243
|
+
promptMeaningful,
|
|
244
|
+
checkpointed: false,
|
|
245
|
+
checkpointEvent: null,
|
|
246
|
+
touchedFiles: [],
|
|
247
|
+
meaningfulWriteCount: 0,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const handleConversationStart = async () => {
|
|
253
|
+
const result = await startTurn({
|
|
254
|
+
phase: 'start',
|
|
255
|
+
maxTokens: DEFAULT_START_MAX_TOKENS,
|
|
256
|
+
});
|
|
257
|
+
const additionalContext = buildCursorAdditionalContext({ result, sessionStart: true });
|
|
258
|
+
await recordHookMetrics({
|
|
259
|
+
action: 'ConversationStart',
|
|
260
|
+
sessionId: result.sessionId ?? null,
|
|
261
|
+
additionalContext,
|
|
262
|
+
continuityState: result.continuity?.state ?? null,
|
|
263
|
+
});
|
|
264
|
+
return buildCursorHookContextResponse('ConversationStart', additionalContext);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const handleUserMessageSubmit = async (input) => {
|
|
268
|
+
const startResolution = await resolveStart({
|
|
269
|
+
prompt: input.user_message,
|
|
270
|
+
ensureSession: true,
|
|
271
|
+
allowIsolation: false,
|
|
272
|
+
startTurn,
|
|
273
|
+
summaryTool,
|
|
274
|
+
startMaxTokens: DEFAULT_START_MAX_TOKENS,
|
|
275
|
+
});
|
|
276
|
+
const result = startResolution.startResult;
|
|
277
|
+
|
|
278
|
+
const trackedState = await maybeTrackTurn({
|
|
279
|
+
hookKey: buildCursorHookKey({ conversationId: input.conversation_id }),
|
|
280
|
+
cursorConversationId: input.conversation_id,
|
|
281
|
+
projectSessionId: result.sessionId ?? null,
|
|
282
|
+
prompt: input.user_message,
|
|
283
|
+
continuityState: result.continuity?.state ?? '',
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const additionalContext = buildCursorAdditionalContext({ result });
|
|
287
|
+
await recordHookMetrics({
|
|
288
|
+
action: 'UserMessageSubmit',
|
|
289
|
+
sessionId: trackedState?.projectSessionId ?? result.sessionId ?? null,
|
|
290
|
+
additionalContext,
|
|
291
|
+
continuityState: result.continuity?.state ?? null,
|
|
292
|
+
});
|
|
293
|
+
return buildCursorHookContextResponse('UserMessageSubmit', additionalContext);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const handlePostToolUse = async (input) => {
|
|
297
|
+
const hookKey = buildCursorHookKey({ conversationId: input.conversation_id });
|
|
298
|
+
const existing = await readTrackedState(hookKey);
|
|
299
|
+
if (!existing) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const checkpoint = isCheckpointToolUse({
|
|
304
|
+
toolName: input.tool_name,
|
|
305
|
+
toolInput: input.tool_input,
|
|
306
|
+
});
|
|
307
|
+
const touchedFiles = extractTouchedFilesFromToolUse({
|
|
308
|
+
toolName: input.tool_name,
|
|
309
|
+
toolInput: input.tool_input,
|
|
310
|
+
toolResponse: input.tool_response,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const nextState = {
|
|
314
|
+
...existing,
|
|
315
|
+
checkpointed: checkpoint.matched ? true : existing.checkpointed,
|
|
316
|
+
checkpointEvent: checkpoint.matched ? checkpoint.event : existing.checkpointEvent,
|
|
317
|
+
touchedFiles: uniq([...existing.touchedFiles, ...touchedFiles]).slice(0, MAX_TOUCHED_FILES),
|
|
318
|
+
meaningfulWriteCount: existing.meaningfulWriteCount + touchedFiles.length,
|
|
319
|
+
updatedAt: new Date().toISOString(),
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
await maybeSetTrackedTurnState({ hookKey, state: nextState });
|
|
323
|
+
if (checkpoint.matched || touchedFiles.length > 0) {
|
|
324
|
+
await recordHookMetrics({
|
|
325
|
+
action: 'PostToolUse',
|
|
326
|
+
sessionId: existing.projectSessionId,
|
|
327
|
+
additionalContext: '',
|
|
328
|
+
continuityState: existing.continuityState,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const handleConversationEnd = async (input) => {
|
|
335
|
+
const hookKey = buildCursorHookKey({ conversationId: input.conversation_id });
|
|
336
|
+
const state = await readTrackedState(hookKey);
|
|
337
|
+
if (!state) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (getMutationSafety().shouldBlock) {
|
|
342
|
+
await recordHookMetrics({
|
|
343
|
+
action: 'ConversationEnd',
|
|
344
|
+
sessionId: state.projectSessionId,
|
|
345
|
+
additionalContext: '',
|
|
346
|
+
blocked: false,
|
|
347
|
+
continuityState: state.continuityState,
|
|
348
|
+
});
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const enforcement = computeStopEnforcement(state, input.last_assistant_message ?? '');
|
|
353
|
+
const shouldEnforce = (state.requireCheckpoint || state.meaningfulWriteCount > 0) && enforcement.shouldBlock;
|
|
354
|
+
if (!shouldEnforce || state.checkpointed) {
|
|
355
|
+
await recordHookMetrics({
|
|
356
|
+
action: 'ConversationEnd',
|
|
357
|
+
sessionId: state.projectSessionId,
|
|
358
|
+
additionalContext: '',
|
|
359
|
+
blocked: false,
|
|
360
|
+
continuityState: state.continuityState,
|
|
361
|
+
});
|
|
362
|
+
await maybeDeleteTrackedTurnState({ hookKey });
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (input.end_hook_active) {
|
|
367
|
+
const update = buildCarryoverUpdate(state, input.last_assistant_message ?? '');
|
|
368
|
+
if (state.projectSessionId && Object.keys(update).length > 0) {
|
|
369
|
+
await summaryTool({
|
|
370
|
+
action: 'auto_append',
|
|
371
|
+
sessionId: state.projectSessionId,
|
|
372
|
+
update,
|
|
373
|
+
maxTokens: STOP_MAX_TOKENS,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
await recordHookMetrics({
|
|
378
|
+
action: 'ConversationEnd',
|
|
379
|
+
sessionId: state.projectSessionId,
|
|
380
|
+
additionalContext: '',
|
|
381
|
+
blocked: false,
|
|
382
|
+
autoAppended: true,
|
|
383
|
+
continuityState: state.continuityState,
|
|
384
|
+
});
|
|
385
|
+
await maybeDeleteTrackedTurnState({ hookKey });
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
await recordHookMetrics({
|
|
390
|
+
action: 'ConversationEnd',
|
|
391
|
+
sessionId: state.projectSessionId,
|
|
392
|
+
additionalContext: '',
|
|
393
|
+
blocked: true,
|
|
394
|
+
continuityState: state.continuityState,
|
|
395
|
+
});
|
|
396
|
+
return {
|
|
397
|
+
decision: 'block',
|
|
398
|
+
reason: `Persist this turn with mcp__devctx__smart_turn phase=end before ending conversation.${state.touchedFiles.length > 0 ? ' Include touchedFiles and the nextStep.' : ' Include the nextStep.'}`,
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const handleEvent = async (input = {}) => {
|
|
403
|
+
const eventName = input.hook_event_name;
|
|
404
|
+
|
|
405
|
+
if (eventName === 'ConversationStart') {
|
|
406
|
+
return handleConversationStart(input);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (eventName === 'UserMessageSubmit') {
|
|
410
|
+
return handleUserMessageSubmit(input);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (eventName === 'PostToolUse') {
|
|
414
|
+
return handlePostToolUse(input);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (eventName === 'ConversationEnd') {
|
|
418
|
+
return handleConversationEnd(input);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return null;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
handleEvent,
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
export const handleCursorHookEvent = createCursorAdapter().handleEvent;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { persistMetrics } from '../metrics.js';
|
|
2
|
+
import { countTokens } from '../tokenCounter.js';
|
|
3
|
+
import { buildOperationalContextLines } from '../client-contract.js';
|
|
4
|
+
import { smartSummary } from '../tools/smart-summary.js';
|
|
5
|
+
import { smartTurn } from '../tools/smart-turn.js';
|
|
6
|
+
import {
|
|
7
|
+
SAFE_CONTINUITY_STATES,
|
|
8
|
+
extractNextStep,
|
|
9
|
+
normalizeWhitespace,
|
|
10
|
+
truncate,
|
|
11
|
+
MAX_FOCUS_LENGTH,
|
|
12
|
+
MAX_GOAL_LENGTH,
|
|
13
|
+
} from './policy/event-policy.js';
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_ORCHESTRATION_EVENT = 'session_end';
|
|
16
|
+
export const DEFAULT_START_MAX_TOKENS = 350;
|
|
17
|
+
export const DEFAULT_END_MAX_TOKENS = 350;
|
|
18
|
+
|
|
19
|
+
const buildContextLines = (startResult) => {
|
|
20
|
+
const context = buildOperationalContextLines(startResult, {
|
|
21
|
+
sessionStart: false,
|
|
22
|
+
maxLineLength: 120,
|
|
23
|
+
maxLines: 8,
|
|
24
|
+
maxChars: 560,
|
|
25
|
+
});
|
|
26
|
+
return context ? context.split('\n') : [];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const buildWrappedPrompt = ({ prompt, startResult }) => {
|
|
30
|
+
const lines = buildContextLines(startResult);
|
|
31
|
+
if (lines.length === 0) {
|
|
32
|
+
return prompt;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return [
|
|
36
|
+
'Use the persisted devctx project context below only if it is relevant to the user request.',
|
|
37
|
+
...lines.map((line) => `- ${line}`),
|
|
38
|
+
'',
|
|
39
|
+
'User request:',
|
|
40
|
+
prompt,
|
|
41
|
+
].join('\n');
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const buildFreshSessionUpdate = (prompt) => {
|
|
45
|
+
const preview = truncate(prompt, MAX_FOCUS_LENGTH);
|
|
46
|
+
return {
|
|
47
|
+
goal: truncate(prompt, MAX_GOAL_LENGTH),
|
|
48
|
+
status: 'planning',
|
|
49
|
+
currentFocus: preview,
|
|
50
|
+
pinnedContext: [preview],
|
|
51
|
+
nextStep: 'Inspect the relevant code, validate task boundaries, and checkpoint the first concrete milestone.',
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const ensureIsolatedSession = async ({
|
|
56
|
+
prompt,
|
|
57
|
+
sessionId,
|
|
58
|
+
startResult,
|
|
59
|
+
startMaxTokens = DEFAULT_START_MAX_TOKENS,
|
|
60
|
+
summaryTool = smartSummary,
|
|
61
|
+
startTurn = smartTurn,
|
|
62
|
+
}) => {
|
|
63
|
+
if (sessionId || !startResult?.sessionId) {
|
|
64
|
+
return {
|
|
65
|
+
startResult,
|
|
66
|
+
isolated: Boolean(startResult?.isolatedSession),
|
|
67
|
+
previousSessionId: startResult?.previousSessionId ?? null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (startResult?.isolatedSession) {
|
|
72
|
+
return {
|
|
73
|
+
startResult,
|
|
74
|
+
isolated: true,
|
|
75
|
+
previousSessionId: startResult.previousSessionId ?? null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (SAFE_CONTINUITY_STATES.has(startResult.continuity?.state ?? '')) {
|
|
80
|
+
return {
|
|
81
|
+
startResult,
|
|
82
|
+
isolated: false,
|
|
83
|
+
previousSessionId: null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const created = await summaryTool({
|
|
88
|
+
action: 'update',
|
|
89
|
+
update: buildFreshSessionUpdate(prompt),
|
|
90
|
+
maxTokens: startMaxTokens,
|
|
91
|
+
});
|
|
92
|
+
const isolatedStart = await startTurn({
|
|
93
|
+
phase: 'start',
|
|
94
|
+
sessionId: created.sessionId,
|
|
95
|
+
prompt,
|
|
96
|
+
ensureSession: false,
|
|
97
|
+
maxTokens: startMaxTokens,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
startResult: isolatedStart,
|
|
102
|
+
isolated: true,
|
|
103
|
+
previousSessionId: startResult.sessionId,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const resolveManagedStart = async ({
|
|
108
|
+
prompt,
|
|
109
|
+
sessionId,
|
|
110
|
+
preparedStartResult = null,
|
|
111
|
+
ensureSession = true,
|
|
112
|
+
allowIsolation = false,
|
|
113
|
+
startMaxTokens = DEFAULT_START_MAX_TOKENS,
|
|
114
|
+
startTurn = smartTurn,
|
|
115
|
+
summaryTool = smartSummary,
|
|
116
|
+
}) => {
|
|
117
|
+
const startResult = preparedStartResult ?? await startTurn({
|
|
118
|
+
phase: 'start',
|
|
119
|
+
sessionId,
|
|
120
|
+
prompt,
|
|
121
|
+
ensureSession,
|
|
122
|
+
maxTokens: startMaxTokens,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!allowIsolation) {
|
|
126
|
+
return {
|
|
127
|
+
startResult,
|
|
128
|
+
isolated: Boolean(startResult?.isolatedSession),
|
|
129
|
+
previousSessionId: startResult?.previousSessionId ?? null,
|
|
130
|
+
autoStarted: !preparedStartResult,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const isolatedSession = await ensureIsolatedSession({
|
|
135
|
+
prompt,
|
|
136
|
+
sessionId,
|
|
137
|
+
startResult,
|
|
138
|
+
startMaxTokens,
|
|
139
|
+
summaryTool,
|
|
140
|
+
startTurn,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
...isolatedSession,
|
|
145
|
+
autoStarted: !preparedStartResult,
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const computeContextOverhead = ({ prompt, wrappedPrompt }) =>
|
|
150
|
+
Math.max(0, countTokens(wrappedPrompt) - countTokens(prompt));
|
|
151
|
+
|
|
152
|
+
export const buildChildEndUpdate = ({ prompt, childResult }) => {
|
|
153
|
+
const combinedOutput = [childResult.stdout, childResult.stderr].filter(Boolean).join('\n');
|
|
154
|
+
const nextStep = extractNextStep(combinedOutput);
|
|
155
|
+
const update = {
|
|
156
|
+
currentFocus: truncate(prompt, MAX_FOCUS_LENGTH),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (nextStep) {
|
|
160
|
+
update.nextStep = nextStep;
|
|
161
|
+
} else if (childResult.exitCode === 0) {
|
|
162
|
+
update.nextStep = 'Review the latest headless agent output and checkpoint any concrete file changes before continuing.';
|
|
163
|
+
} else {
|
|
164
|
+
update.status = 'blocked';
|
|
165
|
+
update.whyBlocked = `Headless agent command exited with code ${childResult.exitCode}.`;
|
|
166
|
+
update.nextStep = 'Review the headless agent stderr/output and rerun the command once the issue is fixed.';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return update;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const inferChildEndEvent = ({
|
|
173
|
+
requestedEvent,
|
|
174
|
+
childResult,
|
|
175
|
+
successEvent = DEFAULT_ORCHESTRATION_EVENT,
|
|
176
|
+
}) => {
|
|
177
|
+
if (requestedEvent) {
|
|
178
|
+
return requestedEvent;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return childResult.exitCode === 0 ? successEvent : 'blocker';
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const finalizeManagedRun = async ({
|
|
185
|
+
prompt,
|
|
186
|
+
childResult,
|
|
187
|
+
sessionId,
|
|
188
|
+
requestedEvent,
|
|
189
|
+
endMaxTokens = DEFAULT_END_MAX_TOKENS,
|
|
190
|
+
endTurn = smartTurn,
|
|
191
|
+
}) => {
|
|
192
|
+
const resolvedEvent = inferChildEndEvent({ requestedEvent, childResult });
|
|
193
|
+
const endResult = await endTurn({
|
|
194
|
+
phase: 'end',
|
|
195
|
+
sessionId,
|
|
196
|
+
event: resolvedEvent,
|
|
197
|
+
update: buildChildEndUpdate({ prompt, childResult }),
|
|
198
|
+
maxTokens: endMaxTokens,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
resolvedEvent,
|
|
203
|
+
endResult,
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const recordAgentWrapperMetric = async ({
|
|
208
|
+
phase,
|
|
209
|
+
client,
|
|
210
|
+
sessionId,
|
|
211
|
+
dryRun = false,
|
|
212
|
+
overheadTokens = 0,
|
|
213
|
+
isolatedSession = false,
|
|
214
|
+
previousSessionId = null,
|
|
215
|
+
exitCode = null,
|
|
216
|
+
event = null,
|
|
217
|
+
autoStarted = false,
|
|
218
|
+
}) => {
|
|
219
|
+
const safeOverheadTokens = Number.isFinite(overheadTokens) ? Math.max(0, overheadTokens) : 0;
|
|
220
|
+
await persistMetrics({
|
|
221
|
+
tool: 'agent_wrapper',
|
|
222
|
+
action: `${client}:${phase}`,
|
|
223
|
+
sessionId: sessionId ?? null,
|
|
224
|
+
rawTokens: 0,
|
|
225
|
+
compressedTokens: 0,
|
|
226
|
+
savedTokens: 0,
|
|
227
|
+
savingsPct: 0,
|
|
228
|
+
metadata: {
|
|
229
|
+
client,
|
|
230
|
+
dryRun,
|
|
231
|
+
autoStarted,
|
|
232
|
+
isolatedSession,
|
|
233
|
+
previousSessionId,
|
|
234
|
+
exitCode,
|
|
235
|
+
event,
|
|
236
|
+
managedByBaseOrchestrator: true,
|
|
237
|
+
isContextOverhead: phase === 'start' && safeOverheadTokens > 0,
|
|
238
|
+
overheadTokens: phase === 'start' ? safeOverheadTokens : 0,
|
|
239
|
+
},
|
|
240
|
+
timestamp: new Date().toISOString(),
|
|
241
|
+
});
|
|
242
|
+
};
|