@miphamai/cli 0.11.0 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -5,6 +5,7 @@ import { ARTIFACT_ALLOWED_EXTENSIONS } from '../shared/constants'
5
5
  import { readManifest } from './manifest'
6
6
  import { ArtifactVersioning } from './versioning'
7
7
  import type { ArtifactEntry } from '../shared/types'
8
+ import { getMetrics } from '../core/metrics'
8
9
 
9
10
  // ── SSE client tracking ──
10
11
  interface SseClient {
@@ -151,10 +152,48 @@ export class ArtifactServer {
151
152
  return
152
153
  }
153
154
 
155
+ // Metrics endpoint — Prometheus text format
156
+ if (urlPath === '/metrics') {
157
+ this.serveMetrics(res, 'prometheus')
158
+ return
159
+ }
160
+
161
+ // Metrics endpoint — JSON format
162
+ if (urlPath === '/metrics/json') {
163
+ this.serveMetrics(res, 'json')
164
+ return
165
+ }
166
+
154
167
  // Artifact file
155
168
  this.serveArtifact(urlPath, res)
156
169
  }
157
170
 
171
+ // ── Metrics ──
172
+
173
+ private serveMetrics(res: any, format: 'prometheus' | 'json'): void {
174
+ try {
175
+ const metrics = getMetrics()
176
+ if (format === 'json') {
177
+ const body = JSON.stringify(metrics.toJSON(), null, 2)
178
+ res.writeHead(200, {
179
+ 'Content-Type': 'application/json',
180
+ 'Access-Control-Allow-Origin': '*',
181
+ })
182
+ res.end(body)
183
+ } else {
184
+ const body = metrics.toPrometheusText()
185
+ res.writeHead(200, {
186
+ 'Content-Type': 'text/plain; charset=utf-8',
187
+ 'Access-Control-Allow-Origin': '*',
188
+ })
189
+ res.end(body)
190
+ }
191
+ } catch (err) {
192
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
193
+ res.end(`Metrics error: ${err instanceof Error ? err.message : 'unknown'}`)
194
+ }
195
+ }
196
+
158
197
  // ── SSE ──
159
198
 
160
199
  private handleSse(res: any): void {
@@ -1,4 +1,4 @@
1
- import type { MiphamConfig } from '../shared/index.ts'
1
+ import type { MiphamConfig, InferenceHookConfig } from '../shared/index.ts'
2
2
  import { DEFAULT_PROVIDERS } from '../shared/index.ts'
3
3
  import { PACKAGE_VERSION } from '../shared/index.ts'
4
4
 
@@ -9,3 +9,12 @@ export const DEFAULT_CONFIG: MiphamConfig = {
9
9
  permission: 'auto',
10
10
  providers: DEFAULT_PROVIDERS,
11
11
  }
12
+
13
+ export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
14
+ endpoint: '',
15
+ signing_secret: '',
16
+ timeout: 5000,
17
+ on_failure: 'fail-closed',
18
+ organization_id: '',
19
+ headers: {},
20
+ }
@@ -10,8 +10,13 @@ import {
10
10
  import { join, dirname } from 'node:path'
11
11
  import { homedir } from 'node:os'
12
12
  import { parse as parseYaml } from 'yaml'
13
- import type { MiphamConfig, ProviderConfig, McpServerConfig } from '../shared/index.ts'
14
- import { DEFAULT_CONFIG } from './defaults'
13
+ import type {
14
+ MiphamConfig,
15
+ ProviderConfig,
16
+ McpServerConfig,
17
+ InferenceHookConfig,
18
+ } from '../shared/index.ts'
19
+ import { DEFAULT_CONFIG, DEFAULT_INFERENCE_HOOK_CONFIG } from './defaults'
15
20
 
16
21
  const MIPHAM_HOME = join(homedir(), '.mipham')
17
22
  const BACKUP_PREFIX = 'config.backup-'
@@ -256,3 +261,40 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
256
261
 
257
262
  return config
258
263
  }
264
+
265
+ /**
266
+ * Load inference hooks (DLP) configuration from the same config sources
267
+ * as the main config. Merges project-level over user-level.
268
+ *
269
+ * Returns default (disabled) config if no inference_hooks section is present.
270
+ */
271
+ export function loadInferenceHookConfig(cwd: string = process.cwd()): InferenceHookConfig {
272
+ const configPath = join(cwd, '.mipham', 'config.yml')
273
+ const userConfigPath = join(MIPHAM_HOME, 'config.yml')
274
+
275
+ let merged = { ...DEFAULT_INFERENCE_HOOK_CONFIG }
276
+
277
+ const paths = [userConfigPath, configPath] // project wins (loaded last)
278
+ for (const path of paths) {
279
+ try {
280
+ if (!existsSync(path)) continue
281
+ const raw = readFileSync(path, 'utf-8')
282
+ const parsed = parseYaml(raw) as Record<string, unknown>
283
+ const section = parsed.inference_hooks as Partial<InferenceHookConfig> | undefined
284
+ if (section) {
285
+ merged = {
286
+ endpoint: section.endpoint ?? merged.endpoint,
287
+ signing_secret: section.signing_secret ?? merged.signing_secret,
288
+ timeout: section.timeout ?? merged.timeout,
289
+ on_failure: section.on_failure ?? merged.on_failure,
290
+ organization_id: section.organization_id ?? merged.organization_id,
291
+ headers: { ...merged.headers, ...(section.headers || {}) },
292
+ }
293
+ }
294
+ } catch {
295
+ // Silently skip malformed configs — main loadConfig already warns
296
+ }
297
+ }
298
+
299
+ return merged
300
+ }
@@ -1,4 +1,9 @@
1
- import type { StreamChunk, ToolDefinition, ToolResult } from '../shared/index.ts'
1
+ import type {
2
+ StreamChunk,
3
+ ToolDefinition,
4
+ ToolResult,
5
+ InferenceHookConfig,
6
+ } from '../shared/index.ts'
2
7
  import { ProviderRegistry } from '../providers/registry'
3
8
  import { ContextManager } from './context'
4
9
  import { PermissionSystem } from './permission'
@@ -11,6 +16,7 @@ import type { AgentViewManager } from '../agent-view/agent-view-manager'
11
16
  import type { SkillsLoader } from '../skills/loader'
12
17
  import { getBackgroundAgentRegistry } from '../agent/background-registry'
13
18
  import { RulesLoader } from './rules-loader'
19
+ import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
14
20
 
15
21
  export class QueryEngine {
16
22
  private hookEngine?: HookEngine
@@ -76,6 +82,8 @@ export class QueryEngine {
76
82
  private rulesLoader?: RulesLoader
77
83
  /** Files touched in the current turn (for rules matching). */
78
84
  private touchedFiles: Set<string> = new Set()
85
+ /** Inference hook (DLP) configuration. */
86
+ private inferenceHookConfig?: InferenceHookConfig
79
87
 
80
88
  /** Register the rules loader. */
81
89
  setRulesLoader(loader: RulesLoader): void {
@@ -83,6 +91,11 @@ export class QueryEngine {
83
91
  this.rulesLoader.load()
84
92
  }
85
93
 
94
+ /** Register inference hook (DLP) configuration. */
95
+ setInferenceHookConfig(config: InferenceHookConfig): void {
96
+ this.inferenceHookConfig = config
97
+ }
98
+
86
99
  /** Pending task notifications from background agents (cleared after draining). */
87
100
  private pendingTaskNotifications: Array<StreamChunk> = []
88
101
 
@@ -256,6 +269,27 @@ export class QueryEngine {
256
269
  const messages = this.context.getMessages()
257
270
  const toolDefs = this.getToolDefinitions()
258
271
 
272
+ // ── PreInference DLP checkpoint ──
273
+ if (isInferenceHookEnabled(this.inferenceHookConfig)) {
274
+ const provider = this.registry.getActive().config.id
275
+ const model = this.registry.getActiveModel()
276
+ const request = buildRequest(
277
+ messages,
278
+ 'session-1',
279
+ provider,
280
+ model,
281
+ this.inferenceHookConfig!.organization_id,
282
+ )
283
+ const verdict = await sendInferenceCheck(this.inferenceHookConfig!, request)
284
+ if (!verdict.allowed) {
285
+ yield {
286
+ type: 'error',
287
+ error: verdict.reason || 'Request blocked by DLP policy.',
288
+ }
289
+ return
290
+ }
291
+ }
292
+
259
293
  let assistantContent = ''
260
294
  let reasoningContent = ''
261
295
  let thinkingContent = ''
@@ -481,6 +515,27 @@ export class QueryEngine {
481
515
  const systemPrompt = this.context.getSystemPrompt()
482
516
  const messages = this.context.getMessages()
483
517
 
518
+ // ── PreInference DLP checkpoint (every tool-calling turn) ──
519
+ if (isInferenceHookEnabled(this.inferenceHookConfig)) {
520
+ const provider = this.registry.getActive().config.id
521
+ const model = this.registry.getActiveModel()
522
+ const request = buildRequest(
523
+ messages,
524
+ 'session-1',
525
+ provider,
526
+ model,
527
+ this.inferenceHookConfig!.organization_id,
528
+ )
529
+ const verdict = await sendInferenceCheck(this.inferenceHookConfig!, request)
530
+ if (!verdict.allowed) {
531
+ yield {
532
+ type: 'error',
533
+ error: verdict.reason || 'Request blocked by DLP policy.',
534
+ }
535
+ return
536
+ }
537
+ }
538
+
484
539
  let assistantContent = ''
485
540
  let reasoningContent = ''
486
541
  let thinkingContent = ''
package/src/core/hooks.ts CHANGED
@@ -155,6 +155,29 @@ export class HookEngine {
155
155
  return this.runHooks('PostToolUseFailure', toolName, ctx)
156
156
  }
157
157
 
158
+ /** PreInference: fires before every model API call for DLP inspection. */
159
+ async executePreInference(
160
+ messages: Array<{ role: string; content: string }>,
161
+ toolCalls: Array<{
162
+ name: string
163
+ input: Record<string, unknown>
164
+ resultPreview: string
165
+ }>,
166
+ sessionId: string,
167
+ provider: string,
168
+ model: string,
169
+ ): Promise<HookResult> {
170
+ const ctx: HookContext = {
171
+ event: 'PreInference',
172
+ sessionId,
173
+ messages,
174
+ toolCalls,
175
+ provider,
176
+ model,
177
+ }
178
+ return this.runHooks('PreInference', undefined, ctx)
179
+ }
180
+
158
181
  // ── Core execution ──
159
182
 
160
183
  private async runHooks(
@@ -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
+ }
package/src/index.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path'
2
2
  import { render } from 'ink'
3
3
  import { App } from './ui/app'
4
- import { loadConfig } from './config/loader'
4
+ import { loadConfig, loadInferenceHookConfig } from './config/loader'
5
5
  import { bootstrapProviders } from './providers/bootstrap'
6
6
  import { InstructionsLoader } from './core/instructions'
7
7
  import { loadSessionMemories } from './core/memory/memory-loader'
@@ -18,6 +18,7 @@ import { registerMcpServerTools } from './mcp/registry'
18
18
  import { AgentRegistry } from './agent/agent-registry'
19
19
  import { HookEngine } from './core/hooks'
20
20
  import { ArtifactServer } from './artifacts/server'
21
+ import { getMetrics } from './core/metrics'
21
22
  import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
22
23
  import { AgentViewManager } from './agent-view/agent-view-manager'
23
24
  import { AgentViewDashboard } from './agent-view/dashboard'
@@ -32,6 +33,9 @@ interface RunOptions {
32
33
  }
33
34
 
34
35
  export async function runApp(options: RunOptions): Promise<void> {
36
+ // Metrics: count CLI invocation
37
+ getMetrics().cliInvocations.inc()
38
+
35
39
  // Handle `mipham agents` subcommand — launch standalone dashboard
36
40
  const args = process.argv.slice(2)
37
41
  if (args[0] === 'agents') {
@@ -149,6 +153,10 @@ export async function runApp(options: RunOptions): Promise<void> {
149
153
  engine.setAgentViewManager(agentViewManager)
150
154
  engine.setSkillsLoader(skillsLoader)
151
155
 
156
+ // Wire inference hooks (DLP) configuration
157
+ const inferenceHookConfig = loadInferenceHookConfig()
158
+ engine.setInferenceHookConfig(inferenceHookConfig)
159
+
152
160
  // Sync engine permission with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
153
161
  if (config.permission) {
154
162
  engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
@@ -196,6 +196,7 @@ export type HookEvent =
196
196
  | 'ConfigChange'
197
197
  | 'SubagentStart'
198
198
  | 'SubagentStop'
199
+ | 'PreInference'
199
200
 
200
201
  export type HookType = 'command' | 'http' | 'code' | 'mcp_tool'
201
202
 
@@ -226,6 +227,18 @@ export interface HookContext {
226
227
  userPrompt?: string
227
228
  configKey?: string
228
229
  configValue?: unknown
230
+ /** PreInference: full conversation messages for DLP inspection. */
231
+ messages?: Array<{ role: string; content: string }>
232
+ /** PreInference: recent tool calls and their results. */
233
+ toolCalls?: Array<{
234
+ name: string
235
+ input: Record<string, unknown>
236
+ resultPreview: string
237
+ }>
238
+ /** PreInference: current provider ID. */
239
+ provider?: string
240
+ /** PreInference: current model ID. */
241
+ model?: string
229
242
  }
230
243
 
231
244
  export interface HookResult {
@@ -278,3 +291,47 @@ export interface PermissionRule {
278
291
  level: PermissionLevel
279
292
  pattern?: string
280
293
  }
294
+
295
+ // ── Inference Hook (DLP) Types ──
296
+
297
+ /** Configuration for the PreInference DLP hook, loaded from config.yml. */
298
+ export interface InferenceHookConfig {
299
+ /** DLP server endpoint (HTTPS). Empty = feature disabled. */
300
+ endpoint: string
301
+ /** HMAC signing secret (format: mis_<random>). */
302
+ signing_secret: string
303
+ /** Request timeout in milliseconds. Default 5000. */
304
+ timeout: number
305
+ /** Failure posture: 'fail-closed' blocks on error, 'fail-open' allows. */
306
+ on_failure: 'fail-closed' | 'fail-open'
307
+ /** Organization identifier (optional, sent in payload). */
308
+ organization_id: string
309
+ /** Additional custom headers to send with each request. */
310
+ headers: Record<string, string>
311
+ }
312
+
313
+ /** Outgoing request to the DLP server. */
314
+ export interface InferenceCheckRequest {
315
+ type: 'inference_check'
316
+ id: string
317
+ created_at: string
318
+ data: {
319
+ type: 'pre_inference'
320
+ session_id: string
321
+ organization_id?: string
322
+ provider: string
323
+ model: string
324
+ messages: Array<{ role: string; content: string }>
325
+ tool_calls: Array<{
326
+ name: string
327
+ input: Record<string, unknown>
328
+ result_preview: string
329
+ }>
330
+ }
331
+ }
332
+
333
+ /** Response from the DLP server. */
334
+ export interface InferenceCheckResponse {
335
+ verdict: 'allow' | 'deny'
336
+ reason?: string
337
+ }
@@ -3272,7 +3272,8 @@ const ideCmd: CommandHandler = async (_ctx) => {
3272
3272
  // ═══════════════════════════════════════════════════════════════
3273
3273
 
3274
3274
  const terminalSetupCmd: CommandHandler = async () => {
3275
- const { writeFileSync, appendFileSync, existsSync, mkdirSync } = await import('node:fs')
3275
+ const { writeFileSync, appendFileSync, existsSync, mkdirSync, readFileSync } =
3276
+ await import('node:fs')
3276
3277
  const { join } = await import('node:path')
3277
3278
  const { homedir } = await import('node:os')
3278
3279
 
@@ -3313,9 +3314,7 @@ const terminalSetupCmd: CommandHandler = async () => {
3313
3314
  const sourceLine = `\n# Mipham Code shell integration\n[ -f ~/.mipham/shell-setup.sh ] && source ~/.mipham/shell-setup.sh\n`
3314
3315
 
3315
3316
  try {
3316
- const existing = existsSync(profilePath)
3317
- ? require('node:fs').readFileSync(profilePath, 'utf-8')
3318
- : ''
3317
+ const existing = existsSync(profilePath) ? readFileSync(profilePath, 'utf-8') : ''
3319
3318
  if (existing.includes('shell-setup.sh')) {
3320
3319
  lines.push(` ⏭ ${profileName} already has Mipham Code integration`)
3321
3320
  } else {
package/dist/mipham DELETED
Binary file