@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.
Files changed (76) hide show
  1. package/.github/workflows/ci.yml +18 -238
  2. package/.github/workflows/contribution.yml +6 -141
  3. package/.github/workflows/dependabot-auto-merge.yml +1 -0
  4. package/.github/workflows/labeler.yml +1 -0
  5. package/.github/workflows/security.yml +3 -0
  6. package/CHANGELOG.md +9 -4
  7. package/README.md +145 -40
  8. package/__tests__/cli-contract.test.ts +104 -0
  9. package/__tests__/core.test.ts +121 -0
  10. package/__tests__/diagnostics.test.ts +155 -0
  11. package/__tests__/file-workflows.test.ts +195 -0
  12. package/__tests__/interactive.test.ts +119 -0
  13. package/__tests__/setup.ts +1 -0
  14. package/__tests__/workflows.test.ts +27 -4
  15. package/dist/commands/generate.d.ts +6 -1
  16. package/dist/commands/generate.js +18 -4
  17. package/dist/commands/health.d.ts +4 -1
  18. package/dist/commands/health.js +59 -16
  19. package/dist/commands/init.js +11 -2
  20. package/dist/commands/menu.js +125 -100
  21. package/dist/commands/metrics.d.ts +5 -1
  22. package/dist/commands/metrics.js +37 -8
  23. package/dist/commands/optimize.js +1 -1
  24. package/dist/commands/review.d.ts +4 -1
  25. package/dist/commands/review.js +48 -8
  26. package/dist/commands/security-check.d.ts +4 -1
  27. package/dist/commands/security-check.js +67 -6
  28. package/dist/commands/status.js +40 -2
  29. package/dist/config/config.d.ts +5 -1
  30. package/dist/config/config.js +24 -7
  31. package/dist/core/ai.d.ts +11 -0
  32. package/dist/core/ai.js +33 -9
  33. package/dist/core/command-catalog.d.ts +10 -0
  34. package/dist/core/command-catalog.js +27 -0
  35. package/dist/core/command-runner.js +100 -21
  36. package/dist/core/logger.js +1 -0
  37. package/dist/core/metrics.d.ts +28 -0
  38. package/dist/core/metrics.js +78 -0
  39. package/dist/index.js +46 -24
  40. package/dist/utils/projectType.d.ts +1 -1
  41. package/dist/utils/projectType.js +26 -7
  42. package/docs/api/assets/highlight.css +4 -4
  43. package/docs/api/index.html +161 -39
  44. package/docs/api/media/CONTRIBUTING.md +60 -0
  45. package/docs/api/media/SECURITY.md +8 -0
  46. package/docs/api/media/dhruv-cli-preview.svg +42 -0
  47. package/docs/api/media/publishing-fix.md +34 -0
  48. package/docs/dhruv-cli-preview.svg +42 -0
  49. package/docs/index.html +631 -533
  50. package/docs/publishing-fix.md +34 -0
  51. package/package.json +1 -1
  52. package/src/commands/generate.ts +23 -4
  53. package/src/commands/health.ts +62 -17
  54. package/src/commands/init.ts +11 -2
  55. package/src/commands/menu.ts +54 -30
  56. package/src/commands/metrics.ts +42 -7
  57. package/src/commands/optimize.ts +1 -1
  58. package/src/commands/review.ts +53 -8
  59. package/src/commands/security-check.ts +80 -6
  60. package/src/commands/status.ts +39 -3
  61. package/src/config/config.ts +26 -7
  62. package/src/core/ai.ts +36 -8
  63. package/src/core/command-catalog.ts +37 -0
  64. package/src/core/command-runner.ts +102 -22
  65. package/src/core/logger.ts +1 -0
  66. package/src/core/metrics.ts +103 -0
  67. package/src/index.ts +45 -24
  68. package/src/utils/projectType.ts +22 -7
  69. package/tsconfig.json +1 -1
  70. package/.github/workflows/auto-assign.yml +0 -14
  71. package/.github/workflows/build-publish.yml +0 -154
  72. package/.github/workflows/deploy.yml +0 -336
  73. package/.github/workflows/monitoring.yml +0 -270
  74. package/PUBLISHING_FIX.md +0 -92
  75. package/logs/.8a99b6cf655346317fdbf29f4fffcf91131432f3-audit.json +0 -15
  76. package/logs/.eee104bf8fff5ecd38a6a2842df260de6470a7c3-audit.json +0 -15
@@ -1,16 +1,19 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- const CONFIG_FILE = path.join(process.cwd(), '.dhruv-config.json');
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
- export function loadConfig() {
11
- if (fs.existsSync(CONFIG_FILE)) {
13
+ function readConfigFile(file) {
14
+ if (fs.existsSync(file)) {
12
15
  try {
13
- const fileContent = fs.readFileSync(CONFIG_FILE, 'utf-8');
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 current = loadConfig();
46
- fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...config }, null, 2));
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('not found')) {
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
- for await (const chunk of stream) {
122
- const token = typeof chunk === 'object' && chunk !== null && 'response' in chunk ? chunk.response : '';
123
- if (!token)
124
- continue;
125
- result += token;
126
- if (request.onToken)
127
- request.onToken(token);
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
- switch (error.kind) {
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 ${error.model || model}`;
26
+ return `💡 Install the model: ollama pull ${typedError.model || model}`;
23
27
  case 'empty-response':
24
- return `💡 Model returned nothing. Install it: ollama pull ${error.model || model}`;
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 error.cause;
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
- printError(error);
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.stop();
52
- console.log(chalk.yellowBright('🤖 Dhruv CLI: AI-powered developer assistant'));
53
- console.log(chalk.green.bold(spec.header));
54
- console.log();
55
- const response = await ask(spec.buildRequest(input, config.model));
56
- console.log('\n');
57
- if (spec.footer) {
58
- console.log(chalk.dim(spec.footer));
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
- const duration = Date.now() - startTime;
128
+ metricsCollector.recordAIRequest(config.model, name, Date.now() - aiStartTime, true);
63
129
  logCommand(name, startTime, true, { model: config.model });
64
- logPerformance(name, duration);
65
- metricsCollector.recordCommand(name, duration, true);
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.stop();
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
- printError(`Command failed.`);
72
- console.log(chalk.yellow(describeAIError(err, config.model)));
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);
@@ -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
  }))
@@ -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;
@@ -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
  }