@rahul05ranjan/dhruv-cli 1.4.6 → 1.5.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/.github/workflows/ci.yml +18 -238
- package/.github/workflows/contribution.yml +6 -141
- package/.github/workflows/dependabot-auto-merge.yml +1 -0
- package/.github/workflows/labeler.yml +1 -0
- package/.github/workflows/security.yml +3 -0
- package/CHANGELOG.md +9 -4
- package/README.md +145 -40
- package/__tests__/cli-contract.test.ts +104 -0
- package/__tests__/core.test.ts +121 -0
- package/__tests__/diagnostics.test.ts +155 -0
- package/__tests__/file-workflows.test.ts +195 -0
- package/__tests__/interactive.test.ts +119 -0
- package/__tests__/setup.ts +1 -0
- package/__tests__/workflows.test.ts +27 -4
- package/dist/commands/generate.d.ts +6 -1
- package/dist/commands/generate.js +18 -4
- package/dist/commands/health.d.ts +4 -1
- package/dist/commands/health.js +59 -16
- package/dist/commands/init.js +11 -2
- package/dist/commands/menu.js +125 -100
- package/dist/commands/metrics.d.ts +5 -1
- package/dist/commands/metrics.js +37 -8
- package/dist/commands/optimize.js +1 -1
- package/dist/commands/review.d.ts +4 -1
- package/dist/commands/review.js +48 -8
- package/dist/commands/security-check.d.ts +4 -1
- package/dist/commands/security-check.js +67 -6
- package/dist/commands/status.js +40 -2
- package/dist/config/config.d.ts +5 -1
- package/dist/config/config.js +24 -7
- package/dist/core/ai.d.ts +11 -0
- package/dist/core/ai.js +33 -9
- package/dist/core/command-catalog.d.ts +10 -0
- package/dist/core/command-catalog.js +27 -0
- package/dist/core/command-runner.js +100 -21
- package/dist/core/logger.js +1 -0
- package/dist/core/metrics.d.ts +28 -0
- package/dist/core/metrics.js +78 -0
- package/dist/index.js +46 -24
- package/dist/utils/projectType.d.ts +1 -1
- package/dist/utils/projectType.js +26 -7
- package/docs/api/assets/highlight.css +4 -4
- package/docs/api/index.html +161 -39
- package/docs/api/media/CONTRIBUTING.md +60 -0
- package/docs/api/media/SECURITY.md +8 -0
- package/docs/api/media/dhruv-cli-preview.svg +42 -0
- package/docs/api/media/publishing-fix.md +34 -0
- package/docs/dhruv-cli-preview.svg +42 -0
- package/docs/index.html +631 -533
- package/docs/publishing-fix.md +34 -0
- package/package.json +1 -1
- package/src/commands/generate.ts +23 -4
- package/src/commands/health.ts +62 -17
- package/src/commands/init.ts +11 -2
- package/src/commands/menu.ts +54 -30
- package/src/commands/metrics.ts +42 -7
- package/src/commands/optimize.ts +1 -1
- package/src/commands/review.ts +53 -8
- package/src/commands/security-check.ts +80 -6
- package/src/commands/status.ts +39 -3
- package/src/config/config.ts +26 -7
- package/src/core/ai.ts +36 -8
- package/src/core/command-catalog.ts +37 -0
- package/src/core/command-runner.ts +102 -22
- package/src/core/logger.ts +1 -0
- package/src/core/metrics.ts +103 -0
- package/src/index.ts +45 -24
- package/src/utils/projectType.ts +22 -7
- package/tsconfig.json +1 -1
- package/.github/workflows/auto-assign.yml +0 -14
- package/.github/workflows/build-publish.yml +0 -154
- package/.github/workflows/deploy.yml +0 -336
- package/.github/workflows/monitoring.yml +0 -270
- package/PUBLISHING_FIX.md +0 -92
- package/logs/.8a99b6cf655346317fdbf29f4fffcf91131432f3-audit.json +0 -15
- package/logs/.eee104bf8fff5ecd38a6a2842df260de6470a7c3-audit.json +0 -15
package/dist/config/config.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
|
|
3
|
+
import os from 'os';
|
|
4
|
+
const LOCAL_CONFIG_FILE = path.join(process.cwd(), '.dhruv-config.json');
|
|
5
|
+
const GLOBAL_CONFIG_FILE = path.join(os.homedir(), '.config', 'dhruv', 'config.json');
|
|
4
6
|
const defaultConfig = {
|
|
5
7
|
model: 'gemma3:270m',
|
|
6
8
|
verbose: false,
|
|
7
9
|
responseFormat: 'text',
|
|
10
|
+
timeoutMs: 45000,
|
|
8
11
|
theme: 'default',
|
|
9
12
|
};
|
|
10
|
-
|
|
11
|
-
if (fs.existsSync(
|
|
13
|
+
function readConfigFile(file) {
|
|
14
|
+
if (fs.existsSync(file)) {
|
|
12
15
|
try {
|
|
13
|
-
const fileContent = fs.readFileSync(
|
|
16
|
+
const fileContent = fs.readFileSync(file, 'utf-8');
|
|
14
17
|
const parsedConfig = JSON.parse(fileContent);
|
|
15
18
|
return validateAndMergeConfig(parsedConfig);
|
|
16
19
|
}
|
|
@@ -21,6 +24,13 @@ export function loadConfig() {
|
|
|
21
24
|
}
|
|
22
25
|
return defaultConfig;
|
|
23
26
|
}
|
|
27
|
+
export function loadConfig() {
|
|
28
|
+
if (fs.existsSync(LOCAL_CONFIG_FILE))
|
|
29
|
+
return readConfigFile(LOCAL_CONFIG_FILE);
|
|
30
|
+
if (fs.existsSync(GLOBAL_CONFIG_FILE))
|
|
31
|
+
return readConfigFile(GLOBAL_CONFIG_FILE);
|
|
32
|
+
return defaultConfig;
|
|
33
|
+
}
|
|
24
34
|
function validateAndMergeConfig(config) {
|
|
25
35
|
const validatedConfig = { ...defaultConfig };
|
|
26
36
|
// Validate model
|
|
@@ -35,14 +45,21 @@ function validateAndMergeConfig(config) {
|
|
|
35
45
|
if (config.responseFormat && ['text', 'json', 'markdown'].includes(config.responseFormat)) {
|
|
36
46
|
validatedConfig.responseFormat = config.responseFormat;
|
|
37
47
|
}
|
|
48
|
+
if (typeof config.timeoutMs === 'number' && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0) {
|
|
49
|
+
validatedConfig.timeoutMs = Math.round(config.timeoutMs);
|
|
50
|
+
}
|
|
38
51
|
// Validate theme
|
|
39
52
|
if (config.theme && ['default', 'dark', 'light', 'mono'].includes(config.theme)) {
|
|
40
53
|
validatedConfig.theme = config.theme;
|
|
41
54
|
}
|
|
42
55
|
return validatedConfig;
|
|
43
56
|
}
|
|
44
|
-
export function saveConfig(config) {
|
|
45
|
-
const
|
|
46
|
-
|
|
57
|
+
export function saveConfig(config, options = {}) {
|
|
58
|
+
const scope = options.scope ?? 'local';
|
|
59
|
+
const file = scope === 'global' ? GLOBAL_CONFIG_FILE : LOCAL_CONFIG_FILE;
|
|
60
|
+
const current = scope === 'global' ? readConfigFile(GLOBAL_CONFIG_FILE) : loadConfig();
|
|
61
|
+
if (scope === 'global')
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
|
+
fs.writeFileSync(file, JSON.stringify({ ...current, ...config }, null, 2));
|
|
47
64
|
}
|
|
48
65
|
// Use .js extension for ESM compatibility if imported elsewhere
|
package/dist/core/ai.d.ts
CHANGED
|
@@ -21,6 +21,11 @@ export type AIError = {
|
|
|
21
21
|
} | {
|
|
22
22
|
kind: 'empty-response';
|
|
23
23
|
model: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'timeout';
|
|
26
|
+
timeoutMs: number;
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'cancelled';
|
|
24
29
|
} | {
|
|
25
30
|
kind: 'request';
|
|
26
31
|
cause: string;
|
|
@@ -31,6 +36,7 @@ export interface AIRequest {
|
|
|
31
36
|
context?: string;
|
|
32
37
|
model?: string;
|
|
33
38
|
onToken?: (token: string) => void;
|
|
39
|
+
signal?: AbortSignal;
|
|
34
40
|
}
|
|
35
41
|
/** The seam. Both adapters implement this; commands and tests depend on it, never on Ollama. */
|
|
36
42
|
export interface AIClient {
|
|
@@ -76,5 +82,10 @@ export declare function setAIClient(client: AIClient): void;
|
|
|
76
82
|
*/
|
|
77
83
|
export declare function ask(request: AIRequest): Promise<string>;
|
|
78
84
|
export declare function listModels(): Promise<string[]>;
|
|
85
|
+
export interface OllamaStatus {
|
|
86
|
+
endpoint: string;
|
|
87
|
+
version?: string;
|
|
88
|
+
}
|
|
89
|
+
export declare function getOllamaStatus(): Promise<OllamaStatus>;
|
|
79
90
|
/** Default model, from configuration — one source of truth. */
|
|
80
91
|
export declare function defaultModel(): string;
|
package/dist/core/ai.js
CHANGED
|
@@ -15,6 +15,7 @@ import fs from 'fs';
|
|
|
15
15
|
import path from 'path';
|
|
16
16
|
import crypto from 'crypto';
|
|
17
17
|
import { loadConfig } from '../config/config.js';
|
|
18
|
+
import { metricsCollector } from './metrics.js';
|
|
18
19
|
const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
19
20
|
const MAX_CACHE_FILES = 100;
|
|
20
21
|
function cacheDir() {
|
|
@@ -88,9 +89,10 @@ function toAIError(err, model) {
|
|
|
88
89
|
if (message.includes('ECONNREFUSED') || message.includes('fetch failed') || message.includes('ENOTFOUND')) {
|
|
89
90
|
return { kind: 'connection', cause: message };
|
|
90
91
|
}
|
|
91
|
-
if (message.includes('
|
|
92
|
+
if (message.includes('returned empty response'))
|
|
93
|
+
return { kind: 'empty-response', model };
|
|
94
|
+
if (message.includes('not found'))
|
|
92
95
|
return { kind: 'model-not-found', model };
|
|
93
|
-
}
|
|
94
96
|
return { kind: 'request', cause: message };
|
|
95
97
|
}
|
|
96
98
|
/**
|
|
@@ -109,22 +111,31 @@ export class OllamaAIClient {
|
|
|
109
111
|
: request.prompt;
|
|
110
112
|
const cached = readCache(request, model);
|
|
111
113
|
if (cached !== undefined) {
|
|
114
|
+
metricsCollector.recordCacheHit('ai-response');
|
|
112
115
|
if (request.onToken)
|
|
113
116
|
request.onToken(cached);
|
|
114
117
|
return cached;
|
|
115
118
|
}
|
|
119
|
+
metricsCollector.recordCacheMiss('ai-response');
|
|
116
120
|
try {
|
|
117
121
|
const streaming = Boolean(request.onToken);
|
|
118
122
|
let result = '';
|
|
119
123
|
if (streaming) {
|
|
120
124
|
const stream = await this.client.generate({ model, prompt: fullPrompt, stream: true });
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
const abort = () => stream.abort();
|
|
126
|
+
request.signal?.addEventListener('abort', abort, { once: true });
|
|
127
|
+
try {
|
|
128
|
+
for await (const chunk of stream) {
|
|
129
|
+
const token = typeof chunk === 'object' && chunk !== null && 'response' in chunk ? chunk.response : '';
|
|
130
|
+
if (!token)
|
|
131
|
+
continue;
|
|
132
|
+
result += token;
|
|
133
|
+
if (request.onToken)
|
|
134
|
+
request.onToken(token);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
request.signal?.removeEventListener('abort', abort);
|
|
128
139
|
}
|
|
129
140
|
}
|
|
130
141
|
else {
|
|
@@ -214,6 +225,19 @@ export async function ask(request) {
|
|
|
214
225
|
export async function listModels() {
|
|
215
226
|
return getAIClient().listModels();
|
|
216
227
|
}
|
|
228
|
+
export async function getOllamaStatus() {
|
|
229
|
+
const endpoint = (process.env.OLLAMA_HOST ?? 'http://127.0.0.1:11434').replace(/\/$/, '');
|
|
230
|
+
try {
|
|
231
|
+
const response = await fetch(`${endpoint}/api/version`, { signal: AbortSignal.timeout(1000) });
|
|
232
|
+
if (!response.ok)
|
|
233
|
+
return { endpoint };
|
|
234
|
+
const body = await response.json();
|
|
235
|
+
return { endpoint, version: typeof body.version === 'string' ? body.version : undefined };
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return { endpoint };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
217
241
|
/** Default model, from configuration — one source of truth. */
|
|
218
242
|
export function defaultModel() {
|
|
219
243
|
return loadConfig().model;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface CommandCatalogEntry {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
menuLabel: string;
|
|
5
|
+
options?: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare const commandCatalog: CommandCatalogEntry[];
|
|
8
|
+
export declare function commandDescription(name: string): string;
|
|
9
|
+
export declare function completionCommands(): string;
|
|
10
|
+
export declare function completionOptions(): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const commandCatalog = [
|
|
2
|
+
{ name: 'explain', description: 'Explain a concept or command', menuLabel: 'Explain' },
|
|
3
|
+
{ name: 'suggest', description: 'Get AI-powered suggestions', menuLabel: 'Suggest' },
|
|
4
|
+
{ name: 'fix', description: 'Get a fix for a coding issue or error', menuLabel: 'Fix' },
|
|
5
|
+
{ name: 'review', description: 'Review code in a file or directory', menuLabel: 'Review', options: ['--diff'] },
|
|
6
|
+
{ name: 'optimize', description: 'Optimize a file (e.g., package.json)', menuLabel: 'Optimize' },
|
|
7
|
+
{ name: 'security-check', description: 'Run a security check on code', menuLabel: 'Security Check', options: ['--strict'] },
|
|
8
|
+
{ name: 'generate', description: 'Generate code/tests for a file', menuLabel: 'Generate', options: ['--apply', '--output', '--overwrite'] },
|
|
9
|
+
{ name: 'init', description: 'Interactive setup/configuration wizard', menuLabel: 'Init (Setup)' },
|
|
10
|
+
{ name: 'status', description: 'Check Ollama connection and available models', menuLabel: 'Status' },
|
|
11
|
+
{ name: 'health', description: 'Run comprehensive health check', menuLabel: 'Health Check', options: ['--details'] },
|
|
12
|
+
{ name: 'metrics', description: 'Display CLI usage metrics', menuLabel: 'Metrics', options: ['--raw', '--reset'] },
|
|
13
|
+
{ name: 'project-type', description: 'Detect and print the current project type', menuLabel: 'Project Type' },
|
|
14
|
+
{ name: 'menu', description: 'Interactive command palette', menuLabel: 'Menu' },
|
|
15
|
+
{ name: 'completion', description: 'Generate shell completion script', menuLabel: 'Shell Completion' },
|
|
16
|
+
];
|
|
17
|
+
export function commandDescription(name) {
|
|
18
|
+
return commandCatalog.find((command) => command.name === name)?.description ?? name;
|
|
19
|
+
}
|
|
20
|
+
export function completionCommands() {
|
|
21
|
+
return commandCatalog.map((command) => command.name).join(' ');
|
|
22
|
+
}
|
|
23
|
+
export function completionOptions() {
|
|
24
|
+
const options = new Set(['--help', '--version', '--model', '--verbose', '--json', '--timeout']);
|
|
25
|
+
commandCatalog.forEach((command) => command.options?.forEach((option) => options.add(option)));
|
|
26
|
+
return [...options].join(' ');
|
|
27
|
+
}
|
|
@@ -15,23 +15,39 @@ import { metricsCollector } from '../core/metrics.js';
|
|
|
15
15
|
import { securityManager } from '../core/security.js';
|
|
16
16
|
/** Maps typed AI errors to user-facing hints — once, not per command. */
|
|
17
17
|
function describeAIError(error, model) {
|
|
18
|
-
|
|
18
|
+
if (!error || typeof error !== 'object' || !('kind' in error)) {
|
|
19
|
+
return error instanceof Error ? error.message : String(error);
|
|
20
|
+
}
|
|
21
|
+
const typedError = error;
|
|
22
|
+
switch (typedError.kind) {
|
|
19
23
|
case 'connection':
|
|
20
24
|
return `💡 Make sure Ollama is running: ollama serve`;
|
|
21
25
|
case 'model-not-found':
|
|
22
|
-
return `💡 Install the model: ollama pull ${
|
|
26
|
+
return `💡 Install the model: ollama pull ${typedError.model || model}`;
|
|
23
27
|
case 'empty-response':
|
|
24
|
-
return `💡 Model returned nothing. Install it: ollama pull ${
|
|
28
|
+
return `💡 Model returned nothing. Install it: ollama pull ${typedError.model || model}`;
|
|
29
|
+
case 'timeout':
|
|
30
|
+
return `💡 The request timed out after ${typedError.timeoutMs}ms. Try again, use a smaller prompt, or increase --timeout.`;
|
|
31
|
+
case 'cancelled':
|
|
32
|
+
return '💡 Request cancelled. Run the command again when ready.';
|
|
25
33
|
default:
|
|
26
|
-
return
|
|
34
|
+
return typedError.cause;
|
|
27
35
|
}
|
|
28
36
|
}
|
|
29
37
|
export async function runCommand(spec) {
|
|
30
38
|
const startTime = Date.now();
|
|
31
39
|
const { name, input } = spec;
|
|
32
40
|
const config = loadConfig();
|
|
41
|
+
const jsonOutput = config.responseFormat === 'json';
|
|
42
|
+
const writeJson = (result) => {
|
|
43
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
44
|
+
};
|
|
33
45
|
const fail = (error) => {
|
|
34
|
-
|
|
46
|
+
process.exitCode = 2;
|
|
47
|
+
if (jsonOutput)
|
|
48
|
+
writeJson({ ok: false, command: name, error, model: config.model, durationMs: Date.now() - startTime });
|
|
49
|
+
else
|
|
50
|
+
printError(error);
|
|
35
51
|
logCommand(name, startTime, false, { error });
|
|
36
52
|
metricsCollector.recordCommand(name, Date.now() - startTime, false);
|
|
37
53
|
};
|
|
@@ -46,30 +62,93 @@ export async function runCommand(spec) {
|
|
|
46
62
|
fail('Rate limit exceeded. Please try again later.');
|
|
47
63
|
return;
|
|
48
64
|
}
|
|
49
|
-
const spinner = ora('Thinking...').start();
|
|
65
|
+
const spinner = jsonOutput ? undefined : ora('Thinking...').start();
|
|
66
|
+
let requestTimeout;
|
|
67
|
+
let sigintHandler;
|
|
50
68
|
try {
|
|
51
|
-
spinner
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
spinner?.stop();
|
|
70
|
+
if (!jsonOutput) {
|
|
71
|
+
console.log(chalk.yellowBright('🤖 Dhruv CLI: AI-powered developer assistant'));
|
|
72
|
+
console.log(chalk.green.bold(spec.header));
|
|
73
|
+
console.log();
|
|
74
|
+
}
|
|
75
|
+
let streamed = false;
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const request = {
|
|
78
|
+
...spec.buildRequest(input, config.model),
|
|
79
|
+
signal: controller.signal,
|
|
80
|
+
onToken: (token) => {
|
|
81
|
+
streamed = true;
|
|
82
|
+
if (!jsonOutput)
|
|
83
|
+
process.stdout.write(token);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
const aiStartTime = Date.now();
|
|
87
|
+
const responsePromise = ask(request);
|
|
88
|
+
const cancellationPromise = new Promise((_, reject) => {
|
|
89
|
+
sigintHandler = () => {
|
|
90
|
+
controller.abort();
|
|
91
|
+
reject({ kind: 'cancelled' });
|
|
92
|
+
};
|
|
93
|
+
process.once('SIGINT', sigintHandler);
|
|
94
|
+
});
|
|
95
|
+
const response = config.timeoutMs > 0
|
|
96
|
+
? await Promise.race([
|
|
97
|
+
responsePromise,
|
|
98
|
+
cancellationPromise,
|
|
99
|
+
new Promise((_, reject) => {
|
|
100
|
+
requestTimeout = setTimeout(() => {
|
|
101
|
+
controller.abort();
|
|
102
|
+
reject({ kind: 'timeout', timeoutMs: config.timeoutMs });
|
|
103
|
+
}, config.timeoutMs);
|
|
104
|
+
}),
|
|
105
|
+
])
|
|
106
|
+
: await Promise.race([responsePromise, cancellationPromise]);
|
|
107
|
+
if (requestTimeout)
|
|
108
|
+
clearTimeout(requestTimeout);
|
|
109
|
+
if (sigintHandler)
|
|
110
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
111
|
+
if (!response.trim()) {
|
|
112
|
+
throw { kind: 'empty-response', model: config.model };
|
|
113
|
+
}
|
|
114
|
+
const durationMs = Date.now() - startTime;
|
|
115
|
+
if (jsonOutput) {
|
|
116
|
+
writeJson({ ok: true, command: name, model: config.model, response, durationMs });
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
if (!streamed)
|
|
120
|
+
process.stdout.write(response);
|
|
121
|
+
process.stdout.write('\n');
|
|
122
|
+
console.log('\n');
|
|
123
|
+
if (spec.footer)
|
|
124
|
+
console.log(chalk.dim(spec.footer));
|
|
59
125
|
}
|
|
60
126
|
if (spec.onComplete)
|
|
61
127
|
spec.onComplete(response, input);
|
|
62
|
-
|
|
128
|
+
metricsCollector.recordAIRequest(config.model, name, Date.now() - aiStartTime, true);
|
|
63
129
|
logCommand(name, startTime, true, { model: config.model });
|
|
64
|
-
logPerformance(name,
|
|
65
|
-
metricsCollector.recordCommand(name,
|
|
66
|
-
logger.info(`${name} command completed successfully`, { duration });
|
|
130
|
+
logPerformance(name, durationMs);
|
|
131
|
+
metricsCollector.recordCommand(name, durationMs, true);
|
|
132
|
+
logger.info(`${name} command completed successfully`, { duration: durationMs });
|
|
67
133
|
}
|
|
68
134
|
catch (err) {
|
|
69
|
-
spinner
|
|
135
|
+
spinner?.stop();
|
|
136
|
+
if (requestTimeout)
|
|
137
|
+
clearTimeout(requestTimeout);
|
|
138
|
+
if (sigintHandler)
|
|
139
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
140
|
+
const cancelled = typeof err === 'object' && err !== null && 'kind' in err && err.kind === 'cancelled';
|
|
141
|
+
process.exitCode = cancelled ? 130 : 1;
|
|
70
142
|
const duration = Date.now() - startTime;
|
|
71
|
-
|
|
72
|
-
|
|
143
|
+
const hint = describeAIError(err, config.model);
|
|
144
|
+
metricsCollector.recordAIRequest(config.model, name, duration, false);
|
|
145
|
+
if (jsonOutput) {
|
|
146
|
+
writeJson({ ok: false, command: name, model: config.model, error: 'Command failed.', hint, durationMs: duration });
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
printError(`Command failed.`);
|
|
150
|
+
console.log(chalk.yellow(hint));
|
|
151
|
+
}
|
|
73
152
|
logError(`${name} command failed`, err, { command: name });
|
|
74
153
|
logCommand(name, startTime, false, { error: err.message });
|
|
75
154
|
metricsCollector.recordCommand(name, duration, false);
|
package/dist/core/logger.js
CHANGED
|
@@ -31,6 +31,7 @@ class Logger {
|
|
|
31
31
|
// Console transport for development
|
|
32
32
|
new winston.transports.Console({
|
|
33
33
|
level: config.verbose ? 'debug' : 'info',
|
|
34
|
+
stderrLevels: ['error', 'warn', 'info', 'debug'],
|
|
34
35
|
format: winston.format.combine(winston.format.colorize(), winston.format.simple(), winston.format.printf(({ timestamp, level, message }) => {
|
|
35
36
|
return `${timestamp} ${level}: ${message}`;
|
|
36
37
|
}))
|
package/dist/core/metrics.d.ts
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
import promClient from 'prom-client';
|
|
2
|
+
export interface CommandSummary {
|
|
3
|
+
runs: number;
|
|
4
|
+
successes: number;
|
|
5
|
+
failures: number;
|
|
6
|
+
durationMs: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ModelSummary {
|
|
9
|
+
requests: number;
|
|
10
|
+
successes: number;
|
|
11
|
+
failures: number;
|
|
12
|
+
durationMs: number;
|
|
13
|
+
}
|
|
14
|
+
export interface CacheSummary {
|
|
15
|
+
hits: number;
|
|
16
|
+
misses: number;
|
|
17
|
+
}
|
|
18
|
+
export interface MetricsSummary {
|
|
19
|
+
sessions: number;
|
|
20
|
+
commands: Record<string, CommandSummary>;
|
|
21
|
+
errors: Record<string, number>;
|
|
22
|
+
models: Record<string, ModelSummary>;
|
|
23
|
+
cache: CacheSummary;
|
|
24
|
+
}
|
|
2
25
|
export declare const metrics: {
|
|
3
26
|
commandDuration: promClient.Histogram<"success" | "command">;
|
|
4
27
|
commandCount: promClient.Counter<"success" | "command">;
|
|
@@ -27,6 +50,11 @@ export declare class MetricsCollector {
|
|
|
27
50
|
updateMemoryUsage(): void;
|
|
28
51
|
recordSession(): void;
|
|
29
52
|
recordPluginLoaded(): void;
|
|
53
|
+
getSummary(): MetricsSummary;
|
|
54
|
+
resetPersistent(): void;
|
|
55
|
+
private persistentPath;
|
|
56
|
+
private readPersistent;
|
|
57
|
+
private updatePersistent;
|
|
30
58
|
getMetrics(): Promise<string>;
|
|
31
59
|
getMetricsJSON(): Promise<promClient.MetricObjectWithValues<promClient.MetricValue<string>>[]>;
|
|
32
60
|
getRegistry(): promClient.Registry;
|
package/dist/core/metrics.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import promClient from 'prom-client';
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
function emptySummary() {
|
|
6
|
+
return { sessions: 0, commands: {}, errors: {}, models: {}, cache: { hits: 0, misses: 0 } };
|
|
7
|
+
}
|
|
3
8
|
// Create a Registry which registers the metrics
|
|
4
9
|
const register = new promClient.Registry();
|
|
5
10
|
// Add a default label which is added to all metrics
|
|
@@ -100,6 +105,16 @@ export class MetricsCollector {
|
|
|
100
105
|
try {
|
|
101
106
|
metrics.commandDuration.observe({ command, success: success.toString() }, duration / 1000);
|
|
102
107
|
metrics.commandCount.inc({ command, success: success.toString() });
|
|
108
|
+
this.updatePersistent((summary) => {
|
|
109
|
+
const current = summary.commands[command] ?? { runs: 0, successes: 0, failures: 0, durationMs: 0 };
|
|
110
|
+
current.runs += 1;
|
|
111
|
+
if (success)
|
|
112
|
+
current.successes += 1;
|
|
113
|
+
else
|
|
114
|
+
current.failures += 1;
|
|
115
|
+
current.durationMs += duration;
|
|
116
|
+
summary.commands[command] = current;
|
|
117
|
+
});
|
|
103
118
|
}
|
|
104
119
|
catch (error) {
|
|
105
120
|
logger.error('Failed to record command metrics', error);
|
|
@@ -114,6 +129,16 @@ export class MetricsCollector {
|
|
|
114
129
|
if (tokensUsed) {
|
|
115
130
|
metrics.aiTokensUsed.inc({ model, command_type: commandType }, tokensUsed);
|
|
116
131
|
}
|
|
132
|
+
this.updatePersistent((summary) => {
|
|
133
|
+
const current = summary.models[model] ?? { requests: 0, successes: 0, failures: 0, durationMs: 0 };
|
|
134
|
+
current.requests += 1;
|
|
135
|
+
if (success)
|
|
136
|
+
current.successes += 1;
|
|
137
|
+
else
|
|
138
|
+
current.failures += 1;
|
|
139
|
+
current.durationMs += duration;
|
|
140
|
+
summary.models[model] = current;
|
|
141
|
+
});
|
|
117
142
|
}
|
|
118
143
|
catch (error) {
|
|
119
144
|
logger.error('Failed to record AI request metrics', error);
|
|
@@ -124,6 +149,9 @@ export class MetricsCollector {
|
|
|
124
149
|
return;
|
|
125
150
|
try {
|
|
126
151
|
metrics.cacheHitCount.inc({ cache_type: cacheType });
|
|
152
|
+
this.updatePersistent((summary) => {
|
|
153
|
+
summary.cache.hits += 1;
|
|
154
|
+
});
|
|
127
155
|
}
|
|
128
156
|
catch (error) {
|
|
129
157
|
logger.error('Failed to record cache hit metrics', error);
|
|
@@ -134,6 +162,9 @@ export class MetricsCollector {
|
|
|
134
162
|
return;
|
|
135
163
|
try {
|
|
136
164
|
metrics.cacheMissCount.inc({ cache_type: cacheType });
|
|
165
|
+
this.updatePersistent((summary) => {
|
|
166
|
+
summary.cache.misses += 1;
|
|
167
|
+
});
|
|
137
168
|
}
|
|
138
169
|
catch (error) {
|
|
139
170
|
logger.error('Failed to record cache miss metrics', error);
|
|
@@ -154,6 +185,10 @@ export class MetricsCollector {
|
|
|
154
185
|
return;
|
|
155
186
|
try {
|
|
156
187
|
metrics.errorCount.inc({ error_type: errorType, command: command || 'unknown' });
|
|
188
|
+
this.updatePersistent((summary) => {
|
|
189
|
+
const key = command ? `${errorType}:${command}` : errorType;
|
|
190
|
+
summary.errors[key] = (summary.errors[key] ?? 0) + 1;
|
|
191
|
+
});
|
|
157
192
|
}
|
|
158
193
|
catch (error) {
|
|
159
194
|
logger.error('Failed to record error metrics', error);
|
|
@@ -178,6 +213,9 @@ export class MetricsCollector {
|
|
|
178
213
|
return;
|
|
179
214
|
try {
|
|
180
215
|
metrics.sessionCount.inc();
|
|
216
|
+
this.updatePersistent((summary) => {
|
|
217
|
+
summary.sessions += 1;
|
|
218
|
+
});
|
|
181
219
|
}
|
|
182
220
|
catch (error) {
|
|
183
221
|
logger.error('Failed to record session metrics', error);
|
|
@@ -193,6 +231,46 @@ export class MetricsCollector {
|
|
|
193
231
|
logger.error('Failed to record plugin loaded metrics', error);
|
|
194
232
|
}
|
|
195
233
|
}
|
|
234
|
+
getSummary() {
|
|
235
|
+
return this.readPersistent();
|
|
236
|
+
}
|
|
237
|
+
resetPersistent() {
|
|
238
|
+
try {
|
|
239
|
+
fs.unlinkSync(this.persistentPath());
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
if (error.code !== 'ENOENT')
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
persistentPath() {
|
|
247
|
+
return path.join(process.cwd(), '.dhruv-metrics.json');
|
|
248
|
+
}
|
|
249
|
+
readPersistent() {
|
|
250
|
+
try {
|
|
251
|
+
const parsed = JSON.parse(fs.readFileSync(this.persistentPath(), 'utf8'));
|
|
252
|
+
return {
|
|
253
|
+
sessions: typeof parsed.sessions === 'number' ? parsed.sessions : 0,
|
|
254
|
+
commands: parsed.commands ?? {},
|
|
255
|
+
errors: parsed.errors ?? {},
|
|
256
|
+
models: parsed.models ?? {},
|
|
257
|
+
cache: parsed.cache ?? { hits: 0, misses: 0 },
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return emptySummary();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
updatePersistent(update) {
|
|
265
|
+
try {
|
|
266
|
+
const summary = this.readPersistent();
|
|
267
|
+
update(summary);
|
|
268
|
+
fs.writeFileSync(this.persistentPath(), JSON.stringify(summary, null, 2));
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
logger.debug('Failed to persist metrics', { error: error.message });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
196
274
|
async getMetrics() {
|
|
197
275
|
return register.metrics();
|
|
198
276
|
}
|