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
|
@@ -1,423 +1 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
13
|
-
const HOOK_CLIENT = 'claude';
|
|
14
|
-
const START_MAX_TOKENS = 350;
|
|
15
|
-
const STOP_MAX_TOKENS = 300;
|
|
16
|
-
const MAX_CONTEXT_LINES = 7;
|
|
17
|
-
const MAX_CONTEXT_CHARS = 420;
|
|
18
|
-
const MAX_PROMPT_PREVIEW = 160;
|
|
19
|
-
const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
|
|
20
|
-
const SIGNIFICANT_RESPONSE_LENGTH = 140;
|
|
21
|
-
|
|
22
|
-
const normalizeWhitespace = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
23
|
-
|
|
24
|
-
const truncate = (value, maxLength = MAX_PROMPT_PREVIEW) => {
|
|
25
|
-
const normalized = normalizeWhitespace(value);
|
|
26
|
-
if (normalized.length <= maxLength) {
|
|
27
|
-
return normalized;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (maxLength <= 3) {
|
|
31
|
-
return '';
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return `${normalized.slice(0, maxLength - 3)}...`;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const countPromptTerms = (value) =>
|
|
38
|
-
normalizeWhitespace(value)
|
|
39
|
-
.split(/[^a-z0-9_.-]+/i)
|
|
40
|
-
.map((term) => term.trim())
|
|
41
|
-
.filter((term) => term.length >= 3)
|
|
42
|
-
.length;
|
|
43
|
-
|
|
44
|
-
const isMeaningfulPrompt = (value) => {
|
|
45
|
-
const normalized = normalizeWhitespace(value);
|
|
46
|
-
return normalized.length >= 20 && countPromptTerms(normalized) >= 4;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const uniq = (values) => [...new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0))];
|
|
50
|
-
|
|
51
|
-
const buildHookKey = ({ sessionId, agentId = null }) =>
|
|
52
|
-
agentId ? `${HOOK_CLIENT}:subagent:${sessionId}:${agentId}` : `${HOOK_CLIENT}:main:${sessionId}`;
|
|
53
|
-
|
|
54
|
-
const buildAdditionalContext = ({ result, sessionStart = false }) => {
|
|
55
|
-
return buildOperationalContextLines(result, {
|
|
56
|
-
sessionStart,
|
|
57
|
-
maxLineLength: 110,
|
|
58
|
-
maxLines: MAX_CONTEXT_LINES,
|
|
59
|
-
maxChars: MAX_CONTEXT_CHARS,
|
|
60
|
-
});
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const buildHookContextResponse = (hookEventName, additionalContext) => {
|
|
64
|
-
if (!additionalContext) {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return {
|
|
69
|
-
hookSpecificOutput: {
|
|
70
|
-
hookEventName,
|
|
71
|
-
additionalContext,
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const readHookTurnState = (hookKey) =>
|
|
77
|
-
getHookTurnState({
|
|
78
|
-
hookKey,
|
|
79
|
-
readOnly: getRepoMutationSafety().shouldBlock,
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
const maybeSetTrackedTurnState = async ({ hookKey, state }) => {
|
|
83
|
-
if (getRepoMutationSafety().shouldBlock) {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return setHookTurnState({ hookKey, state });
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const maybeDeleteTrackedTurnState = async ({ hookKey }) => {
|
|
91
|
-
if (getRepoMutationSafety().shouldBlock) {
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return deleteHookTurnState({ hookKey });
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const recordHookMetrics = async ({
|
|
99
|
-
action,
|
|
100
|
-
sessionId,
|
|
101
|
-
additionalContext = '',
|
|
102
|
-
blocked = false,
|
|
103
|
-
autoAppended = false,
|
|
104
|
-
continuityState = null,
|
|
105
|
-
} = {}) => {
|
|
106
|
-
const overheadTokens = additionalContext ? countTokens(additionalContext) : 0;
|
|
107
|
-
|
|
108
|
-
await persistMetrics({
|
|
109
|
-
tool: 'claude_hook',
|
|
110
|
-
action,
|
|
111
|
-
sessionId,
|
|
112
|
-
rawTokens: 0,
|
|
113
|
-
compressedTokens: 0,
|
|
114
|
-
savedTokens: 0,
|
|
115
|
-
savingsPct: 0,
|
|
116
|
-
metadata: {
|
|
117
|
-
isContextOverhead: overheadTokens > 0,
|
|
118
|
-
overheadTokens,
|
|
119
|
-
blocked,
|
|
120
|
-
autoAppended,
|
|
121
|
-
continuityState,
|
|
122
|
-
},
|
|
123
|
-
timestamp: new Date().toISOString(),
|
|
124
|
-
});
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
const isSmartTurnTool = (toolName) => /^mcp__.+__smart_turn$/i.test(toolName ?? '');
|
|
128
|
-
const isSmartSummaryTool = (toolName) => /^mcp__.+__smart_summary$/i.test(toolName ?? '');
|
|
129
|
-
|
|
130
|
-
const isCheckpointToolUse = ({ toolName, toolInput }) => {
|
|
131
|
-
if (isSmartTurnTool(toolName)) {
|
|
132
|
-
return toolInput?.phase === 'end'
|
|
133
|
-
? { matched: true, event: toolInput?.event ?? 'manual' }
|
|
134
|
-
: { matched: false, event: null };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (isSmartSummaryTool(toolName)) {
|
|
138
|
-
const action = toolInput?.action;
|
|
139
|
-
if (action === 'checkpoint') {
|
|
140
|
-
return { matched: true, event: toolInput?.event ?? 'manual' };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (action === 'append' || action === 'auto_append' || action === 'update') {
|
|
144
|
-
return { matched: true, event: action };
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return { matched: false, event: null };
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const extractTouchedFiles = ({ toolName, toolInput, toolResponse }) => {
|
|
152
|
-
if (!WRITE_TOOLS.has(toolName)) {
|
|
153
|
-
return [];
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return uniq([
|
|
157
|
-
toolInput?.file_path,
|
|
158
|
-
toolInput?.filePath,
|
|
159
|
-
toolResponse?.file_path,
|
|
160
|
-
toolResponse?.filePath,
|
|
161
|
-
]);
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const extractNextStep = (message) => {
|
|
165
|
-
const normalized = normalizeWhitespace(message);
|
|
166
|
-
if (!normalized) {
|
|
167
|
-
return '';
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const explicitMatch = normalized.match(/(?:next step|siguiente paso)\s*[:\-]\s*([^.;\n]{12,180})/i);
|
|
171
|
-
if (explicitMatch?.[1]) {
|
|
172
|
-
return truncate(explicitMatch[1], 150);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return '';
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
const buildCarryoverUpdate = (state, lastAssistantMessage) => {
|
|
179
|
-
const promptPreview = truncate(state.promptPreview, 140);
|
|
180
|
-
const nextStep = extractNextStep(lastAssistantMessage);
|
|
181
|
-
const pinnedContext = promptPreview ? [`Uncheckpointed turn: ${promptPreview}`] : [];
|
|
182
|
-
|
|
183
|
-
return {
|
|
184
|
-
...(promptPreview ? { currentFocus: promptPreview } : {}),
|
|
185
|
-
...(pinnedContext.length > 0 ? { pinnedContext } : {}),
|
|
186
|
-
...(state.touchedFiles.length > 0 ? { touchedFiles: state.touchedFiles } : {}),
|
|
187
|
-
...(nextStep ? { nextStep } : {}),
|
|
188
|
-
};
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
const computeStopEnforcement = (state, lastAssistantMessage) => {
|
|
192
|
-
const nextStep = extractNextStep(lastAssistantMessage);
|
|
193
|
-
const responseLength = normalizeWhitespace(lastAssistantMessage).length;
|
|
194
|
-
let score = 0;
|
|
195
|
-
|
|
196
|
-
if (state.meaningfulWriteCount > 0) {
|
|
197
|
-
score += 3;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (state.touchedFiles.length > 0) {
|
|
201
|
-
score += 1;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (nextStep) {
|
|
205
|
-
score += 2;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (responseLength >= SIGNIFICANT_RESPONSE_LENGTH) {
|
|
209
|
-
score += 1;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (state.continuityState === 'task_switch' || state.continuityState === 'possible_shift') {
|
|
213
|
-
score += 1;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return {
|
|
217
|
-
shouldBlock: score >= 3,
|
|
218
|
-
score,
|
|
219
|
-
nextStep,
|
|
220
|
-
};
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
const maybeTrackTurn = async ({
|
|
224
|
-
hookKey,
|
|
225
|
-
claudeSessionId,
|
|
226
|
-
projectSessionId,
|
|
227
|
-
prompt,
|
|
228
|
-
continuityState,
|
|
229
|
-
}) => {
|
|
230
|
-
const promptMeaningful = isMeaningfulPrompt(prompt);
|
|
231
|
-
const shouldTrack = Boolean(projectSessionId) && promptMeaningful;
|
|
232
|
-
|
|
233
|
-
if (!shouldTrack) {
|
|
234
|
-
await deleteHookTurnState({ hookKey });
|
|
235
|
-
return null;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return maybeSetTrackedTurnState({
|
|
239
|
-
hookKey,
|
|
240
|
-
state: {
|
|
241
|
-
client: HOOK_CLIENT,
|
|
242
|
-
claudeSessionId,
|
|
243
|
-
projectSessionId,
|
|
244
|
-
turnId: `${claudeSessionId}:${Date.now()}`,
|
|
245
|
-
promptPreview: truncate(prompt),
|
|
246
|
-
continuityState,
|
|
247
|
-
requireCheckpoint: true,
|
|
248
|
-
promptMeaningful,
|
|
249
|
-
checkpointed: false,
|
|
250
|
-
checkpointEvent: null,
|
|
251
|
-
touchedFiles: [],
|
|
252
|
-
meaningfulWriteCount: 0,
|
|
253
|
-
},
|
|
254
|
-
});
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
const handleSessionStart = async (input) => {
|
|
258
|
-
const result = await smartTurn({
|
|
259
|
-
phase: 'start',
|
|
260
|
-
maxTokens: START_MAX_TOKENS,
|
|
261
|
-
});
|
|
262
|
-
const additionalContext = buildAdditionalContext({ result, sessionStart: true });
|
|
263
|
-
await recordHookMetrics({
|
|
264
|
-
action: 'SessionStart',
|
|
265
|
-
sessionId: result.sessionId ?? null,
|
|
266
|
-
additionalContext,
|
|
267
|
-
continuityState: result.continuity?.state ?? null,
|
|
268
|
-
});
|
|
269
|
-
return buildHookContextResponse('SessionStart', additionalContext);
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
const handleUserPromptSubmit = async (input) => {
|
|
273
|
-
const result = await smartTurn({
|
|
274
|
-
phase: 'start',
|
|
275
|
-
prompt: input.prompt,
|
|
276
|
-
ensureSession: true,
|
|
277
|
-
maxTokens: START_MAX_TOKENS,
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
const trackedState = await maybeTrackTurn({
|
|
281
|
-
hookKey: buildHookKey({ sessionId: input.session_id }),
|
|
282
|
-
claudeSessionId: input.session_id,
|
|
283
|
-
projectSessionId: result.sessionId ?? null,
|
|
284
|
-
prompt: input.prompt,
|
|
285
|
-
continuityState: result.continuity?.state ?? '',
|
|
286
|
-
});
|
|
287
|
-
const additionalContext = buildAdditionalContext({ result });
|
|
288
|
-
await recordHookMetrics({
|
|
289
|
-
action: 'UserPromptSubmit',
|
|
290
|
-
sessionId: trackedState?.projectSessionId ?? result.sessionId ?? null,
|
|
291
|
-
additionalContext,
|
|
292
|
-
continuityState: result.continuity?.state ?? null,
|
|
293
|
-
});
|
|
294
|
-
return buildHookContextResponse('UserPromptSubmit', additionalContext);
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
const handlePostToolUse = async (input) => {
|
|
298
|
-
const hookKey = buildHookKey({ sessionId: input.session_id });
|
|
299
|
-
const existing = await readHookTurnState(hookKey);
|
|
300
|
-
if (!existing) {
|
|
301
|
-
return null;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const checkpoint = isCheckpointToolUse({
|
|
305
|
-
toolName: input.tool_name,
|
|
306
|
-
toolInput: input.tool_input,
|
|
307
|
-
});
|
|
308
|
-
const touchedFiles = extractTouchedFiles({
|
|
309
|
-
toolName: input.tool_name,
|
|
310
|
-
toolInput: input.tool_input,
|
|
311
|
-
toolResponse: input.tool_response,
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
const nextState = {
|
|
315
|
-
...existing,
|
|
316
|
-
checkpointed: checkpoint.matched ? true : existing.checkpointed,
|
|
317
|
-
checkpointEvent: checkpoint.matched ? checkpoint.event : existing.checkpointEvent,
|
|
318
|
-
touchedFiles: uniq([...existing.touchedFiles, ...touchedFiles]),
|
|
319
|
-
meaningfulWriteCount: existing.meaningfulWriteCount + touchedFiles.length,
|
|
320
|
-
updatedAt: new Date().toISOString(),
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
await maybeSetTrackedTurnState({ hookKey, state: nextState });
|
|
324
|
-
if (checkpoint.matched || touchedFiles.length > 0) {
|
|
325
|
-
await recordHookMetrics({
|
|
326
|
-
action: 'PostToolUse',
|
|
327
|
-
sessionId: existing.projectSessionId,
|
|
328
|
-
additionalContext: '',
|
|
329
|
-
continuityState: existing.continuityState,
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
return null;
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
const handleStop = async (input) => {
|
|
336
|
-
const hookKey = buildHookKey({ sessionId: input.session_id });
|
|
337
|
-
const state = await readHookTurnState(hookKey);
|
|
338
|
-
if (!state) {
|
|
339
|
-
return null;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (getRepoMutationSafety().shouldBlock) {
|
|
343
|
-
await recordHookMetrics({
|
|
344
|
-
action: 'Stop',
|
|
345
|
-
sessionId: state.projectSessionId,
|
|
346
|
-
additionalContext: '',
|
|
347
|
-
blocked: false,
|
|
348
|
-
continuityState: state.continuityState,
|
|
349
|
-
});
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const enforcement = computeStopEnforcement(state, input.last_assistant_message);
|
|
354
|
-
const shouldEnforce = (state.requireCheckpoint || state.meaningfulWriteCount > 0) && enforcement.shouldBlock;
|
|
355
|
-
if (!shouldEnforce || state.checkpointed) {
|
|
356
|
-
await recordHookMetrics({
|
|
357
|
-
action: 'Stop',
|
|
358
|
-
sessionId: state.projectSessionId,
|
|
359
|
-
additionalContext: '',
|
|
360
|
-
blocked: false,
|
|
361
|
-
continuityState: state.continuityState,
|
|
362
|
-
});
|
|
363
|
-
await maybeDeleteTrackedTurnState({ hookKey });
|
|
364
|
-
return null;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
if (input.stop_hook_active) {
|
|
368
|
-
const update = buildCarryoverUpdate(state, input.last_assistant_message);
|
|
369
|
-
if (state.projectSessionId && Object.keys(update).length > 0) {
|
|
370
|
-
await smartSummary({
|
|
371
|
-
action: 'auto_append',
|
|
372
|
-
sessionId: state.projectSessionId,
|
|
373
|
-
update,
|
|
374
|
-
maxTokens: STOP_MAX_TOKENS,
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
await recordHookMetrics({
|
|
379
|
-
action: 'Stop',
|
|
380
|
-
sessionId: state.projectSessionId,
|
|
381
|
-
additionalContext: '',
|
|
382
|
-
blocked: false,
|
|
383
|
-
autoAppended: true,
|
|
384
|
-
continuityState: state.continuityState,
|
|
385
|
-
});
|
|
386
|
-
await maybeDeleteTrackedTurnState({ hookKey });
|
|
387
|
-
return null;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
await recordHookMetrics({
|
|
391
|
-
action: 'Stop',
|
|
392
|
-
sessionId: state.projectSessionId,
|
|
393
|
-
additionalContext: '',
|
|
394
|
-
blocked: true,
|
|
395
|
-
continuityState: state.continuityState,
|
|
396
|
-
});
|
|
397
|
-
return {
|
|
398
|
-
decision: 'block',
|
|
399
|
-
reason: `Persist this turn with mcp__devctx__smart_turn phase=end before stopping.${state.touchedFiles.length > 0 ? ' Include touchedFiles and the nextStep.' : ' Include the nextStep.'}`,
|
|
400
|
-
};
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
export const handleClaudeHookEvent = async (input = {}) => {
|
|
404
|
-
const eventName = input.hook_event_name;
|
|
405
|
-
|
|
406
|
-
if (eventName === 'SessionStart') {
|
|
407
|
-
return handleSessionStart(input);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
if (eventName === 'UserPromptSubmit') {
|
|
411
|
-
return handleUserPromptSubmit(input);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (eventName === 'PostToolUse') {
|
|
415
|
-
return handlePostToolUse(input);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
if (eventName === 'Stop') {
|
|
419
|
-
return handleStop(input);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
return null;
|
|
423
|
-
};
|
|
1
|
+
export { handleClaudeHookEvent } from '../orchestration/adapters/claude-adapter.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { handleCursorHookEvent } from '../orchestration/adapters/cursor-adapter.js';
|