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.
- package/LICENSE +21 -0
- package/README.md +510 -0
- package/dist/advisor/advisor.d.ts +50 -0
- package/dist/advisor/advisor.js +178 -0
- package/dist/agents/base.d.ts +62 -0
- package/dist/agents/base.js +215 -0
- package/dist/agents/orchestrator.d.ts +46 -0
- package/dist/agents/orchestrator.js +192 -0
- package/dist/agents/team.d.ts +51 -0
- package/dist/agents/team.js +210 -0
- package/dist/api/models.d.ts +23 -0
- package/dist/api/models.js +514 -0
- package/dist/api/ollama.d.ts +153 -0
- package/dist/api/ollama.js +751 -0
- package/dist/api/openrouter.d.ts +55 -0
- package/dist/api/openrouter.js +223 -0
- package/dist/commands/commands.d.ts +43 -0
- package/dist/commands/commands.js +308 -0
- package/dist/config/config.d.ts +8 -0
- package/dist/config/config.js +311 -0
- package/dist/context/compaction.d.ts +54 -0
- package/dist/context/compaction.js +251 -0
- package/dist/context/custom-agents.d.ts +107 -0
- package/dist/context/custom-agents.js +397 -0
- package/dist/context/custom-tools.d.ts +120 -0
- package/dist/context/custom-tools.js +564 -0
- package/dist/context/git-checkpoint.d.ts +52 -0
- package/dist/context/git-checkpoint.js +240 -0
- package/dist/context/neuro-md.d.ts +48 -0
- package/dist/context/neuro-md.js +202 -0
- package/dist/context/neuroignore.d.ts +102 -0
- package/dist/context/neuroignore.js +441 -0
- package/dist/context/repo-map.d.ts +38 -0
- package/dist/context/repo-map.js +220 -0
- package/dist/context/skill-standard.d.ts +262 -0
- package/dist/context/skill-standard.js +1156 -0
- package/dist/context/skill-system.d.ts +75 -0
- package/dist/context/skill-system.js +578 -0
- package/dist/context/tree-sitter.d.ts +99 -0
- package/dist/context/tree-sitter.js +1956 -0
- package/dist/core/acp.d.ts +325 -0
- package/dist/core/acp.js +1498 -0
- package/dist/core/api-server.d.ts +143 -0
- package/dist/core/api-server.js +550 -0
- package/dist/core/approval.d.ts +81 -0
- package/dist/core/approval.js +432 -0
- package/dist/core/auto-compact.d.ts +127 -0
- package/dist/core/auto-compact.js +436 -0
- package/dist/core/auto-mode.d.ts +232 -0
- package/dist/core/auto-mode.js +831 -0
- package/dist/core/background-session.d.ts +166 -0
- package/dist/core/background-session.js +696 -0
- package/dist/core/cicd.d.ts +257 -0
- package/dist/core/cicd.js +1443 -0
- package/dist/core/cloud-sync.d.ts +156 -0
- package/dist/core/cloud-sync.js +582 -0
- package/dist/core/code-review.d.ts +132 -0
- package/dist/core/code-review.js +1191 -0
- package/dist/core/completion.d.ts +49 -0
- package/dist/core/completion.js +384 -0
- package/dist/core/context.d.ts +38 -0
- package/dist/core/context.js +144 -0
- package/dist/core/diff-preview.d.ts +35 -0
- package/dist/core/diff-preview.js +173 -0
- package/dist/core/doom-loop.d.ts +51 -0
- package/dist/core/doom-loop.js +179 -0
- package/dist/core/engine.d.ts +183 -0
- package/dist/core/engine.js +942 -0
- package/dist/core/extended-thinking.d.ts +103 -0
- package/dist/core/extended-thinking.js +269 -0
- package/dist/core/fallback.d.ts +54 -0
- package/dist/core/fallback.js +104 -0
- package/dist/core/git-worktree.d.ts +139 -0
- package/dist/core/git-worktree.js +614 -0
- package/dist/core/headless.d.ts +30 -0
- package/dist/core/headless.js +101 -0
- package/dist/core/i18n.d.ts +83 -0
- package/dist/core/i18n.js +739 -0
- package/dist/core/linting.d.ts +129 -0
- package/dist/core/linting.js +699 -0
- package/dist/core/model-router.d.ts +109 -0
- package/dist/core/model-router.js +581 -0
- package/dist/core/multi-model.d.ts +243 -0
- package/dist/core/multi-model.js +1099 -0
- package/dist/core/multi-session.d.ts +144 -0
- package/dist/core/multi-session.js +442 -0
- package/dist/core/multimodal.d.ts +125 -0
- package/dist/core/multimodal.js +286 -0
- package/dist/core/observability.d.ts +93 -0
- package/dist/core/observability.js +737 -0
- package/dist/core/os-sandbox.d.ts +122 -0
- package/dist/core/os-sandbox.js +1193 -0
- package/dist/core/outcome-grading.d.ts +228 -0
- package/dist/core/outcome-grading.js +1123 -0
- package/dist/core/output-styles.d.ts +57 -0
- package/dist/core/output-styles.js +382 -0
- package/dist/core/parallel-agents.d.ts +183 -0
- package/dist/core/parallel-agents.js +563 -0
- package/dist/core/plugin-bundle.d.ts +236 -0
- package/dist/core/plugin-bundle.js +887 -0
- package/dist/core/plugin-sdk.d.ts +139 -0
- package/dist/core/plugin-sdk.js +273 -0
- package/dist/core/prompt-cache.d.ts +163 -0
- package/dist/core/prompt-cache.js +599 -0
- package/dist/core/sandbox.d.ts +127 -0
- package/dist/core/sandbox.js +369 -0
- package/dist/core/scheduled-tasks.d.ts +151 -0
- package/dist/core/scheduled-tasks.js +502 -0
- package/dist/core/security-scanner.d.ts +160 -0
- package/dist/core/security-scanner.js +1494 -0
- package/dist/core/session.d.ts +83 -0
- package/dist/core/session.js +269 -0
- package/dist/core/shell-completion.d.ts +51 -0
- package/dist/core/shell-completion.js +674 -0
- package/dist/core/smart-monitor.d.ts +146 -0
- package/dist/core/smart-monitor.js +1199 -0
- package/dist/core/spec-driven.d.ts +233 -0
- package/dist/core/spec-driven.js +1485 -0
- package/dist/core/spending-warnings.d.ts +123 -0
- package/dist/core/spending-warnings.js +456 -0
- package/dist/core/sub-agent.d.ts +298 -0
- package/dist/core/sub-agent.js +1023 -0
- package/dist/core/telemetry.d.ts +157 -0
- package/dist/core/telemetry.js +412 -0
- package/dist/core/terminal-ux.d.ts +134 -0
- package/dist/core/terminal-ux.js +649 -0
- package/dist/core/testing.d.ts +146 -0
- package/dist/core/testing.js +1200 -0
- package/dist/core/types.d.ts +439 -0
- package/dist/core/types.js +6 -0
- package/dist/core/undo-redo.d.ts +112 -0
- package/dist/core/undo-redo.js +290 -0
- package/dist/core/updater.d.ts +159 -0
- package/dist/core/updater.js +608 -0
- package/dist/core/vim-mode.d.ts +151 -0
- package/dist/core/vim-mode.js +771 -0
- package/dist/core/voice.d.ts +137 -0
- package/dist/core/voice.js +538 -0
- package/dist/core/web-dashboard.d.ts +109 -0
- package/dist/core/web-dashboard.js +484 -0
- package/dist/hooks/hooks.d.ts +74 -0
- package/dist/hooks/hooks.js +160 -0
- package/dist/hooks/llm-evaluator.d.ts +165 -0
- package/dist/hooks/llm-evaluator.js +560 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1186 -0
- package/dist/lsp/lsp-manager.d.ts +63 -0
- package/dist/lsp/lsp-manager.js +351 -0
- package/dist/mcp/client.d.ts +133 -0
- package/dist/mcp/client.js +684 -0
- package/dist/mcp/mcp-apps.d.ts +70 -0
- package/dist/mcp/mcp-apps.js +1007 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +195 -0
- package/dist/tools/browser.d.ts +92 -0
- package/dist/tools/browser.js +1570 -0
- package/dist/tools/extended.d.ts +6 -0
- package/dist/tools/extended.js +191 -0
- package/dist/tools/file.d.ts +10 -0
- package/dist/tools/file.js +382 -0
- package/dist/tools/github.d.ts +389 -0
- package/dist/tools/github.js +759 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +40 -0
- package/dist/tools/memory.d.ts +6 -0
- package/dist/tools/memory.js +197 -0
- package/dist/tools/registry.d.ts +29 -0
- package/dist/tools/registry.js +64 -0
- package/dist/tools/web.d.ts +6 -0
- package/dist/tools/web.js +150 -0
- package/dist/ui/renderer.d.ts +97 -0
- package/dist/ui/renderer.js +279 -0
- package/dist/ui/theme.d.ts +27 -0
- package/dist/ui/theme.js +106 -0
- 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
|
package/dist/index.d.ts
ADDED