@miphamai/cli 0.33.1 → 0.34.0
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/package.json +1 -1
- package/src/agent/cross-session/file-inbox.ts +30 -12
- package/src/core/compaction-progress.ts +205 -0
- package/src/core/context-compact.ts +70 -6
- package/src/core/context-drain.ts +65 -6
- package/src/core/context.ts +11 -4
- package/src/core/dream-engine.ts +552 -0
- package/src/core/emotion-detector.ts +257 -0
- package/src/core/engine.ts +50 -0
- package/src/core/hooks.ts +114 -9
- package/src/ui/app.tsx +164 -130
- package/src/ui/commands.ts +409 -35
- package/src/ui/error-boundary.tsx +85 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emotion Detector — User Frustration Detection
|
|
3
|
+
*
|
|
4
|
+
* Inspired by Claude Code's emotion detection: parses user input for signals
|
|
5
|
+
* of frustration, confusion, or satisfaction. Adjusts agent behavior accordingly:
|
|
6
|
+
* - Frustration → be more concise, skip explanations, get to the fix faster
|
|
7
|
+
* - Confusion → explain more, offer context, slow down
|
|
8
|
+
* - Satisfaction → maintain current mode
|
|
9
|
+
*
|
|
10
|
+
* Uses regex heuristics (no LLM call) for zero-latency detection.
|
|
11
|
+
* Designed as a lightweight pre-processor in the input pipeline.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ── Types ──
|
|
15
|
+
|
|
16
|
+
export type Emotion =
|
|
17
|
+
| 'frustrated' // User is angry/annoyed — move fast, skip explanations
|
|
18
|
+
| 'confused' // User doesn't understand — explain more
|
|
19
|
+
| 'impatient' // User wants speed — be terse
|
|
20
|
+
| 'satisfied' // User is happy — continue normally
|
|
21
|
+
| 'neutral' // No strong signal
|
|
22
|
+
|
|
23
|
+
export interface EmotionResult {
|
|
24
|
+
emotion: Emotion
|
|
25
|
+
/** 0.0-1.0 confidence in the detected emotion */
|
|
26
|
+
confidence: number
|
|
27
|
+
/** Specific phrases that triggered the detection */
|
|
28
|
+
triggers: string[]
|
|
29
|
+
/** Suggested behavior adjustment for the agent */
|
|
30
|
+
suggestion: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Constants ──
|
|
34
|
+
|
|
35
|
+
/** Patterns that indicate frustration/anger. Ordered by severity. */
|
|
36
|
+
const FRUSTRATION_PATTERNS: Array<{ pattern: RegExp; weight: number; label: string }> = [
|
|
37
|
+
{ pattern: /\b(wtf|wtaf)\b/i, weight: 0.9, label: 'profanity' },
|
|
38
|
+
{
|
|
39
|
+
pattern: /\b(useless|worthless|garbage|rubbish|broken)\b/i,
|
|
40
|
+
weight: 0.8,
|
|
41
|
+
label: 'strong-negative',
|
|
42
|
+
},
|
|
43
|
+
{ pattern: /\b(damn|shit|crap)\b/i, weight: 0.7, label: 'mild-profanity' },
|
|
44
|
+
{
|
|
45
|
+
pattern: /\b(stop|don't|do not)\s+(explain|talk|lecture|ramble)\b/i,
|
|
46
|
+
weight: 0.85,
|
|
47
|
+
label: 'stop-explaining',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
pattern: /\b(just\s+fix\s+it|just\s+do\s+it|get\s+on\s+with\s+it)\b/i,
|
|
51
|
+
weight: 0.8,
|
|
52
|
+
label: 'just-fix-it',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
pattern: /\b(no|wrong|incorrect|bad|terrible|awful)\s*[!!]{1,3}/i,
|
|
56
|
+
weight: 0.75,
|
|
57
|
+
label: 'emphatic-no',
|
|
58
|
+
},
|
|
59
|
+
{ pattern: /\b(again\?|still|not\s+again)\b/i, weight: 0.7, label: 'repeated-failure' },
|
|
60
|
+
{ pattern: /\b(i\s+said|i\s+told\s+you|i\s+already)\b/i, weight: 0.75, label: 'i-already-said' },
|
|
61
|
+
{ pattern: /\b(no\s+no\s+no|nope\s+nope)\b/i, weight: 0.7, label: 'repeated-no' },
|
|
62
|
+
{ pattern: /[!!]{2,}/, weight: 0.6, label: 'multiple-exclamation' },
|
|
63
|
+
{
|
|
64
|
+
pattern: /\b(why|why\s+would\s+you|what\s+are\s+you\s+doing)\b/i,
|
|
65
|
+
weight: 0.65,
|
|
66
|
+
label: 'why-question',
|
|
67
|
+
},
|
|
68
|
+
{ pattern: /[??]{2,}/, weight: 0.4, label: 'multiple-question-marks' },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
/** Patterns that indicate confusion / need for more explanation */
|
|
72
|
+
const CONFUSION_PATTERNS: Array<{ pattern: RegExp; weight: number; label: string }> = [
|
|
73
|
+
{
|
|
74
|
+
pattern: /\b(i\s+don'?t\s+understand|i\s+am\s+confused|i'?m\s+confused)\b/i,
|
|
75
|
+
weight: 0.9,
|
|
76
|
+
label: 'explicit-confusion',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
pattern: /\b(what\s+does|what\s+is|what\s+are|how\s+does)\b/i,
|
|
80
|
+
weight: 0.6,
|
|
81
|
+
label: 'what-is-question',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
pattern: /\b(can\s+you\s+explain|explain\s+this|elaborate)\b/i,
|
|
85
|
+
weight: 0.7,
|
|
86
|
+
label: 'ask-for-explanation',
|
|
87
|
+
},
|
|
88
|
+
{ pattern: /\b(wait|huh|eh\?+|hmm+)\b/i, weight: 0.5, label: 'hesitation' },
|
|
89
|
+
{
|
|
90
|
+
pattern: /\b(i\s+thought|i\s+expected|shouldn'?t\s+it)\b/i,
|
|
91
|
+
weight: 0.6,
|
|
92
|
+
label: 'mismatch-expectation',
|
|
93
|
+
},
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
/** Patterns that indicate impatience */
|
|
97
|
+
const IMPATIENCE_PATTERNS: Array<{ pattern: RegExp; weight: number; label: string }> = [
|
|
98
|
+
{ pattern: /\b(hurry|quick|fast|faster|asap|urgent)\b/i, weight: 0.8, label: 'speed-words' },
|
|
99
|
+
{
|
|
100
|
+
pattern: /\b(just\s+the\s+(code|answer|result|fix|summary))\b/i,
|
|
101
|
+
weight: 0.75,
|
|
102
|
+
label: 'just-the-result',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
pattern: /\b(skip\s+(the\s+)?(explanation|details|context|background))\b/i,
|
|
106
|
+
weight: 0.8,
|
|
107
|
+
label: 'skip-explanation',
|
|
108
|
+
},
|
|
109
|
+
{ pattern: /\b(tldr|tl;dr|summary|summarize)\b/i, weight: 0.7, label: 'tldr' },
|
|
110
|
+
{
|
|
111
|
+
pattern: /\b(get\s+to\s+the\s+point|cut\s+to\s+the\s+chase)\b/i,
|
|
112
|
+
weight: 0.75,
|
|
113
|
+
label: 'get-to-point',
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
/** Patterns that indicate satisfaction */
|
|
118
|
+
const SATISFACTION_PATTERNS: Array<{ pattern: RegExp; weight: number; label: string }> = [
|
|
119
|
+
{ pattern: /\b(thanks|thank\s+you|thx|ty|tyvm)\b/i, weight: 0.5, label: 'thanks' },
|
|
120
|
+
{
|
|
121
|
+
pattern: /\b(great|awesome|perfect|excellent|amazing|brilliant|beautiful)\b/i,
|
|
122
|
+
weight: 0.6,
|
|
123
|
+
label: 'positive-words',
|
|
124
|
+
},
|
|
125
|
+
{ pattern: /\b(nice|good\s+job|well\s+done|love\s+it)\b/i, weight: 0.7, label: 'praise' },
|
|
126
|
+
{
|
|
127
|
+
pattern: /\b(exactly|finally|yes!|that'?s\s+it)\b/i,
|
|
128
|
+
weight: 0.6,
|
|
129
|
+
label: 'confirmation-positive',
|
|
130
|
+
},
|
|
131
|
+
{ pattern: /[👍🙌🎉✨💯🔥✅]/u, weight: 0.5, label: 'positive-emoji' },
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
// ── Detector ──
|
|
135
|
+
|
|
136
|
+
export class EmotionDetector {
|
|
137
|
+
/**
|
|
138
|
+
* Analyze user input for emotional signals.
|
|
139
|
+
*
|
|
140
|
+
* @param input — raw user message text
|
|
141
|
+
* @param recentContext — optional recent conversation turns for pattern detection
|
|
142
|
+
* @returns EmotionResult with detected emotion and behavioral suggestion
|
|
143
|
+
*/
|
|
144
|
+
detect(input: string, recentContext?: string[]): EmotionResult {
|
|
145
|
+
// Check frustration first (most actionable)
|
|
146
|
+
const frustration = this.scorePatterns(input, FRUSTRATION_PATTERNS, recentContext)
|
|
147
|
+
if (frustration.score >= 0.5) {
|
|
148
|
+
return {
|
|
149
|
+
emotion: 'frustrated',
|
|
150
|
+
confidence: frustration.score,
|
|
151
|
+
triggers: frustration.triggers,
|
|
152
|
+
suggestion:
|
|
153
|
+
frustration.score >= 0.8
|
|
154
|
+
? 'User is very frustrated. Be extremely concise. Skip ALL explanations. Output only the fix/result. No preamble, no summary, no "here\'s what I did". One sentence max before code.'
|
|
155
|
+
: 'User is frustrated. Be concise. Skip explanations. Get to the fix immediately.',
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Check impatience
|
|
160
|
+
const impatience = this.scorePatterns(input, IMPATIENCE_PATTERNS)
|
|
161
|
+
if (impatience.score >= 0.5) {
|
|
162
|
+
return {
|
|
163
|
+
emotion: 'impatient',
|
|
164
|
+
confidence: impatience.score,
|
|
165
|
+
triggers: impatience.triggers,
|
|
166
|
+
suggestion:
|
|
167
|
+
'User wants speed. Skip explanations. Output the result immediately. No "let me explain why" — just the answer.',
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Check confusion
|
|
172
|
+
const confusion = this.scorePatterns(input, CONFUSION_PATTERNS)
|
|
173
|
+
if (confusion.score >= 0.5) {
|
|
174
|
+
return {
|
|
175
|
+
emotion: 'confused',
|
|
176
|
+
confidence: confusion.score,
|
|
177
|
+
triggers: confusion.triggers,
|
|
178
|
+
suggestion:
|
|
179
|
+
'User is confused. Explain more. Offer context. Break down complex steps. Ask clarifying questions rather than assuming.',
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check satisfaction
|
|
184
|
+
const satisfaction = this.scorePatterns(input, SATISFACTION_PATTERNS)
|
|
185
|
+
if (satisfaction.score >= 0.5) {
|
|
186
|
+
return {
|
|
187
|
+
emotion: 'satisfied',
|
|
188
|
+
confidence: satisfaction.score,
|
|
189
|
+
triggers: satisfaction.triggers,
|
|
190
|
+
suggestion:
|
|
191
|
+
'User is satisfied. Maintain current tone and pace. Good time for optional suggestions.',
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
emotion: 'neutral',
|
|
197
|
+
confidence: 0,
|
|
198
|
+
triggers: [],
|
|
199
|
+
suggestion: 'No strong emotional signal. Proceed normally.',
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Quick check — does this input contain ANY frustration signal?
|
|
205
|
+
* Use as a pre-filter before the full detect() call.
|
|
206
|
+
*/
|
|
207
|
+
isFrustrated(input: string): boolean {
|
|
208
|
+
return FRUSTRATION_PATTERNS.some((p) => p.pattern.test(input))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Detect repeated frustration across multiple turns (escalation detection).
|
|
213
|
+
*/
|
|
214
|
+
detectEscalation(recentInputs: string[]): boolean {
|
|
215
|
+
if (recentInputs.length < 2) return false
|
|
216
|
+
const recent = recentInputs.slice(-3)
|
|
217
|
+
const frustrationCount = recent.filter((i) => this.isFrustrated(i)).length
|
|
218
|
+
return frustrationCount >= 2
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Internal ──
|
|
222
|
+
|
|
223
|
+
private scorePatterns(
|
|
224
|
+
input: string,
|
|
225
|
+
patterns: Array<{ pattern: RegExp; weight: number; label: string }>,
|
|
226
|
+
recentContext?: string[],
|
|
227
|
+
): { score: number; triggers: string[] } {
|
|
228
|
+
const triggers: string[] = []
|
|
229
|
+
let totalWeight = 0
|
|
230
|
+
let matchCount = 0
|
|
231
|
+
|
|
232
|
+
for (const p of patterns) {
|
|
233
|
+
if (p.pattern.test(input)) {
|
|
234
|
+
triggers.push(p.label)
|
|
235
|
+
totalWeight += p.weight
|
|
236
|
+
matchCount++
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Check recent context for frustration escalation
|
|
241
|
+
if (recentContext && patterns === FRUSTRATION_PATTERNS) {
|
|
242
|
+
const recentFrustration = recentContext
|
|
243
|
+
.slice(-2)
|
|
244
|
+
.filter((ctx) => patterns.some((p) => p.pattern.test(ctx))).length
|
|
245
|
+
if (recentFrustration >= 1) {
|
|
246
|
+
totalWeight += 0.15 // escalation bonus
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Normalize: first match passes at weight ≥ 0.5; additional matches increase confidence
|
|
251
|
+
// with diminishing returns (matchCount-1 so single match = no dampening)
|
|
252
|
+
const score =
|
|
253
|
+
matchCount === 0 ? 0 : Math.min(1.0, totalWeight / (1 + Math.max(0, matchCount - 1) * 0.5))
|
|
254
|
+
|
|
255
|
+
return { score, triggers }
|
|
256
|
+
}
|
|
257
|
+
}
|
package/src/core/engine.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { AgentRegistry } from '../agent/agent-registry'
|
|
|
15
15
|
import { analyzeForMemory } from './memory/memory-writer'
|
|
16
16
|
import { getMemoryManager } from './memory/memory-loader'
|
|
17
17
|
import { AutoMemoryEngine } from './auto-memory.js'
|
|
18
|
+
import type { ToolCallRecord } from './auto-memory.js'
|
|
18
19
|
import type { AgentViewManager } from '../agent-view/agent-view-manager'
|
|
19
20
|
import type { SkillsLoader } from '../skills/loader'
|
|
20
21
|
import { getBackgroundAgentRegistry } from '../agent/background-registry'
|
|
@@ -26,6 +27,7 @@ import { ErrorSignatureDB } from './error-signature-db.js'
|
|
|
26
27
|
import { PreFlightChecker } from './preflight-checker.js'
|
|
27
28
|
import { AutoCorrector } from './auto-corrector.js'
|
|
28
29
|
import { MetaRuleEngine } from './meta-rule-engine.js'
|
|
30
|
+
import { DreamEngine } from './dream-engine.js'
|
|
29
31
|
import { UsageTracker } from './usage-tracker'
|
|
30
32
|
import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
|
|
31
33
|
import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
|
|
@@ -55,6 +57,7 @@ export class QueryEngine {
|
|
|
55
57
|
private _preflightChecker?: PreFlightChecker
|
|
56
58
|
private _autoCorrector?: AutoCorrector
|
|
57
59
|
private _metaRuleEngine?: MetaRuleEngine
|
|
60
|
+
private _dreamEngine?: DreamEngine
|
|
58
61
|
/** Files read this session — tracks what the Read tool has loaded */
|
|
59
62
|
private readFiles = new Set<string>()
|
|
60
63
|
private goal?: string
|
|
@@ -160,6 +163,11 @@ export class QueryEngine {
|
|
|
160
163
|
this.hookEngine = hooks
|
|
161
164
|
}
|
|
162
165
|
|
|
166
|
+
/** Get the registered hook engine (for health inspection). */
|
|
167
|
+
getHookEngine(): HookEngine | undefined {
|
|
168
|
+
return this.hookEngine
|
|
169
|
+
}
|
|
170
|
+
|
|
163
171
|
/** Register the artifact server for tool context. */
|
|
164
172
|
setArtifactServer(server: ArtifactServer): void {
|
|
165
173
|
this.artifactServer = server
|
|
@@ -423,6 +431,7 @@ export class QueryEngine {
|
|
|
423
431
|
let thinkingContent = ''
|
|
424
432
|
let turnApiInputTokens = 0
|
|
425
433
|
let turnApiOutputTokens = 0
|
|
434
|
+
const turnStart = Date.now()
|
|
426
435
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
427
436
|
|
|
428
437
|
// Stream model response
|
|
@@ -529,7 +538,9 @@ export class QueryEngine {
|
|
|
529
538
|
}
|
|
530
539
|
|
|
531
540
|
// Execute any tools that were requested
|
|
541
|
+
const toolCallRecords: ToolCallRecord[] = []
|
|
532
542
|
for (const toolUse of toolUses) {
|
|
543
|
+
const toolStart = Date.now()
|
|
533
544
|
const result = await this.executeTool(toolUse.name, toolUse.input)
|
|
534
545
|
yield {
|
|
535
546
|
type: 'tool_result',
|
|
@@ -537,6 +548,15 @@ export class QueryEngine {
|
|
|
537
548
|
content: result.success ? result.content : result.error || result.content,
|
|
538
549
|
}
|
|
539
550
|
|
|
551
|
+
// Collect tool call record for CRSI auto-reflection
|
|
552
|
+
toolCallRecords.push({
|
|
553
|
+
name: toolUse.name,
|
|
554
|
+
input: toolUse.input,
|
|
555
|
+
success: result.success,
|
|
556
|
+
error: result.success ? undefined : result.error || 'Unknown error',
|
|
557
|
+
durationMs: Date.now() - toolStart,
|
|
558
|
+
})
|
|
559
|
+
|
|
540
560
|
// Add tool use + result to context
|
|
541
561
|
// DeepSeek V4 thinking mode requires reasoning_content on every assistant message
|
|
542
562
|
this.context.addMessage({
|
|
@@ -556,6 +576,28 @@ export class QueryEngine {
|
|
|
556
576
|
})
|
|
557
577
|
}
|
|
558
578
|
|
|
579
|
+
// ── CRSI Reflection: Wire AutoMemoryEngine.analyzeTurn() into main loop ──
|
|
580
|
+
// This is the primary CRSI learning mechanism — every turn's tool calls,
|
|
581
|
+
// successes, and failures are analyzed for patterns and fed into the
|
|
582
|
+
// CRSI pipeline (PatternAnalyzer → ExperienceRuleEngine → ErrorSignatureDB).
|
|
583
|
+
if (toolCallRecords.length > 0) {
|
|
584
|
+
try {
|
|
585
|
+
const autoMemory = this.getAutoMemory()
|
|
586
|
+
const reflection = autoMemory.analyzeTurn({
|
|
587
|
+
sessionId: this.sessionId,
|
|
588
|
+
userMessage: userInput,
|
|
589
|
+
assistantContent,
|
|
590
|
+
toolCalls: toolCallRecords,
|
|
591
|
+
modelProvider: this.registry.getActive().config.id,
|
|
592
|
+
modelId: this.registry.getActiveModel(),
|
|
593
|
+
turnDurationMs: turnStart ? Date.now() - turnStart : 0,
|
|
594
|
+
})
|
|
595
|
+
autoMemory.persist(reflection)
|
|
596
|
+
} catch {
|
|
597
|
+
// Reflection is non-critical — failures must not break the main loop
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
559
601
|
// Record API token usage for this turn, attributed to executed tools
|
|
560
602
|
if (turnApiInputTokens > 0 || turnApiOutputTokens > 0) {
|
|
561
603
|
if (toolUses.length > 0) {
|
|
@@ -1136,6 +1178,14 @@ export class QueryEngine {
|
|
|
1136
1178
|
return this._metaRuleEngine
|
|
1137
1179
|
}
|
|
1138
1180
|
|
|
1181
|
+
/** Auto-Dream: Lazily-initialized background memory consolidation engine. */
|
|
1182
|
+
getDreamEngine(): DreamEngine {
|
|
1183
|
+
if (!this._dreamEngine) {
|
|
1184
|
+
this._dreamEngine = new DreamEngine()
|
|
1185
|
+
}
|
|
1186
|
+
return this._dreamEngine
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1139
1189
|
/** Register a tool dynamically (used by MCP auto-registration). */
|
|
1140
1190
|
registerTool(tool: ToolDefinition): void {
|
|
1141
1191
|
if (this.tools.has(tool.name)) {
|
package/src/core/hooks.ts
CHANGED
|
@@ -6,6 +6,29 @@ import type {
|
|
|
6
6
|
ToolResult,
|
|
7
7
|
} from '../shared/index.ts'
|
|
8
8
|
|
|
9
|
+
// ── Hook resilience types ──
|
|
10
|
+
|
|
11
|
+
interface HookHealth {
|
|
12
|
+
/** Consecutive failure count */
|
|
13
|
+
failures: number
|
|
14
|
+
/** Timestamp of last failure (ms since epoch) */
|
|
15
|
+
lastFailureTime: number
|
|
16
|
+
/** Whether the hook is currently disabled due to repeated failures */
|
|
17
|
+
disabled: boolean
|
|
18
|
+
/** Timestamp when disabled (ms since epoch) */
|
|
19
|
+
disabledAt: number
|
|
20
|
+
/** Total failure count (lifetime) */
|
|
21
|
+
totalFailures: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Max consecutive failures before auto-disabling a hook */
|
|
25
|
+
const MAX_CONSECUTIVE_FAILURES = 5
|
|
26
|
+
|
|
27
|
+
/** Cooldown period in ms before a disabled hook is re-tried (5 minutes) */
|
|
28
|
+
const COOLDOWN_MS = 5 * 60 * 1000
|
|
29
|
+
|
|
30
|
+
// ── Helpers ──
|
|
31
|
+
|
|
9
32
|
/**
|
|
10
33
|
* Merge two permission decisions, keeping the more restrictive one.
|
|
11
34
|
* Priority: deny > defer > ask > allow
|
|
@@ -22,6 +45,8 @@ export function mergePermissionDecision(
|
|
|
22
45
|
|
|
23
46
|
export class HookEngine {
|
|
24
47
|
private hooks: HookDefinition[] = []
|
|
48
|
+
/** Health tracking per hook key (event[:toolName]) */
|
|
49
|
+
private health = new Map<string, HookHealth>()
|
|
25
50
|
|
|
26
51
|
register(hook: HookDefinition): void {
|
|
27
52
|
this.hooks.push(hook)
|
|
@@ -71,31 +96,26 @@ export class HookEngine {
|
|
|
71
96
|
|
|
72
97
|
// ── NEW event executors ──
|
|
73
98
|
|
|
74
|
-
/** Stop hook: fires when AI completes a turn. Can block to force continuation. */
|
|
75
99
|
async executeStop(sessionId: string): Promise<HookResult> {
|
|
76
100
|
const ctx: HookContext = { event: 'Stop', sessionId }
|
|
77
101
|
return this.runHooks('Stop', undefined, ctx)
|
|
78
102
|
}
|
|
79
103
|
|
|
80
|
-
/** UserPromptSubmit: fires when user submits input. Can block malicious input. */
|
|
81
104
|
async executeUserPromptSubmit(prompt: string, sessionId: string): Promise<HookResult> {
|
|
82
105
|
const ctx: HookContext = { event: 'UserPromptSubmit', sessionId, userPrompt: prompt }
|
|
83
106
|
return this.runHooks('UserPromptSubmit', undefined, ctx)
|
|
84
107
|
}
|
|
85
108
|
|
|
86
|
-
/** PreCompact: fires before context compaction. */
|
|
87
109
|
async executePreCompact(sessionId: string): Promise<HookResult> {
|
|
88
110
|
const ctx: HookContext = { event: 'PreCompact', sessionId }
|
|
89
111
|
return this.runHooks('PreCompact', undefined, ctx)
|
|
90
112
|
}
|
|
91
113
|
|
|
92
|
-
/** PostCompact: fires after context compaction. Can inject additional context. */
|
|
93
114
|
async executePostCompact(sessionId: string): Promise<HookResult> {
|
|
94
115
|
const ctx: HookContext = { event: 'PostCompact', sessionId }
|
|
95
116
|
return this.runHooks('PostCompact', undefined, ctx)
|
|
96
117
|
}
|
|
97
118
|
|
|
98
|
-
/** ConfigChange: fires when configuration changes. */
|
|
99
119
|
async executeConfigChange(key: string, value: unknown, sessionId: string): Promise<HookResult> {
|
|
100
120
|
const ctx: HookContext = {
|
|
101
121
|
event: 'ConfigChange',
|
|
@@ -106,7 +126,6 @@ export class HookEngine {
|
|
|
106
126
|
return this.runHooks('ConfigChange', undefined, ctx)
|
|
107
127
|
}
|
|
108
128
|
|
|
109
|
-
/** SubagentStart: fires when a sub-agent begins execution. */
|
|
110
129
|
async executeSubagentStart(
|
|
111
130
|
agentType: string,
|
|
112
131
|
description: string,
|
|
@@ -120,7 +139,6 @@ export class HookEngine {
|
|
|
120
139
|
return this.runHooks('SubagentStart', undefined, ctx)
|
|
121
140
|
}
|
|
122
141
|
|
|
123
|
-
/** SubagentStop: fires when a sub-agent completes (success or failure). */
|
|
124
142
|
async executeSubagentStop(
|
|
125
143
|
agentType: string,
|
|
126
144
|
description: string,
|
|
@@ -137,7 +155,6 @@ export class HookEngine {
|
|
|
137
155
|
return this.runHooks('SubagentStop', undefined, ctx)
|
|
138
156
|
}
|
|
139
157
|
|
|
140
|
-
/** PostToolUseFailure: fires when a tool call fails. */
|
|
141
158
|
async executePostToolUseFailure(
|
|
142
159
|
toolName: string,
|
|
143
160
|
toolInput: Record<string, unknown>,
|
|
@@ -154,7 +171,6 @@ export class HookEngine {
|
|
|
154
171
|
return this.runHooks('PostToolUseFailure', toolName, ctx)
|
|
155
172
|
}
|
|
156
173
|
|
|
157
|
-
/** PreInference: fires before every model API call for DLP inspection. */
|
|
158
174
|
async executePreInference(
|
|
159
175
|
messages: Array<{ role: string; content: string }>,
|
|
160
176
|
toolCalls: Array<{
|
|
@@ -177,6 +193,79 @@ export class HookEngine {
|
|
|
177
193
|
return this.runHooks('PreInference', undefined, ctx)
|
|
178
194
|
}
|
|
179
195
|
|
|
196
|
+
// ── Health & Resilience ──
|
|
197
|
+
|
|
198
|
+
/** Get a hook health key for tracking. */
|
|
199
|
+
private healthKey(hook: HookDefinition): string {
|
|
200
|
+
return hook.toolName ? `${hook.event}:${hook.toolName}` : hook.event
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Check if a hook should be skipped due to repeated failures. */
|
|
204
|
+
private shouldSkip(key: string): boolean {
|
|
205
|
+
const h = this.health.get(key)
|
|
206
|
+
if (!h) return false
|
|
207
|
+
|
|
208
|
+
// If disabled, check cooldown
|
|
209
|
+
if (h.disabled) {
|
|
210
|
+
const elapsed = Date.now() - h.disabledAt
|
|
211
|
+
if (elapsed < COOLDOWN_MS) {
|
|
212
|
+
return true // still in cooldown
|
|
213
|
+
}
|
|
214
|
+
// Cooldown expired — re-enable for a trial
|
|
215
|
+
h.disabled = false
|
|
216
|
+
h.failures = 0
|
|
217
|
+
process.stderr.write(`🔄 Hook "${key}" cooldown expired — re-enabled for trial.\n`)
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Record a hook success — resets failure counter. */
|
|
225
|
+
private recordSuccess(key: string): void {
|
|
226
|
+
const h = this.health.get(key)
|
|
227
|
+
if (h) {
|
|
228
|
+
h.failures = 0
|
|
229
|
+
h.disabled = false
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Record a hook failure — may trigger auto-disable. */
|
|
234
|
+
private recordFailure(key: string): void {
|
|
235
|
+
let h = this.health.get(key)
|
|
236
|
+
if (!h) {
|
|
237
|
+
h = { failures: 0, lastFailureTime: 0, disabled: false, disabledAt: 0, totalFailures: 0 }
|
|
238
|
+
this.health.set(key, h)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
h.failures++
|
|
242
|
+
h.totalFailures++
|
|
243
|
+
h.lastFailureTime = Date.now()
|
|
244
|
+
|
|
245
|
+
if (h.failures >= MAX_CONSECUTIVE_FAILURES && !h.disabled) {
|
|
246
|
+
h.disabled = true
|
|
247
|
+
h.disabledAt = Date.now()
|
|
248
|
+
process.stderr.write(
|
|
249
|
+
`⚠️ Hook "${key}" failed ${MAX_CONSECUTIVE_FAILURES} consecutive times — auto-disabled for ${COOLDOWN_MS / 60_000} minutes.\n` +
|
|
250
|
+
` Use /hooks enable "${key}" to re-enable manually.\n`,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Get the health status of all hooks. */
|
|
256
|
+
getHookHealth(): Array<{ key: string; health: HookHealth }> {
|
|
257
|
+
return Array.from(this.health.entries()).map(([key, health]) => ({ key, health }))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Manually re-enable a disabled hook. */
|
|
261
|
+
reEnableHook(key: string): boolean {
|
|
262
|
+
const h = this.health.get(key)
|
|
263
|
+
if (!h || !h.disabled) return false
|
|
264
|
+
h.disabled = false
|
|
265
|
+
h.failures = 0
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
|
|
180
269
|
// ── Core execution ──
|
|
181
270
|
|
|
182
271
|
private async runHooks(
|
|
@@ -191,9 +280,23 @@ export class HookEngine {
|
|
|
191
280
|
const result: HookResult = { allowed: true }
|
|
192
281
|
|
|
193
282
|
for (const hook of matching) {
|
|
283
|
+
const key = this.healthKey(hook)
|
|
284
|
+
|
|
285
|
+
// ── Resilience: skip disabled hooks ──
|
|
286
|
+
if (this.shouldSkip(key)) {
|
|
287
|
+
result.additionalContext = result.additionalContext
|
|
288
|
+
? result.additionalContext +
|
|
289
|
+
`\n[Hook "${key}" skipped — temporarily disabled after repeated failures]`
|
|
290
|
+
: `[Hook "${key}" skipped — temporarily disabled after repeated failures]`
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
|
|
194
294
|
try {
|
|
195
295
|
const hookResult = await hook.handler(ctx)
|
|
196
296
|
|
|
297
|
+
// Record success
|
|
298
|
+
this.recordSuccess(key)
|
|
299
|
+
|
|
197
300
|
// Block on first deny — stops further hook execution
|
|
198
301
|
if (!hookResult.allowed) {
|
|
199
302
|
return { ...hookResult }
|
|
@@ -233,6 +336,8 @@ export class HookEngine {
|
|
|
233
336
|
result.updatedOutput = hookResult.updatedOutput
|
|
234
337
|
}
|
|
235
338
|
} catch {
|
|
339
|
+
// Record failure — may trigger auto-disable
|
|
340
|
+
this.recordFailure(key)
|
|
236
341
|
// Hook failures do not block execution
|
|
237
342
|
}
|
|
238
343
|
}
|