@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -6,6 +6,8 @@
6
6
  * can be queried via the Task tool (output/stop actions) or the Agent View.
7
7
  */
8
8
 
9
+ export type BackgroundTaskKind = 'interactive' | 'forked' | 'attached' | 'unattended'
10
+
9
11
  export type BackgroundTaskStatus = 'running' | 'completed' | 'failed'
10
12
 
11
13
  export interface BackgroundTask {
@@ -13,10 +15,17 @@ export interface BackgroundTask {
13
15
  description: string
14
16
  agentType: string
15
17
  status: BackgroundTaskStatus
18
+ kind: BackgroundTaskKind
16
19
  startedAt: Date
17
20
  completedAt?: Date
18
21
  result?: string
19
22
  error?: string
23
+ /** Git worktree path (forked tasks) */
24
+ worktree?: string
25
+ /** Git branch name (forked tasks) */
26
+ branch?: string
27
+ /** Created PR URL (if requested) */
28
+ prUrl?: string
20
29
  abortController: AbortController
21
30
  }
22
31
 
@@ -39,6 +48,7 @@ export class BackgroundAgentRegistry {
39
48
  description: string,
40
49
  agentType: string,
41
50
  executor: (signal: AbortSignal) => Promise<string>,
51
+ kind: BackgroundTaskKind = 'interactive',
42
52
  ): string {
43
53
  const id = `bg-${++this.idCounter}-${Date.now().toString(36)}`
44
54
 
@@ -47,6 +57,7 @@ export class BackgroundAgentRegistry {
47
57
  description,
48
58
  agentType,
49
59
  status: 'running',
60
+ kind,
50
61
  startedAt: new Date(),
51
62
  abortController: new AbortController(),
52
63
  }
@@ -13,10 +13,13 @@ export interface SessionMessage {
13
13
  content: string
14
14
  }
15
15
 
16
+ export type SessionKind = 'interactive' | 'forked' | 'attached' | 'unattended'
17
+
16
18
  export interface AgentSession {
17
19
  id: string
18
20
  title: string
19
21
  status: SessionStatus
22
+ kind: SessionKind
20
23
  provider: string
21
24
  model: string
22
25
  task: string
@@ -25,6 +28,12 @@ export interface AgentSession {
25
28
  completedAt?: Date
26
29
  elapsedMs: number
27
30
  messages: SessionMessage[]
31
+ /** Git worktree path (forked sessions) */
32
+ worktree?: string
33
+ /** Git branch name (forked sessions) */
34
+ branch?: string
35
+ /** Created PR URL (if requested) */
36
+ prUrl?: string
28
37
  }
29
38
 
30
39
  export interface CreateSessionOptions {
@@ -53,6 +62,7 @@ export class AgentViewManager {
53
62
  id,
54
63
  title,
55
64
  status: 'needs-input',
65
+ kind: 'interactive',
56
66
  provider: options.provider ?? 'unknown',
57
67
  model: options.model ?? 'unknown',
58
68
  task,
@@ -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,9 @@
1
- import type { MiphamConfig } from '../shared/index.ts'
1
+ import type {
2
+ MiphamConfig,
3
+ InferenceHookConfig,
4
+ CredentialMaskingConfig,
5
+ BackgroundAgentConfig,
6
+ } from '../shared/index.ts'
2
7
  import { DEFAULT_PROVIDERS } from '../shared/index.ts'
3
8
  import { PACKAGE_VERSION } from '../shared/index.ts'
4
9
 
@@ -9,3 +14,32 @@ export const DEFAULT_CONFIG: MiphamConfig = {
9
14
  permission: 'auto',
10
15
  providers: DEFAULT_PROVIDERS,
11
16
  }
17
+
18
+ export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
19
+ endpoint: '',
20
+ signing_secret: '',
21
+ timeout: 5000,
22
+ on_failure: 'fail-closed',
23
+ organization_id: '',
24
+ headers: {},
25
+ }
26
+
27
+ export const DEFAULT_CREDENTIAL_MASKING_CONFIG: CredentialMaskingConfig = {
28
+ enabled: true,
29
+ files: [],
30
+ output_scrubbing: {
31
+ enabled: true,
32
+ patterns: ['(?i)(api[_-]?key|secret|token|password|credential)\\s*[:=]\\s*\\S+'],
33
+ },
34
+ env_filter: {
35
+ enabled: true,
36
+ patterns: ['(?i)(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)$'],
37
+ },
38
+ }
39
+
40
+ export const DEFAULT_BACKGROUND_AGENT_CONFIG: BackgroundAgentConfig = {
41
+ auto_commit: true,
42
+ auto_push: true,
43
+ auto_worktree: true,
44
+ commit_coauthors: true,
45
+ }
@@ -10,8 +10,20 @@ 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
+ CredentialMaskingConfig,
19
+ BackgroundAgentConfig,
20
+ } from '../shared/index.ts'
21
+ import {
22
+ DEFAULT_CONFIG,
23
+ DEFAULT_INFERENCE_HOOK_CONFIG,
24
+ DEFAULT_CREDENTIAL_MASKING_CONFIG,
25
+ DEFAULT_BACKGROUND_AGENT_CONFIG,
26
+ } from './defaults'
15
27
 
16
28
  const MIPHAM_HOME = join(homedir(), '.mipham')
17
29
  const BACKUP_PREFIX = 'config.backup-'
@@ -256,3 +268,111 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
256
268
 
257
269
  return config
258
270
  }
271
+
272
+ /**
273
+ * Load inference hooks (DLP) configuration from the same config sources
274
+ * as the main config. Merges project-level over user-level.
275
+ *
276
+ * Returns default (disabled) config if no inference_hooks section is present.
277
+ */
278
+ export function loadInferenceHookConfig(cwd: string = process.cwd()): InferenceHookConfig {
279
+ const configPath = join(cwd, '.mipham', 'config.yml')
280
+ const userConfigPath = join(MIPHAM_HOME, 'config.yml')
281
+
282
+ let merged = { ...DEFAULT_INFERENCE_HOOK_CONFIG }
283
+
284
+ const paths = [userConfigPath, configPath] // project wins (loaded last)
285
+ for (const path of paths) {
286
+ try {
287
+ if (!existsSync(path)) continue
288
+ const raw = readFileSync(path, 'utf-8')
289
+ const parsed = parseYaml(raw) as Record<string, unknown>
290
+ const section = parsed.inference_hooks as Partial<InferenceHookConfig> | undefined
291
+ if (section) {
292
+ merged = {
293
+ endpoint: section.endpoint ?? merged.endpoint,
294
+ signing_secret: section.signing_secret ?? merged.signing_secret,
295
+ timeout: section.timeout ?? merged.timeout,
296
+ on_failure: section.on_failure ?? merged.on_failure,
297
+ organization_id: section.organization_id ?? merged.organization_id,
298
+ headers: { ...merged.headers, ...(section.headers || {}) },
299
+ }
300
+ }
301
+ } catch {
302
+ // Silently skip malformed configs — main loadConfig already warns
303
+ }
304
+ }
305
+
306
+ return merged
307
+ }
308
+
309
+ /**
310
+ * Load credential masking configuration from the same config sources.
311
+ * Merges project-level over user-level. Returns defaults if no section present.
312
+ */
313
+ export function loadCredentialMaskingConfig(cwd: string = process.cwd()): CredentialMaskingConfig {
314
+ const configPath = join(cwd, '.mipham', 'config.yml')
315
+ const userConfigPath = join(MIPHAM_HOME, 'config.yml')
316
+
317
+ let merged = { ...DEFAULT_CREDENTIAL_MASKING_CONFIG }
318
+
319
+ const paths = [userConfigPath, configPath] // project wins (loaded last)
320
+ for (const path of paths) {
321
+ try {
322
+ if (!existsSync(path)) continue
323
+ const raw = readFileSync(path, 'utf-8')
324
+ const parsed = parseYaml(raw) as Record<string, unknown>
325
+ const section = parsed.credential_masking as Partial<CredentialMaskingConfig> | undefined
326
+ if (section) {
327
+ merged = {
328
+ enabled: section.enabled ?? merged.enabled,
329
+ files: section.files ?? merged.files,
330
+ output_scrubbing: {
331
+ enabled: section.output_scrubbing?.enabled ?? merged.output_scrubbing.enabled,
332
+ patterns: section.output_scrubbing?.patterns ?? merged.output_scrubbing.patterns,
333
+ },
334
+ env_filter: {
335
+ enabled: section.env_filter?.enabled ?? merged.env_filter.enabled,
336
+ patterns: section.env_filter?.patterns ?? merged.env_filter.patterns,
337
+ },
338
+ }
339
+ }
340
+ } catch {
341
+ // Silently skip malformed configs
342
+ }
343
+ }
344
+
345
+ return merged
346
+ }
347
+
348
+ /**
349
+ * Load background agent configuration from config sources.
350
+ */
351
+ export function loadBackgroundAgentConfig(cwd: string = process.cwd()): BackgroundAgentConfig {
352
+ const configPath = join(cwd, '.mipham', 'config.yml')
353
+ const userConfigPath = join(MIPHAM_HOME, 'config.yml')
354
+
355
+ let merged = { ...DEFAULT_BACKGROUND_AGENT_CONFIG }
356
+
357
+ const paths = [userConfigPath, configPath]
358
+ for (const path of paths) {
359
+ try {
360
+ if (!existsSync(path)) continue
361
+ const raw = readFileSync(path, 'utf-8')
362
+ const parsed = parseYaml(raw) as Record<string, unknown>
363
+ const section = parsed.background_agent as Partial<BackgroundAgentConfig> | undefined
364
+ if (section) {
365
+ merged = {
366
+ auto_commit: section.auto_commit ?? merged.auto_commit,
367
+ auto_push: section.auto_push ?? merged.auto_push,
368
+ auto_worktree: section.auto_worktree ?? merged.auto_worktree,
369
+ commit_coauthors: section.commit_coauthors ?? merged.commit_coauthors,
370
+ }
371
+ }
372
+ } catch {
373
+ // Silently skip malformed configs
374
+ }
375
+ }
376
+
377
+ return merged
378
+ }
@@ -0,0 +1,203 @@
1
+ import { relative, isAbsolute } from 'node:path'
2
+ import { homedir } from 'node:os'
3
+ import type { CredentialMaskingConfig, CredentialFileRule } from '../shared/index.ts'
4
+
5
+ /** Sentinel value used to replace masked credentials. */
6
+ export const CREDENTIAL_SENTINEL = '__MIPHAM_CREDENTIAL_MASKED__'
7
+
8
+ /**
9
+ * Check if a file path matches any credential file rule.
10
+ * Returns the matching rule, or null if no match.
11
+ */
12
+ export function matchCredentialFile(
13
+ filePath: string,
14
+ config: CredentialMaskingConfig,
15
+ ): CredentialFileRule | null {
16
+ if (!config.enabled || config.files.length === 0) return null
17
+
18
+ const expanded = expandHome(filePath)
19
+ for (const rule of config.files) {
20
+ if (matchPath(expanded, rule.path)) {
21
+ return rule
22
+ }
23
+ }
24
+ return null
25
+ }
26
+
27
+ /**
28
+ * Apply credential masking to file content.
29
+ * - 'full' mode: return sentinel value only
30
+ * - 'extract' mode: replace regex-matched tokens with sentinel
31
+ */
32
+ export function maskContent(content: string, rule: CredentialFileRule): string {
33
+ if (rule.mode === 'full') {
34
+ return CREDENTIAL_SENTINEL
35
+ }
36
+
37
+ // extract mode
38
+ let masked = content
39
+ for (const extractRule of rule.extract) {
40
+ const replacement = extractRule.replacement || CREDENTIAL_SENTINEL
41
+ try {
42
+ const regex = new RegExp(extractRule.pattern, 'gm')
43
+ masked = masked.replace(regex, replacement)
44
+ } catch {
45
+ // Invalid regex — skip this rule silently
46
+ }
47
+ }
48
+ return masked
49
+ }
50
+
51
+ /**
52
+ * Scrub credential patterns from stdout/stderr output.
53
+ * Uses the configured output_scrubbing patterns to detect and replace
54
+ * credentials that may have leaked into command output.
55
+ */
56
+ export function maskOutput(output: string, config: CredentialMaskingConfig): string {
57
+ if (!config.enabled || !config.output_scrubbing.enabled) return output
58
+
59
+ let masked = output
60
+ for (const pattern of config.output_scrubbing.patterns) {
61
+ try {
62
+ // Strip (?i) inline flags — JS uses the 'i' flag instead
63
+ const clean = pattern.replace(/^\(\?i\)/, '')
64
+ const regex = new RegExp(clean, 'gim')
65
+ masked = masked.replace(regex, (match) => {
66
+ // Replace everything after the separator (= or :)
67
+ return match.replace(/\s*[:=]\s*\S+/, `=${CREDENTIAL_SENTINEL}`)
68
+ })
69
+ } catch {
70
+ // Invalid regex — skip
71
+ }
72
+ }
73
+ return masked
74
+ }
75
+
76
+ /**
77
+ * Filter sensitive environment variables before spawning a subprocess.
78
+ * Returns a sanitized copy of process.env with matching vars removed.
79
+ */
80
+ export function filterEnv(
81
+ env: Record<string, string | undefined>,
82
+ config: CredentialMaskingConfig,
83
+ ): Record<string, string | undefined> {
84
+ if (!config.enabled || !config.env_filter.enabled) return { ...env }
85
+
86
+ const filtered: Record<string, string | undefined> = {}
87
+ for (const [key, value] of Object.entries(env)) {
88
+ let blocked = false
89
+ for (const pattern of config.env_filter.patterns) {
90
+ try {
91
+ // Strip (?i) inline flags — JS uses the 'i' flag instead
92
+ const clean = pattern.replace(/^\(\?i\)/, '')
93
+ const regex = new RegExp(clean, 'i')
94
+ if (regex.test(key)) {
95
+ blocked = true
96
+ break
97
+ }
98
+ } catch {
99
+ // Invalid regex — skip
100
+ }
101
+ }
102
+ if (blocked) {
103
+ // Replace with sentinel so tools can detect they're filtered
104
+ filtered[key] = CREDENTIAL_SENTINEL
105
+ } else {
106
+ filtered[key] = value
107
+ }
108
+ }
109
+
110
+ return filtered
111
+ }
112
+
113
+ /**
114
+ * Get the file display name for a sentinel value (for model visibility).
115
+ * The model sees the sentinel, doesn't know the original file path.
116
+ */
117
+ export function getSentinelDisplay(hint?: string): string {
118
+ if (hint) {
119
+ return `[Credential file masked: ${hint}]`
120
+ }
121
+ return CREDENTIAL_SENTINEL
122
+ }
123
+
124
+ // ── Internal helpers ──
125
+
126
+ /**
127
+ * Expand ~ to the user's home directory.
128
+ */
129
+ function expandHome(p: string): string {
130
+ if (p.startsWith('~')) {
131
+ return homedir() + p.slice(1)
132
+ }
133
+ return p
134
+ }
135
+
136
+ /**
137
+ * Match a file path against a glob-like pattern.
138
+ * Supports:
139
+ * - ** globstar (matches any number of path segments)
140
+ * - * wildcard within a single path segment
141
+ * - ~ home directory expansion
142
+ * - basename-only matching (e.g. ".env*" matches any .env file)
143
+ */
144
+ function matchPath(filePath: string, pattern: string): boolean {
145
+ const expandedPattern = expandHome(pattern)
146
+ const normalizedFile = filePath.replace(/\\/g, '/')
147
+ const normalizedPattern = expandedPattern.replace(/\\/g, '/')
148
+
149
+ // Exact match
150
+ if (normalizedFile === normalizedPattern) {
151
+ return true
152
+ }
153
+
154
+ // File ends with pattern (for relative basename matching like ".env*")
155
+ if (normalizedFile.endsWith('/' + normalizedPattern)) {
156
+ return true
157
+ }
158
+
159
+ // Convert glob pattern to regex
160
+ const regex = globToRegex(normalizedPattern)
161
+ return regex.test(normalizedFile)
162
+ }
163
+
164
+ /**
165
+ * Convert a glob pattern to a RegExp.
166
+ * - ** → .* (matches any path segments)
167
+ * - * → [^/]* (matches within a single segment)
168
+ * - ? → . (single char)
169
+ * - All other special regex chars are escaped.
170
+ */
171
+ function globToRegex(pattern: string): RegExp {
172
+ let regexStr = ''
173
+ let i = 0
174
+ while (i < pattern.length) {
175
+ const ch = pattern[i]!
176
+ if (ch === '*' && pattern[i + 1] === '*') {
177
+ // ** — match zero or more path segments
178
+ regexStr += '.*'
179
+ i += 2
180
+ // Skip trailing slash after **
181
+ if (pattern[i] === '/') i++
182
+ continue
183
+ }
184
+ if (ch === '*') {
185
+ regexStr += '[^/]*'
186
+ i++
187
+ continue
188
+ }
189
+ if (ch === '?') {
190
+ regexStr += '.'
191
+ i++
192
+ continue
193
+ }
194
+ // Escape regex special chars
195
+ if ('\\^$.|+()[]{}'.includes(ch)) {
196
+ regexStr += '\\' + ch
197
+ } else {
198
+ regexStr += ch
199
+ }
200
+ i++
201
+ }
202
+ return new RegExp('^' + regexStr + '$')
203
+ }
@@ -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(