@miphamai/cli 0.11.0 → 0.13.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.
@@ -0,0 +1,224 @@
1
+ import { createHmac, randomUUID } from 'node:crypto'
2
+ import type {
3
+ InferenceHookConfig,
4
+ InferenceCheckRequest,
5
+ InferenceCheckResponse,
6
+ Message,
7
+ } from '../shared/index.ts'
8
+
9
+ // ── Result type ──
10
+
11
+ export interface InferenceVerdict {
12
+ allowed: boolean
13
+ reason?: string
14
+ }
15
+
16
+ // ── Public API ──
17
+
18
+ /**
19
+ * Build the inference-check request payload from current conversation state.
20
+ *
21
+ * Follows Claude Inference Hooks protocol:
22
+ * - Sends all messages EXCEPT system role (never expose system prompts)
23
+ * - Includes recent tool calls with result previews (truncated to 2000 chars)
24
+ * - Omits tool definitions and raw file/image content
25
+ */
26
+ export function buildRequest(
27
+ messages: Message[],
28
+ sessionId: string,
29
+ provider: string,
30
+ model: string,
31
+ organizationId?: string,
32
+ ): InferenceCheckRequest {
33
+ // Filter: exclude system messages, flatten content blocks to text
34
+ const serialized: Array<{ role: string; content: string }> = []
35
+ for (const msg of messages) {
36
+ if (msg.role === 'system') continue
37
+ serialized.push({
38
+ role: msg.role,
39
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
40
+ })
41
+ }
42
+
43
+ // Extract tool calls from the message history
44
+ const toolCalls = extractToolCalls(messages)
45
+
46
+ return {
47
+ type: 'inference_check',
48
+ id: `evt_${randomUUID()}`,
49
+ created_at: new Date().toISOString(),
50
+ data: {
51
+ type: 'pre_inference',
52
+ session_id: sessionId,
53
+ organization_id: organizationId || undefined,
54
+ provider,
55
+ model,
56
+ messages: serialized,
57
+ tool_calls: toolCalls,
58
+ },
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Send the inference-check request to the DLP server and return the verdict.
64
+ *
65
+ * On network error or timeout, applies the configured `on_failure` strategy:
66
+ * - 'fail-closed': block the request (security-first)
67
+ * - 'fail-open': allow the request (availability-first)
68
+ */
69
+ export async function sendInferenceCheck(
70
+ config: InferenceHookConfig,
71
+ request: InferenceCheckRequest,
72
+ ): Promise<InferenceVerdict> {
73
+ const body = JSON.stringify(request)
74
+
75
+ // Build signature header
76
+ const signature = signPayload(config.signing_secret, body)
77
+
78
+ const headers: Record<string, string> = {
79
+ 'Content-Type': 'application/json',
80
+ 'User-Agent': `MiphamCode/${getVersion()}`,
81
+ ...(config.headers || {}),
82
+ }
83
+
84
+ if (signature) {
85
+ headers['X-Mipham-Signature'] = signature
86
+ }
87
+
88
+ try {
89
+ const response = await fetch(config.endpoint, {
90
+ method: 'POST',
91
+ headers,
92
+ body,
93
+ signal: AbortSignal.timeout(config.timeout),
94
+ })
95
+
96
+ const responseBody = await response.text()
97
+
98
+ if (response.status === 200) {
99
+ // Parse verdict — treat any non-deny as allow
100
+ try {
101
+ const parsed = JSON.parse(responseBody) as InferenceCheckResponse
102
+ if (parsed.verdict === 'deny') {
103
+ return { allowed: false, reason: parsed.reason || 'Blocked by DLP policy' }
104
+ }
105
+ return { allowed: true }
106
+ } catch {
107
+ // Unparseable response — treat as allow (server acknowledged receipt)
108
+ return { allowed: true }
109
+ }
110
+ }
111
+
112
+ // 403 = explicit deny
113
+ if (response.status === 403) {
114
+ try {
115
+ const parsed = JSON.parse(responseBody) as InferenceCheckResponse
116
+ return { allowed: false, reason: parsed.reason || 'Blocked by DLP policy' }
117
+ } catch {
118
+ return { allowed: false, reason: `DLP server denied (403): ${responseBody.slice(0, 200)}` }
119
+ }
120
+ }
121
+
122
+ // Other non-2xx: apply failure posture
123
+ if (config.on_failure === 'fail-closed') {
124
+ return {
125
+ allowed: false,
126
+ reason: `DLP server returned ${response.status}: ${responseBody.slice(0, 200)}`,
127
+ }
128
+ }
129
+ return { allowed: true }
130
+ } catch (err) {
131
+ // Network error or timeout
132
+ if (config.on_failure === 'fail-closed') {
133
+ const msg = err instanceof Error ? err.message : String(err)
134
+ return {
135
+ allowed: false,
136
+ reason: `DLP server unreachable: ${msg}`,
137
+ }
138
+ }
139
+ // fail-open: allow through
140
+ return { allowed: true }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Check if inference hook is configured and should be used.
146
+ */
147
+ export function isInferenceHookEnabled(config?: InferenceHookConfig): boolean {
148
+ return !!(config?.endpoint && config.endpoint.length > 0)
149
+ }
150
+
151
+ // ── Internal helpers ──
152
+
153
+ /**
154
+ * Extract tool calls and their results from the entire message history.
155
+ * Scans for tool_use/tool_result pairs across all messages.
156
+ * Each result_preview is truncated to 2000 characters.
157
+ */
158
+ function extractToolCalls(
159
+ messages: Message[],
160
+ ): Array<{ name: string; input: Record<string, unknown>; result_preview: string }> {
161
+ // Collect tool results first (keyed by tool_use_id)
162
+ const toolResults = new Map<string, string>()
163
+ for (const msg of messages) {
164
+ if (Array.isArray(msg.content)) {
165
+ for (const block of msg.content) {
166
+ if (block.type === 'tool_result') {
167
+ toolResults.set(block.tool_use_id, block.content || '')
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ // Extract tool_use blocks with their result previews
174
+ const calls: Array<{
175
+ name: string
176
+ input: Record<string, unknown>
177
+ result_preview: string
178
+ }> = []
179
+
180
+ for (const msg of messages) {
181
+ if (Array.isArray(msg.content)) {
182
+ for (const block of msg.content) {
183
+ if (block.type === 'tool_use') {
184
+ const resultPreview = toolResults.get(block.id) || ''
185
+ calls.push({
186
+ name: block.name,
187
+ input: block.input,
188
+ result_preview: resultPreview.slice(0, 2000),
189
+ })
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ return calls
196
+ }
197
+
198
+ /**
199
+ * Sign the request body using HMAC-SHA256.
200
+ * Follows Standard Webhooks specification:
201
+ * X-Mipham-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>
202
+ * Returns empty string if no signing secret is configured.
203
+ */
204
+ function signPayload(secret: string, body: string): string {
205
+ if (!secret) return ''
206
+ const timestamp = Math.floor(Date.now() / 1000).toString()
207
+ const signedPayload = `${timestamp}.${body}`
208
+ const signature = createHmac('sha256', secret).update(signedPayload).digest('hex')
209
+ return `t=${timestamp},v1=${signature}`
210
+ }
211
+
212
+ /**
213
+ * Get the current package version for the User-Agent header.
214
+ */
215
+ function getVersion(): string {
216
+ try {
217
+ // Dynamic import to avoid circular deps — the shared package-info
218
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
219
+ const pkg = require('../../package.json') as { version?: string }
220
+ return pkg.version || '0.0.0'
221
+ } catch {
222
+ return '0.0.0'
223
+ }
224
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Lightweight Prometheus-compatible metrics registry for mipham-code.
3
+ *
4
+ * Provides Counter, Gauge, and Histogram metric types compatible with
5
+ * the Python shared/metrics.py module in MegaSystem. Metrics are
6
+ * collected in-memory and can be exported in Prometheus text format
7
+ * or JSON.
8
+ *
9
+ * Usage:
10
+ * import { getMetrics } from '../core/metrics.js'
11
+ *
12
+ * const m = getMetrics()
13
+ * m.cliInvocations.inc()
14
+ * m.toolCalls.inc({ tool_name: 'bash' })
15
+ * m.modelRequestDuration.observe(150.0, { provider: 'anthropic' })
16
+ *
17
+ * Endpoints (via ArtifactServer):
18
+ * GET /metrics — Prometheus text format
19
+ * GET /metrics/json — JSON format
20
+ */
21
+
22
+ // ── Metric types ──────────────────────────────────────────────────────────
23
+
24
+ export interface MetricLabels {
25
+ [key: string]: string
26
+ }
27
+
28
+ /** Format labels into Prometheus {...} string. */
29
+ function formatLabels(labels?: MetricLabels): string {
30
+ if (!labels || Object.keys(labels).length === 0) return ''
31
+ const parts = Object.entries(labels)
32
+ .sort(([a], [b]) => a.localeCompare(b))
33
+ .map(([k, v]) => `${k}="${v.replace(/"/g, '\\"')}"`)
34
+ return `{${parts.join(',')}}`
35
+ }
36
+
37
+ // ── Counter ──────────────────────────────────────────────────────────────
38
+
39
+ export class Counter {
40
+ readonly name: string
41
+ readonly help: string
42
+ readonly labels: MetricLabels
43
+ private _value = 0
44
+
45
+ constructor(name: string, help: string, labels?: MetricLabels) {
46
+ this.name = name
47
+ this.help = help
48
+ this.labels = labels ?? {}
49
+ }
50
+
51
+ inc(amount = 1): void {
52
+ this._value += amount
53
+ }
54
+
55
+ get value(): number {
56
+ return this._value
57
+ }
58
+
59
+ /** Full metric name with labels for dedup key. */
60
+ get key(): string {
61
+ return this.name + formatLabels(this.labels)
62
+ }
63
+
64
+ toPrometheus(): string {
65
+ const labelStr = formatLabels(this.labels)
66
+ return `${this.name}${labelStr} ${this._value}`
67
+ }
68
+
69
+ toJSON(): object {
70
+ return {
71
+ name: this.name,
72
+ type: 'counter',
73
+ help: this.help,
74
+ labels: this.labels,
75
+ value: this._value,
76
+ }
77
+ }
78
+ }
79
+
80
+ // ── Gauge ────────────────────────────────────────────────────────────────
81
+
82
+ export class Gauge {
83
+ readonly name: string
84
+ readonly help: string
85
+ readonly labels: MetricLabels
86
+ private _value = 0
87
+
88
+ constructor(name: string, help: string, labels?: MetricLabels) {
89
+ this.name = name
90
+ this.help = help
91
+ this.labels = labels ?? {}
92
+ }
93
+
94
+ inc(amount = 1): void {
95
+ this._value += amount
96
+ }
97
+
98
+ dec(amount = 1): void {
99
+ this._value -= amount
100
+ }
101
+
102
+ set(value: number): void {
103
+ this._value = value
104
+ }
105
+
106
+ get value(): number {
107
+ return this._value
108
+ }
109
+
110
+ get key(): string {
111
+ return this.name + formatLabels(this.labels)
112
+ }
113
+
114
+ toPrometheus(): string {
115
+ const labelStr = formatLabels(this.labels)
116
+ return `${this.name}${labelStr} ${this._value}`
117
+ }
118
+
119
+ toJSON(): object {
120
+ return {
121
+ name: this.name,
122
+ type: 'gauge',
123
+ help: this.help,
124
+ labels: this.labels,
125
+ value: this._value,
126
+ }
127
+ }
128
+ }
129
+
130
+ // ── Histogram ────────────────────────────────────────────────────────────
131
+
132
+ const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
133
+
134
+ export class Histogram {
135
+ readonly name: string
136
+ readonly help: string
137
+ readonly labels: MetricLabels
138
+ readonly buckets: number[]
139
+ private _count = 0
140
+ private _sum = 0
141
+ private _bucketCounts: number[]
142
+
143
+ constructor(name: string, help: string, buckets?: number[], labels?: MetricLabels) {
144
+ this.name = name
145
+ this.help = help
146
+ this.labels = labels ?? {}
147
+ this.buckets = buckets ?? DEFAULT_BUCKETS
148
+ this._bucketCounts = new Array(this.buckets.length).fill(0)
149
+ }
150
+
151
+ observe(value: number): void {
152
+ this._count++
153
+ this._sum += value
154
+ for (let i = 0; i < this.buckets.length; i++) {
155
+ if (value <= (this.buckets[i] ?? Infinity)) {
156
+ this._bucketCounts[i] = (this._bucketCounts[i] ?? 0) + 1
157
+ }
158
+ }
159
+ }
160
+
161
+ get count(): number {
162
+ return this._count
163
+ }
164
+
165
+ get sum(): number {
166
+ return this._sum
167
+ }
168
+
169
+ get key(): string {
170
+ return this.name + formatLabels(this.labels)
171
+ }
172
+
173
+ toPrometheus(): string {
174
+ const labelStr = formatLabels(this.labels)
175
+ const lines: string[] = []
176
+
177
+ // _bucket values
178
+ for (let i = 0; i < this.buckets.length; i++) {
179
+ const bucketLabel = formatLabels({
180
+ ...this.labels,
181
+ le: String(this.buckets[i]),
182
+ })
183
+ lines.push(`${this.name}_bucket${bucketLabel} ${this._bucketCounts[i]}`)
184
+ }
185
+ // +Inf bucket
186
+ const infLabel = formatLabels({ ...this.labels, le: '+Inf' })
187
+ lines.push(`${this.name}_bucket${infLabel} ${this._count}`)
188
+
189
+ // _sum and _count
190
+ lines.push(`${this.name}_sum${labelStr} ${this._sum}`)
191
+ lines.push(`${this.name}_count${labelStr} ${this._count}`)
192
+
193
+ return lines.join('\n')
194
+ }
195
+
196
+ toJSON(): object {
197
+ const bucketResults: { le: string; count: number }[] = []
198
+ for (let i = 0; i < this.buckets.length; i++) {
199
+ bucketResults.push({ le: String(this.buckets[i]), count: this._bucketCounts[i] ?? 0 })
200
+ }
201
+ bucketResults.push({ le: '+Inf', count: this._count })
202
+ return {
203
+ name: this.name,
204
+ type: 'histogram',
205
+ help: this.help,
206
+ labels: this.labels,
207
+ count: this._count,
208
+ sum: this._sum,
209
+ buckets: bucketResults,
210
+ }
211
+ }
212
+ }
213
+
214
+ // ── MetricsRegistry ──────────────────────────────────────────────────────
215
+
216
+ export class MetricsRegistry {
217
+ private _counters = new Map<string, Counter>()
218
+ private _gauges = new Map<string, Gauge>()
219
+ private _histograms = new Map<string, Histogram>()
220
+
221
+ // ── Predefined metrics ─────────────────────────────────────────
222
+
223
+ /** CLI invocation counter. Incremented on each CLI startup. */
224
+ readonly cliInvocations: Counter
225
+
226
+ /** Tool call counter, labelled by tool_name. Callers use .inc({tool_name}). */
227
+ readonly toolCalls: Counter
228
+
229
+ /** Model API request counter, labelled by provider and model. */
230
+ readonly modelRequests: Counter
231
+
232
+ /** Model API request error counter, labelled by provider and error type. */
233
+ readonly modelRequestErrors: Counter
234
+
235
+ /** Model API request latency histogram (milliseconds). */
236
+ readonly modelRequestDurationMs: Histogram
237
+
238
+ /** Active CLI sessions gauge. */
239
+ readonly activeSessions: Gauge
240
+
241
+ constructor() {
242
+ // Pre-register standard metrics
243
+ this.cliInvocations = this.counter(
244
+ 'mipham_code_cli_invocations_total',
245
+ 'Number of CLI invocations',
246
+ )
247
+
248
+ this.toolCalls = this.counter('mipham_code_tool_calls_total', 'Number of tool invocations')
249
+
250
+ this.modelRequests = this.counter(
251
+ 'mipham_code_model_requests_total',
252
+ 'Number of model API requests',
253
+ )
254
+
255
+ this.modelRequestErrors = this.counter(
256
+ 'mipham_code_model_request_errors_total',
257
+ 'Number of model API request errors',
258
+ )
259
+
260
+ this.modelRequestDurationMs = this.histogram(
261
+ 'mipham_code_model_request_duration_ms',
262
+ 'Model API request duration in milliseconds',
263
+ [50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000],
264
+ )
265
+
266
+ this.activeSessions = this.gauge('mipham_code_active_sessions', 'Number of active CLI sessions')
267
+ }
268
+
269
+ // ── Factory methods ─────────────────────────────────────────────
270
+
271
+ counter(name: string, help: string, labels?: MetricLabels): Counter {
272
+ const c = new Counter(name, help, labels)
273
+ if (this._counters.has(c.key)) return this._counters.get(c.key)!
274
+ this._counters.set(c.key, c)
275
+ return c
276
+ }
277
+
278
+ gauge(name: string, help: string, labels?: MetricLabels): Gauge {
279
+ const g = new Gauge(name, help, labels)
280
+ if (this._gauges.has(g.key)) return this._gauges.get(g.key)!
281
+ this._gauges.set(g.key, g)
282
+ return g
283
+ }
284
+
285
+ histogram(name: string, help: string, buckets?: number[], labels?: MetricLabels): Histogram {
286
+ const h = new Histogram(name, help, buckets, labels)
287
+ if (this._histograms.has(h.key)) return this._histograms.get(h.key)!
288
+ this._histograms.set(h.key, h)
289
+ return h
290
+ }
291
+
292
+ // ── Export ──────────────────────────────────────────────────────
293
+
294
+ /** Export all metrics in Prometheus text format. */
295
+ toPrometheusText(): string {
296
+ const lines: string[] = []
297
+ const seen = new Set<string>()
298
+
299
+ for (const c of this._counters.values()) {
300
+ if (!seen.has(c.name)) {
301
+ lines.push(`# HELP ${c.name} ${c.help}`)
302
+ lines.push(`# TYPE ${c.name} counter`)
303
+ seen.add(c.name)
304
+ }
305
+ lines.push(c.toPrometheus())
306
+ }
307
+
308
+ for (const g of this._gauges.values()) {
309
+ if (!seen.has(g.name)) {
310
+ lines.push(`# HELP ${g.name} ${g.help}`)
311
+ lines.push(`# TYPE ${g.name} gauge`)
312
+ seen.add(g.name)
313
+ }
314
+ lines.push(g.toPrometheus())
315
+ }
316
+
317
+ for (const h of this._histograms.values()) {
318
+ if (!seen.has(h.name)) {
319
+ lines.push(`# HELP ${h.name} ${h.help}`)
320
+ lines.push(`# TYPE ${h.name} histogram`)
321
+ seen.add(h.name)
322
+ }
323
+ lines.push(h.toPrometheus())
324
+ }
325
+
326
+ return lines.join('\n') + '\n'
327
+ }
328
+
329
+ /** Export all metrics as JSON. */
330
+ toJSON(): object {
331
+ return {
332
+ counters: Array.from(this._counters.values()).map((c) => c.toJSON()),
333
+ gauges: Array.from(this._gauges.values()).map((g) => g.toJSON()),
334
+ histograms: Array.from(this._histograms.values()).map((h) => h.toJSON()),
335
+ }
336
+ }
337
+
338
+ /** Reset all metrics (useful for testing). */
339
+ reset(): void {
340
+ this._counters.clear()
341
+ this._gauges.clear()
342
+ this._histograms.clear()
343
+ }
344
+ }
345
+
346
+ // ── Singleton ────────────────────────────────────────────────────────────
347
+
348
+ let _instance: MetricsRegistry | null = null
349
+
350
+ /** Get the global MetricsRegistry singleton. */
351
+ export function getMetrics(): MetricsRegistry {
352
+ if (!_instance) {
353
+ _instance = new MetricsRegistry()
354
+ }
355
+ return _instance
356
+ }
357
+
358
+ /** Reset the singleton (useful for testing). */
359
+ export function resetMetrics(): void {
360
+ if (_instance) {
361
+ _instance.reset()
362
+ _instance = null
363
+ }
364
+ }