neuro-cli 4.1.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.
Files changed (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +510 -0
  3. package/dist/advisor/advisor.d.ts +50 -0
  4. package/dist/advisor/advisor.js +178 -0
  5. package/dist/agents/base.d.ts +62 -0
  6. package/dist/agents/base.js +215 -0
  7. package/dist/agents/orchestrator.d.ts +46 -0
  8. package/dist/agents/orchestrator.js +192 -0
  9. package/dist/agents/team.d.ts +51 -0
  10. package/dist/agents/team.js +210 -0
  11. package/dist/api/models.d.ts +23 -0
  12. package/dist/api/models.js +514 -0
  13. package/dist/api/ollama.d.ts +153 -0
  14. package/dist/api/ollama.js +751 -0
  15. package/dist/api/openrouter.d.ts +55 -0
  16. package/dist/api/openrouter.js +223 -0
  17. package/dist/commands/commands.d.ts +43 -0
  18. package/dist/commands/commands.js +308 -0
  19. package/dist/config/config.d.ts +8 -0
  20. package/dist/config/config.js +311 -0
  21. package/dist/context/compaction.d.ts +54 -0
  22. package/dist/context/compaction.js +251 -0
  23. package/dist/context/custom-agents.d.ts +107 -0
  24. package/dist/context/custom-agents.js +397 -0
  25. package/dist/context/custom-tools.d.ts +120 -0
  26. package/dist/context/custom-tools.js +564 -0
  27. package/dist/context/git-checkpoint.d.ts +52 -0
  28. package/dist/context/git-checkpoint.js +240 -0
  29. package/dist/context/neuro-md.d.ts +48 -0
  30. package/dist/context/neuro-md.js +202 -0
  31. package/dist/context/neuroignore.d.ts +102 -0
  32. package/dist/context/neuroignore.js +441 -0
  33. package/dist/context/repo-map.d.ts +38 -0
  34. package/dist/context/repo-map.js +220 -0
  35. package/dist/context/skill-standard.d.ts +262 -0
  36. package/dist/context/skill-standard.js +1156 -0
  37. package/dist/context/skill-system.d.ts +75 -0
  38. package/dist/context/skill-system.js +578 -0
  39. package/dist/context/tree-sitter.d.ts +99 -0
  40. package/dist/context/tree-sitter.js +1956 -0
  41. package/dist/core/acp.d.ts +325 -0
  42. package/dist/core/acp.js +1498 -0
  43. package/dist/core/api-server.d.ts +143 -0
  44. package/dist/core/api-server.js +550 -0
  45. package/dist/core/approval.d.ts +81 -0
  46. package/dist/core/approval.js +432 -0
  47. package/dist/core/auto-compact.d.ts +127 -0
  48. package/dist/core/auto-compact.js +436 -0
  49. package/dist/core/auto-mode.d.ts +232 -0
  50. package/dist/core/auto-mode.js +831 -0
  51. package/dist/core/background-session.d.ts +166 -0
  52. package/dist/core/background-session.js +696 -0
  53. package/dist/core/cicd.d.ts +257 -0
  54. package/dist/core/cicd.js +1443 -0
  55. package/dist/core/cloud-sync.d.ts +156 -0
  56. package/dist/core/cloud-sync.js +582 -0
  57. package/dist/core/code-review.d.ts +132 -0
  58. package/dist/core/code-review.js +1191 -0
  59. package/dist/core/completion.d.ts +49 -0
  60. package/dist/core/completion.js +384 -0
  61. package/dist/core/context.d.ts +38 -0
  62. package/dist/core/context.js +144 -0
  63. package/dist/core/diff-preview.d.ts +35 -0
  64. package/dist/core/diff-preview.js +173 -0
  65. package/dist/core/doom-loop.d.ts +51 -0
  66. package/dist/core/doom-loop.js +179 -0
  67. package/dist/core/engine.d.ts +183 -0
  68. package/dist/core/engine.js +942 -0
  69. package/dist/core/extended-thinking.d.ts +103 -0
  70. package/dist/core/extended-thinking.js +269 -0
  71. package/dist/core/fallback.d.ts +54 -0
  72. package/dist/core/fallback.js +104 -0
  73. package/dist/core/git-worktree.d.ts +139 -0
  74. package/dist/core/git-worktree.js +614 -0
  75. package/dist/core/headless.d.ts +30 -0
  76. package/dist/core/headless.js +101 -0
  77. package/dist/core/i18n.d.ts +83 -0
  78. package/dist/core/i18n.js +739 -0
  79. package/dist/core/linting.d.ts +129 -0
  80. package/dist/core/linting.js +699 -0
  81. package/dist/core/model-router.d.ts +109 -0
  82. package/dist/core/model-router.js +581 -0
  83. package/dist/core/multi-model.d.ts +243 -0
  84. package/dist/core/multi-model.js +1099 -0
  85. package/dist/core/multi-session.d.ts +144 -0
  86. package/dist/core/multi-session.js +442 -0
  87. package/dist/core/multimodal.d.ts +125 -0
  88. package/dist/core/multimodal.js +286 -0
  89. package/dist/core/observability.d.ts +93 -0
  90. package/dist/core/observability.js +737 -0
  91. package/dist/core/os-sandbox.d.ts +122 -0
  92. package/dist/core/os-sandbox.js +1193 -0
  93. package/dist/core/outcome-grading.d.ts +228 -0
  94. package/dist/core/outcome-grading.js +1123 -0
  95. package/dist/core/output-styles.d.ts +57 -0
  96. package/dist/core/output-styles.js +382 -0
  97. package/dist/core/parallel-agents.d.ts +183 -0
  98. package/dist/core/parallel-agents.js +563 -0
  99. package/dist/core/plugin-bundle.d.ts +236 -0
  100. package/dist/core/plugin-bundle.js +887 -0
  101. package/dist/core/plugin-sdk.d.ts +139 -0
  102. package/dist/core/plugin-sdk.js +273 -0
  103. package/dist/core/prompt-cache.d.ts +163 -0
  104. package/dist/core/prompt-cache.js +599 -0
  105. package/dist/core/sandbox.d.ts +127 -0
  106. package/dist/core/sandbox.js +369 -0
  107. package/dist/core/scheduled-tasks.d.ts +151 -0
  108. package/dist/core/scheduled-tasks.js +502 -0
  109. package/dist/core/security-scanner.d.ts +160 -0
  110. package/dist/core/security-scanner.js +1494 -0
  111. package/dist/core/session.d.ts +83 -0
  112. package/dist/core/session.js +269 -0
  113. package/dist/core/shell-completion.d.ts +51 -0
  114. package/dist/core/shell-completion.js +674 -0
  115. package/dist/core/smart-monitor.d.ts +146 -0
  116. package/dist/core/smart-monitor.js +1199 -0
  117. package/dist/core/spec-driven.d.ts +233 -0
  118. package/dist/core/spec-driven.js +1485 -0
  119. package/dist/core/spending-warnings.d.ts +123 -0
  120. package/dist/core/spending-warnings.js +456 -0
  121. package/dist/core/sub-agent.d.ts +298 -0
  122. package/dist/core/sub-agent.js +1023 -0
  123. package/dist/core/telemetry.d.ts +157 -0
  124. package/dist/core/telemetry.js +412 -0
  125. package/dist/core/terminal-ux.d.ts +134 -0
  126. package/dist/core/terminal-ux.js +649 -0
  127. package/dist/core/testing.d.ts +146 -0
  128. package/dist/core/testing.js +1200 -0
  129. package/dist/core/types.d.ts +439 -0
  130. package/dist/core/types.js +6 -0
  131. package/dist/core/undo-redo.d.ts +112 -0
  132. package/dist/core/undo-redo.js +290 -0
  133. package/dist/core/updater.d.ts +159 -0
  134. package/dist/core/updater.js +608 -0
  135. package/dist/core/vim-mode.d.ts +151 -0
  136. package/dist/core/vim-mode.js +771 -0
  137. package/dist/core/voice.d.ts +137 -0
  138. package/dist/core/voice.js +538 -0
  139. package/dist/core/web-dashboard.d.ts +109 -0
  140. package/dist/core/web-dashboard.js +484 -0
  141. package/dist/hooks/hooks.d.ts +74 -0
  142. package/dist/hooks/hooks.js +160 -0
  143. package/dist/hooks/llm-evaluator.d.ts +165 -0
  144. package/dist/hooks/llm-evaluator.js +560 -0
  145. package/dist/index.d.ts +3 -0
  146. package/dist/index.js +1186 -0
  147. package/dist/lsp/lsp-manager.d.ts +63 -0
  148. package/dist/lsp/lsp-manager.js +351 -0
  149. package/dist/mcp/client.d.ts +133 -0
  150. package/dist/mcp/client.js +684 -0
  151. package/dist/mcp/mcp-apps.d.ts +70 -0
  152. package/dist/mcp/mcp-apps.js +1007 -0
  153. package/dist/tools/bash.d.ts +5 -0
  154. package/dist/tools/bash.js +195 -0
  155. package/dist/tools/browser.d.ts +92 -0
  156. package/dist/tools/browser.js +1570 -0
  157. package/dist/tools/extended.d.ts +6 -0
  158. package/dist/tools/extended.js +191 -0
  159. package/dist/tools/file.d.ts +10 -0
  160. package/dist/tools/file.js +382 -0
  161. package/dist/tools/github.d.ts +389 -0
  162. package/dist/tools/github.js +759 -0
  163. package/dist/tools/index.d.ts +9 -0
  164. package/dist/tools/index.js +40 -0
  165. package/dist/tools/memory.d.ts +6 -0
  166. package/dist/tools/memory.js +197 -0
  167. package/dist/tools/registry.d.ts +29 -0
  168. package/dist/tools/registry.js +64 -0
  169. package/dist/tools/web.d.ts +6 -0
  170. package/dist/tools/web.js +150 -0
  171. package/dist/ui/renderer.d.ts +97 -0
  172. package/dist/ui/renderer.js +279 -0
  173. package/dist/ui/theme.d.ts +27 -0
  174. package/dist/ui/theme.js +106 -0
  175. package/package.json +83 -0
@@ -0,0 +1,560 @@
1
+ // ============================================================
2
+ // NeuroCLI - LLM Evaluator Hooks (GAP-31)
3
+ // LLM-based approval/denial evaluators for hook lifecycle events
4
+ // ============================================================
5
+ import { createHash } from 'node:crypto';
6
+ import { readFile, readdir, stat } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ // ---------------------------------------------------------------------------
9
+ // YAML frontmatter parser (lightweight, no external dependency)
10
+ // ---------------------------------------------------------------------------
11
+ function parseYamlFrontmatter(text) {
12
+ const trimmed = text.trimStart();
13
+ if (!trimmed.startsWith('---')) {
14
+ return { frontmatter: {}, body: text };
15
+ }
16
+ const closingIndex = trimmed.indexOf('---', 3);
17
+ if (closingIndex === -1) {
18
+ return { frontmatter: {}, body: text };
19
+ }
20
+ const yamlBlock = trimmed.slice(3, closingIndex).trim();
21
+ const body = trimmed.slice(closingIndex + 3).trim();
22
+ const frontmatter = {};
23
+ for (const line of yamlBlock.split('\n')) {
24
+ const colonIdx = line.indexOf(':');
25
+ if (colonIdx === -1)
26
+ continue;
27
+ const key = line.slice(0, colonIdx).trim();
28
+ let value = line.slice(colonIdx + 1).trim();
29
+ if (value === 'true')
30
+ value = true;
31
+ else if (value === 'false')
32
+ value = false;
33
+ else if (value === 'null' || value === '~')
34
+ value = null;
35
+ else if (/^-?\d+$/.test(value))
36
+ value = Number(value);
37
+ else if (/^-?\d+\.\d+$/.test(value))
38
+ value = Number(value);
39
+ else if (/^["']/.test(value) && /["']$/.test(value))
40
+ value = value.slice(1, -1);
41
+ const keys = key.split('.');
42
+ let target = frontmatter;
43
+ for (let i = 0; i < keys.length - 1; i++) {
44
+ if (target[keys[i]] === undefined)
45
+ target[keys[i]] = {};
46
+ target = target[keys[i]];
47
+ }
48
+ target[keys[keys.length - 1]] = value;
49
+ }
50
+ return { frontmatter, body };
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // Prompt construction helpers
54
+ // ---------------------------------------------------------------------------
55
+ const SYSTEM_PROMPT = `You are an action evaluator for NeuroCLI, an AI-powered terminal coding assistant.
56
+ Your job is to evaluate whether a proposed tool action should be approved, denied, modified, or escalated to the user.
57
+
58
+ You MUST respond with a JSON object (and nothing else) in the following format:
59
+ {
60
+ "decision": "approve" | "deny" | "modify" | "ask-user",
61
+ "confidence": <number between 0 and 1>,
62
+ "reasoning": "<your reasoning>",
63
+ "suggestedModification": { ... } // only when decision is "modify"
64
+ "userQuestion": "<question>" // only when decision is "ask-user"
65
+ }
66
+
67
+ Guidelines:
68
+ - "approve": The action is safe and aligns with project goals.
69
+ - "deny": The action is unsafe, destructive, or violates the rubric.
70
+ - "modify": The action is mostly fine but needs adjustments to the arguments.
71
+ - "ask-user": You are uncertain and the user should decide.
72
+ - If your confidence is below 0.7, prefer "ask-user" over a direct decision.
73
+ - Always explain your reasoning clearly.`;
74
+ function buildEvaluationPrompt(hook, context) {
75
+ const parts = [];
76
+ parts.push(`## Evaluation Rubric\n${hook.rubric}`);
77
+ parts.push(`## Proposed Action\nTool: ${context.toolName}`);
78
+ parts.push(`Arguments:\n\`\`\`json\n${JSON.stringify(context.toolArgs, null, 2)}\n\`\`\``);
79
+ if (context.fileChanges && context.fileChanges.length > 0) {
80
+ parts.push(`## File Changes\n${context.fileChanges.map(fc => {
81
+ const label = fc.type === 'create' ? 'CREATE' : fc.type === 'delete' ? 'DELETE' : 'MODIFY';
82
+ return `- [${label}] ${fc.path}${fc.diff ? '\n' + fc.diff : ''}`;
83
+ }).join('\n')}`);
84
+ }
85
+ if (context.commandOutput) {
86
+ parts.push(`## Command Output\n\`\`\`\n${context.commandOutput.slice(0, 4000)}\n\`\`\``);
87
+ }
88
+ if (context.conversationContext) {
89
+ parts.push(`## Conversation Context\n${context.conversationContext.slice(0, 4000)}`);
90
+ }
91
+ if (context.projectContext) {
92
+ parts.push(`## Project Context (NEURO.md)\n${context.projectContext.slice(0, 4000)}`);
93
+ }
94
+ return parts.join('\n\n');
95
+ }
96
+ // ---------------------------------------------------------------------------
97
+ // Cache key computation
98
+ // ---------------------------------------------------------------------------
99
+ function computeCacheKey(hook, context) {
100
+ const payload = JSON.stringify({
101
+ tool: context.toolName,
102
+ args: context.toolArgs,
103
+ rubric: hook.rubric,
104
+ model: hook.evaluatorModel,
105
+ });
106
+ return createHash('sha256').update(payload).digest('hex');
107
+ }
108
+ // ---------------------------------------------------------------------------
109
+ // Ollama client (minimal, for local evaluation models)
110
+ // ---------------------------------------------------------------------------
111
+ async function callOllama(baseUrl, model, systemPrompt, userPrompt, temperature, maxTokens) {
112
+ const start = Date.now();
113
+ const response = await fetch(`${baseUrl}/api/chat`, {
114
+ method: 'POST',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify({
117
+ model,
118
+ messages: [
119
+ { role: 'system', content: systemPrompt },
120
+ { role: 'user', content: userPrompt },
121
+ ],
122
+ stream: false,
123
+ options: { temperature, num_predict: maxTokens },
124
+ }),
125
+ signal: AbortSignal.timeout(60_000),
126
+ });
127
+ if (!response.ok) {
128
+ const body = await response.text();
129
+ throw new Error(`Ollama API error (${response.status}): ${body}`);
130
+ }
131
+ const data = await response.json();
132
+ const content = data.message?.content ?? '';
133
+ return { content, duration: Date.now() - start };
134
+ }
135
+ // ---------------------------------------------------------------------------
136
+ // LLMEvaluatorManager
137
+ // ---------------------------------------------------------------------------
138
+ export class LLMEvaluatorManager {
139
+ apiClient;
140
+ evaluators = new Map();
141
+ eventIndex = new Map();
142
+ cache = new Map();
143
+ stats = {
144
+ totalEvaluations: 0,
145
+ totalTokensUsed: 0,
146
+ totalCost: 0,
147
+ cacheHits: 0,
148
+ cacheMisses: 0,
149
+ decisions: { approve: 0, deny: 0, modify: 0, 'ask-user': 0 },
150
+ byEvaluator: {},
151
+ };
152
+ defaultModel;
153
+ defaultRubric;
154
+ confidenceThreshold;
155
+ cacheEnabled;
156
+ defaultCacheTTL;
157
+ maxCacheEntries;
158
+ ollamaBaseUrl;
159
+ constructor(apiClient, config) {
160
+ this.apiClient = apiClient;
161
+ this.defaultModel = config?.defaultModel ?? 'google/gemma-4-31b-it:free';
162
+ this.defaultRubric = config?.defaultRubric ?? 'Evaluate whether this action is safe and aligns with project goals';
163
+ this.confidenceThreshold = config?.confidenceThreshold ?? 0.7;
164
+ this.cacheEnabled = config?.cacheEnabled ?? true;
165
+ this.defaultCacheTTL = config?.defaultCacheTTL ?? 600;
166
+ this.maxCacheEntries = config?.maxCacheEntries ?? 1000;
167
+ this.ollamaBaseUrl = config?.ollamaBaseUrl ?? 'http://localhost:11434';
168
+ }
169
+ // -----------------------------------------------------------------------
170
+ // Evaluator registration
171
+ // -----------------------------------------------------------------------
172
+ /** Register a new LLM evaluator hook. */
173
+ registerEvaluator(hook) {
174
+ const resolved = {
175
+ ...hook,
176
+ evaluatorModel: hook.evaluatorModel || this.defaultModel,
177
+ rubric: hook.rubric || this.defaultRubric,
178
+ maxTokens: hook.maxTokens || 512,
179
+ temperature: hook.temperature ?? 0.2,
180
+ cacheDecisions: hook.cacheDecisions ?? this.cacheEnabled,
181
+ cacheTTL: hook.cacheTTL || this.defaultCacheTTL,
182
+ enabled: hook.enabled !== false,
183
+ };
184
+ this.evaluators.set(resolved.id, resolved);
185
+ if (!this.eventIndex.has(resolved.event)) {
186
+ this.eventIndex.set(resolved.event, []);
187
+ }
188
+ const list = this.eventIndex.get(resolved.event);
189
+ if (!list.includes(resolved.id)) {
190
+ list.push(resolved.id);
191
+ }
192
+ }
193
+ /** Remove a registered evaluator by its id. */
194
+ unregisterEvaluator(id) {
195
+ const hook = this.evaluators.get(id);
196
+ if (!hook)
197
+ return;
198
+ this.evaluators.delete(id);
199
+ const list = this.eventIndex.get(hook.event);
200
+ if (list) {
201
+ const idx = list.indexOf(id);
202
+ if (idx !== -1)
203
+ list.splice(idx, 1);
204
+ if (list.length === 0)
205
+ this.eventIndex.delete(hook.event);
206
+ }
207
+ delete this.stats.byEvaluator[id];
208
+ }
209
+ /** Return all registered evaluators. */
210
+ listEvaluators() {
211
+ return Array.from(this.evaluators.values());
212
+ }
213
+ // -----------------------------------------------------------------------
214
+ // Core evaluation
215
+ // -----------------------------------------------------------------------
216
+ /**
217
+ * Run all evaluators registered for `event` against the given `context`.
218
+ * Evaluators are executed sequentially in registration order.
219
+ * The first "deny" short-circuits; "modify" updates the context for
220
+ * subsequent evaluators; "ask-user" is collected and returned.
221
+ */
222
+ async evaluate(event, context) {
223
+ const evaluatorIds = this.eventIndex.get(event) ?? [];
224
+ const matchingEvaluators = evaluatorIds
225
+ .map(id => this.evaluators.get(id))
226
+ .filter(h => h.enabled !== false)
227
+ .filter(h => {
228
+ if (!h.matcher)
229
+ return true;
230
+ return new RegExp(h.matcher).test(context.toolName);
231
+ });
232
+ if (matchingEvaluators.length === 0) {
233
+ return {
234
+ decision: 'approve',
235
+ confidence: 1.0,
236
+ reasoning: 'No evaluators registered for this event/tool combination.',
237
+ cached: false,
238
+ modelUsed: 'none',
239
+ tokensUsed: 0,
240
+ duration: 0,
241
+ };
242
+ }
243
+ let lastResult = null;
244
+ let aggregatedTokens = 0;
245
+ let aggregatedDuration = 0;
246
+ for (const hook of matchingEvaluators) {
247
+ const result = await this.evaluateSingle(hook, context);
248
+ aggregatedTokens += result.tokensUsed;
249
+ aggregatedDuration += result.duration;
250
+ // If confidence is below threshold, escalate to ask-user
251
+ if (result.confidence < this.confidenceThreshold && result.decision !== 'ask-user') {
252
+ result.decision = 'ask-user';
253
+ result.userQuestion = `Low confidence (${(result.confidence * 100).toFixed(0)}%) on evaluation. Reasoning: ${result.reasoning}`;
254
+ }
255
+ // Track stats
256
+ this.stats.totalEvaluations += 1;
257
+ this.stats.totalTokensUsed += result.tokensUsed;
258
+ this.stats.decisions[result.decision] = (this.stats.decisions[result.decision] ?? 0) + 1;
259
+ if (!this.stats.byEvaluator[hook.id]) {
260
+ this.stats.byEvaluator[hook.id] = { evaluations: 0, tokensUsed: 0, cost: 0 };
261
+ }
262
+ this.stats.byEvaluator[hook.id].evaluations += 1;
263
+ this.stats.byEvaluator[hook.id].tokensUsed += result.tokensUsed;
264
+ lastResult = result;
265
+ // Short-circuit on deny
266
+ if (result.decision === 'deny') {
267
+ return {
268
+ ...result,
269
+ tokensUsed: aggregatedTokens,
270
+ duration: aggregatedDuration,
271
+ };
272
+ }
273
+ // Apply modifications to context for downstream evaluators
274
+ if (result.decision === 'modify' && result.suggestedModification) {
275
+ context.toolArgs = { ...context.toolArgs, ...result.suggestedModification };
276
+ }
277
+ }
278
+ return {
279
+ ...lastResult,
280
+ tokensUsed: aggregatedTokens,
281
+ duration: aggregatedDuration,
282
+ };
283
+ }
284
+ // -----------------------------------------------------------------------
285
+ // Single evaluator execution
286
+ // -----------------------------------------------------------------------
287
+ async evaluateSingle(hook, context) {
288
+ const startTime = Date.now();
289
+ // Check cache
290
+ if (hook.cacheDecisions) {
291
+ const cached = this.getFromCache(hook, context);
292
+ if (cached) {
293
+ this.stats.cacheHits += 1;
294
+ return { ...cached, cached: true, duration: Date.now() - startTime };
295
+ }
296
+ this.stats.cacheMisses += 1;
297
+ }
298
+ // Build prompt
299
+ const userPrompt = buildEvaluationPrompt(hook, context);
300
+ let rawContent;
301
+ let tokensUsed;
302
+ let cost;
303
+ let modelUsed = hook.evaluatorModel;
304
+ try {
305
+ if (hook.provider === 'ollama') {
306
+ const ollamaResult = await callOllama(hook.ollamaBaseUrl ?? this.ollamaBaseUrl, hook.evaluatorModel, SYSTEM_PROMPT, userPrompt, hook.temperature, hook.maxTokens);
307
+ rawContent = ollamaResult.content;
308
+ tokensUsed = this.estimateTokens(rawContent);
309
+ cost = 0; // local model, no cost
310
+ modelUsed = hook.evaluatorModel;
311
+ }
312
+ else {
313
+ // Use OpenRouter client
314
+ const chatResult = await this.apiClient.quickChat(hook.evaluatorModel, [
315
+ { role: 'system', content: SYSTEM_PROMPT },
316
+ { role: 'user', content: userPrompt },
317
+ ], undefined, hook.temperature);
318
+ rawContent = chatResult.content;
319
+ tokensUsed = chatResult.usage.inputTokens + chatResult.usage.outputTokens;
320
+ cost = chatResult.usage.cost;
321
+ modelUsed = hook.evaluatorModel;
322
+ }
323
+ }
324
+ catch (error) {
325
+ // On error, fall back to the hook's default action
326
+ return {
327
+ decision: hook.action,
328
+ confidence: 0,
329
+ reasoning: `Evaluator call failed: ${error instanceof Error ? error.message : String(error)}. Falling back to default action.`,
330
+ cached: false,
331
+ modelUsed: 'error-fallback',
332
+ tokensUsed: 0,
333
+ duration: Date.now() - startTime,
334
+ };
335
+ }
336
+ // Parse the LLM response
337
+ const result = this.parseEvaluationResponse(rawContent, modelUsed, tokensUsed);
338
+ result.duration = Date.now() - startTime;
339
+ result.cached = false;
340
+ // Track cost
341
+ this.stats.totalCost += cost;
342
+ if (this.stats.byEvaluator[hook.id]) {
343
+ this.stats.byEvaluator[hook.id].cost += cost;
344
+ }
345
+ // Store in cache
346
+ if (hook.cacheDecisions) {
347
+ this.storeInCache(hook, context, result);
348
+ }
349
+ return result;
350
+ }
351
+ // -----------------------------------------------------------------------
352
+ // Response parsing
353
+ // -----------------------------------------------------------------------
354
+ parseEvaluationResponse(rawContent, modelUsed, tokensUsed) {
355
+ // Try to extract JSON from the response
356
+ let parsed = null;
357
+ // Attempt 1: Direct parse of the full content
358
+ try {
359
+ parsed = JSON.parse(rawContent);
360
+ }
361
+ catch {
362
+ // Attempt 2: Extract JSON from markdown code fences
363
+ const fenceMatch = rawContent.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
364
+ if (fenceMatch) {
365
+ try {
366
+ parsed = JSON.parse(fenceMatch[1]);
367
+ }
368
+ catch {
369
+ // continue to fallback
370
+ }
371
+ }
372
+ // Attempt 3: Find the first { ... } block
373
+ if (!parsed) {
374
+ const braceMatch = rawContent.match(/\{[\s\S]*\}/);
375
+ if (braceMatch) {
376
+ try {
377
+ parsed = JSON.parse(braceMatch[0]);
378
+ }
379
+ catch {
380
+ // continue to fallback
381
+ }
382
+ }
383
+ }
384
+ }
385
+ // Fallback: if we could not parse JSON, create a heuristic result
386
+ if (!parsed || typeof parsed !== 'object') {
387
+ const lower = rawContent.toLowerCase();
388
+ let decision = 'approve';
389
+ if (lower.includes('deny') || lower.includes('block') || lower.includes('reject')) {
390
+ decision = 'deny';
391
+ }
392
+ else if (lower.includes('modify') || lower.includes('adjust') || lower.includes('suggest')) {
393
+ decision = 'modify';
394
+ }
395
+ else if (lower.includes('ask') || lower.includes('unsure') || lower.includes('uncertain')) {
396
+ decision = 'ask-user';
397
+ }
398
+ return {
399
+ decision,
400
+ confidence: 0.5,
401
+ reasoning: rawContent.slice(0, 1000),
402
+ cached: false,
403
+ modelUsed,
404
+ tokensUsed,
405
+ duration: 0,
406
+ };
407
+ }
408
+ // Validate and normalise parsed object
409
+ const validDecisions = new Set(['approve', 'deny', 'modify', 'ask-user']);
410
+ const decision = validDecisions.has(parsed.decision) ? parsed.decision : 'ask-user';
411
+ const confidence = typeof parsed.confidence === 'number'
412
+ ? Math.max(0, Math.min(1, parsed.confidence))
413
+ : 0.5;
414
+ const reasoning = typeof parsed.reasoning === 'string' ? parsed.reasoning : '';
415
+ const suggestedModification = parsed.suggestedModification ?? undefined;
416
+ const userQuestion = parsed.userQuestion ?? undefined;
417
+ return {
418
+ decision,
419
+ confidence,
420
+ reasoning,
421
+ suggestedModification,
422
+ userQuestion,
423
+ cached: false,
424
+ modelUsed,
425
+ tokensUsed,
426
+ duration: 0,
427
+ };
428
+ }
429
+ // -----------------------------------------------------------------------
430
+ // Cache management
431
+ // -----------------------------------------------------------------------
432
+ getFromCache(hook, context) {
433
+ const key = computeCacheKey(hook, context);
434
+ const entry = this.cache.get(key);
435
+ if (!entry)
436
+ return null;
437
+ if (Date.now() > entry.expiresAt) {
438
+ this.cache.delete(key);
439
+ return null;
440
+ }
441
+ return { ...entry.result };
442
+ }
443
+ storeInCache(hook, context, result) {
444
+ // Evict oldest entries when cache is full
445
+ if (this.cache.size >= this.maxCacheEntries) {
446
+ const keys = Array.from(this.cache.keys());
447
+ const toDelete = Math.ceil(this.maxCacheEntries * 0.2);
448
+ for (let i = 0; i < toDelete && i < keys.length; i++) {
449
+ this.cache.delete(keys[i]);
450
+ }
451
+ }
452
+ const key = computeCacheKey(hook, context);
453
+ this.cache.set(key, {
454
+ result: { ...result },
455
+ expiresAt: Date.now() + hook.cacheTTL * 1000,
456
+ });
457
+ }
458
+ /** Remove all cached decisions. */
459
+ clearCache() {
460
+ this.cache.clear();
461
+ }
462
+ // -----------------------------------------------------------------------
463
+ // Loading evaluators from .neuro/hooks/ YAML files
464
+ // -----------------------------------------------------------------------
465
+ /**
466
+ * Scan `dir/.neuro/hooks/` for YAML files with frontmatter and register
467
+ * them as evaluators.
468
+ *
469
+ * Expected file format:
470
+ * ```
471
+ * ---
472
+ * id: my-evaluator
473
+ * name: My Safety Evaluator
474
+ * event: BeforeTool
475
+ * evaluatorModel: google/gemma-4-31b-it:free
476
+ * action: deny
477
+ * maxTokens: 256
478
+ * temperature: 0.1
479
+ * cacheDecisions: true
480
+ * cacheTTL: 300
481
+ * matcher: "write|delete|exec"
482
+ * provider: openrouter
483
+ * ---
484
+ * Never allow modifications to .env files or deletion of test files.
485
+ * ```
486
+ *
487
+ * The body (after frontmatter) becomes the rubric.
488
+ */
489
+ async loadFromConfig(dir) {
490
+ const hooksDir = join(dir, '.neuro', 'hooks');
491
+ let entries;
492
+ try {
493
+ entries = await readdir(hooksDir);
494
+ }
495
+ catch {
496
+ // Directory does not exist — nothing to load
497
+ return;
498
+ }
499
+ for (const entry of entries) {
500
+ if (!entry.endsWith('.yml') && !entry.endsWith('.yaml'))
501
+ continue;
502
+ const filePath = join(hooksDir, entry);
503
+ let fileStat;
504
+ try {
505
+ fileStat = await stat(filePath);
506
+ }
507
+ catch {
508
+ continue;
509
+ }
510
+ if (!fileStat.isFile())
511
+ continue;
512
+ const content = await readFile(filePath, 'utf-8');
513
+ const { frontmatter, body } = parseYamlFrontmatter(content);
514
+ if (!frontmatter.id || !frontmatter.event) {
515
+ // Skip files missing required fields
516
+ continue;
517
+ }
518
+ const hook = {
519
+ id: String(frontmatter.id),
520
+ name: String(frontmatter.name ?? frontmatter.id),
521
+ event: frontmatter.event,
522
+ evaluatorModel: String(frontmatter.evaluatorModel ?? this.defaultModel),
523
+ rubric: String(frontmatter.rubric ?? body ?? this.defaultRubric),
524
+ action: frontmatter.action ?? 'ask-user',
525
+ maxTokens: typeof frontmatter.maxTokens === 'number' ? frontmatter.maxTokens : 512,
526
+ temperature: typeof frontmatter.temperature === 'number' ? frontmatter.temperature : 0.2,
527
+ cacheDecisions: frontmatter.cacheDecisions !== false,
528
+ cacheTTL: typeof frontmatter.cacheTTL === 'number' ? frontmatter.cacheTTL : this.defaultCacheTTL,
529
+ matcher: frontmatter.matcher != null ? String(frontmatter.matcher) : undefined,
530
+ enabled: frontmatter.enabled !== false,
531
+ provider: frontmatter.provider ?? undefined,
532
+ ollamaBaseUrl: frontmatter.ollamaBaseUrl != null ? String(frontmatter.ollamaBaseUrl) : undefined,
533
+ };
534
+ this.registerEvaluator(hook);
535
+ }
536
+ }
537
+ // -----------------------------------------------------------------------
538
+ // Statistics
539
+ // -----------------------------------------------------------------------
540
+ /** Return a snapshot of aggregate evaluation statistics. */
541
+ getStats() {
542
+ return {
543
+ totalEvaluations: this.stats.totalEvaluations,
544
+ totalTokensUsed: this.stats.totalTokensUsed,
545
+ totalCost: this.stats.totalCost,
546
+ cacheHits: this.stats.cacheHits,
547
+ cacheMisses: this.stats.cacheMisses,
548
+ decisions: { ...this.stats.decisions },
549
+ byEvaluator: Object.fromEntries(Object.entries(this.stats.byEvaluator).map(([id, s]) => [id, { ...s }])),
550
+ };
551
+ }
552
+ // -----------------------------------------------------------------------
553
+ // Helpers
554
+ // -----------------------------------------------------------------------
555
+ /** Rough token estimator when exact counts are unavailable. */
556
+ estimateTokens(text) {
557
+ return Math.ceil(text.length / 4);
558
+ }
559
+ }
560
+ //# sourceMappingURL=llm-evaluator.js.map
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map