@miphamai/cli 0.10.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.10.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",
@@ -1,4 +1,7 @@
1
1
  // apps/cli/src/agent/agent-context.ts
2
+ import { readdirSync, readFileSync, existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { homedir } from 'node:os'
2
5
  import { ContextManager } from '../core/context'
3
6
  import type { ToolDefinition } from '../shared/index.ts'
4
7
  import type { AgentDefinition } from './types'
@@ -8,6 +11,50 @@ export interface AgentContextResult {
8
11
  allowedTools: ToolDefinition[]
9
12
  }
10
13
 
14
+ /**
15
+ * Load agent memory files from the appropriate scope directory.
16
+ * Returns combined content for injection into the system prompt.
17
+ */
18
+ function loadAgentMemory(agentName: string, scope: 'user' | 'project' | 'local'): string {
19
+ let memoryDir: string
20
+ const home = homedir()
21
+
22
+ switch (scope) {
23
+ case 'user':
24
+ memoryDir = join(home, '.mipham', 'agent-memory', agentName)
25
+ break
26
+ case 'project':
27
+ memoryDir = join(process.cwd(), '.mipham', 'agent-memory', agentName)
28
+ break
29
+ case 'local':
30
+ memoryDir = join(process.cwd(), '.mipham', 'agent-memory-local', agentName)
31
+ break
32
+ }
33
+
34
+ if (!existsSync(memoryDir)) return ''
35
+
36
+ try {
37
+ const files = readdirSync(memoryDir).filter((f) => f.endsWith('.md'))
38
+ if (files.length === 0) return ''
39
+
40
+ const contents: string[] = []
41
+ for (const file of files.slice(0, 10)) {
42
+ // max 10 files
43
+ try {
44
+ const content = readFileSync(join(memoryDir, file), 'utf-8').trim()
45
+ if (content) contents.push(content)
46
+ } catch {
47
+ // skip unreadable
48
+ }
49
+ }
50
+
51
+ if (contents.length === 0) return ''
52
+ return [`[Agent Memory — ${scope} scope]`, ...contents].join('\n\n')
53
+ } catch {
54
+ return ''
55
+ }
56
+ }
57
+
11
58
  /**
12
59
  * Create an isolated context and tool set for a sub-agent.
13
60
  *
@@ -15,6 +62,9 @@ export interface AgentContextResult {
15
62
  * 1. If `tools` is set, only those tools are allowed.
16
63
  * 2. If `disallowedTools` is set, those are removed from the full set.
17
64
  * 3. If neither is set, all tools are available.
65
+ *
66
+ * Agent memory: if agentDef.memory is set, loads memory files from the
67
+ * appropriate scope and injects them into the system prompt.
18
68
  */
19
69
  export function createAgentContext(
20
70
  agentDef: AgentDefinition,
@@ -27,7 +77,16 @@ export function createAgentContext(
27
77
  compactionThreshold: 0.85,
28
78
  })
29
79
 
30
- context.setSystemPrompt(agentDef.systemPrompt)
80
+ // Build system prompt with optional agent memory
81
+ let systemPrompt = agentDef.systemPrompt
82
+ if (agentDef.memory) {
83
+ const memory = loadAgentMemory(agentDef.name, agentDef.memory)
84
+ if (memory) {
85
+ systemPrompt = `${systemPrompt}\n\n---\n\n${memory}`
86
+ }
87
+ }
88
+
89
+ context.setSystemPrompt(systemPrompt)
31
90
 
32
91
  // Scope tools
33
92
  let allowedTools = Array.from(toolRegistry.values())
@@ -74,6 +74,7 @@ export class AgentRegistry {
74
74
  .map((s) => s.trim())
75
75
  : undefined,
76
76
  background: (data.background as boolean) || false,
77
+ memory: data.memory as 'user' | 'project' | 'local' | undefined,
77
78
  source,
78
79
  filePath: fullPath,
79
80
  }
@@ -12,6 +12,7 @@ export interface AgentFrontmatter {
12
12
  maxTurns?: number
13
13
  skills?: string
14
14
  background?: boolean
15
+ memory?: 'user' | 'project' | 'local' // agent memory scope
15
16
  }
16
17
 
17
18
  export interface AgentDefinition {
@@ -27,6 +28,7 @@ export interface AgentDefinition {
27
28
  background: boolean
28
29
  source: 'builtin' | 'project' | 'user'
29
30
  filePath?: string
31
+ memory?: 'user' | 'project' | 'local' // agent memory scope
30
32
  }
31
33
 
32
34
  export interface SubAgentOptions {
@@ -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'
@@ -10,6 +15,8 @@ import { getMemoryManager } from './memory/memory-loader'
10
15
  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'
18
+ import { RulesLoader } from './rules-loader'
19
+ import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
13
20
 
14
21
  export class QueryEngine {
15
22
  private hookEngine?: HookEngine
@@ -71,9 +78,47 @@ export class QueryEngine {
71
78
  this.skillsLoader = loader
72
79
  }
73
80
 
81
+ /** Rules loader for path-scoped rules injection. */
82
+ private rulesLoader?: RulesLoader
83
+ /** Files touched in the current turn (for rules matching). */
84
+ private touchedFiles: Set<string> = new Set()
85
+ /** Inference hook (DLP) configuration. */
86
+ private inferenceHookConfig?: InferenceHookConfig
87
+
88
+ /** Register the rules loader. */
89
+ setRulesLoader(loader: RulesLoader): void {
90
+ this.rulesLoader = loader
91
+ this.rulesLoader.load()
92
+ }
93
+
94
+ /** Register inference hook (DLP) configuration. */
95
+ setInferenceHookConfig(config: InferenceHookConfig): void {
96
+ this.inferenceHookConfig = config
97
+ }
98
+
74
99
  /** Pending task notifications from background agents (cleared after draining). */
75
100
  private pendingTaskNotifications: Array<StreamChunk> = []
76
101
 
102
+ /** Track files touched by tools for rules matching. */
103
+ private trackTouchedFile(toolName: string, params: Record<string, unknown>): void {
104
+ const fileTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep']
105
+ if (!fileTools.includes(toolName)) return
106
+ const filePath = (params.file_path || params.path || params.file) as string | undefined
107
+ if (filePath && typeof filePath === 'string') {
108
+ this.touchedFiles.add(filePath)
109
+ }
110
+ }
111
+
112
+ /** Inject matching rules as context after tool execution. */
113
+ private injectRules(): void {
114
+ if (!this.rulesLoader || this.touchedFiles.size === 0) return
115
+ const files = Array.from(this.touchedFiles)
116
+ const block = this.rulesLoader.buildContextBlock(files)
117
+ if (!block) return
118
+ this.context.addMessage({ role: 'user', content: block })
119
+ this.touchedFiles.clear()
120
+ }
121
+
77
122
  /**
78
123
  * Drain pending background task notifications.
79
124
  * Call this after tool execution to surface completed/failed background agent results.
@@ -224,6 +269,27 @@ export class QueryEngine {
224
269
  const messages = this.context.getMessages()
225
270
  const toolDefs = this.getToolDefinitions()
226
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
+
227
293
  let assistantContent = ''
228
294
  let reasoningContent = ''
229
295
  let thinkingContent = ''
@@ -351,6 +417,9 @@ export class QueryEngine {
351
417
  })
352
418
  }
353
419
 
420
+ // Inject path-scoped rules for touched files
421
+ this.injectRules()
422
+
354
423
  // Drain task notifications after tool execution
355
424
  for (const chunk of this.drainTaskNotifications()) {
356
425
  yield chunk
@@ -446,6 +515,27 @@ export class QueryEngine {
446
515
  const systemPrompt = this.context.getSystemPrompt()
447
516
  const messages = this.context.getMessages()
448
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
+
449
539
  let assistantContent = ''
450
540
  let reasoningContent = ''
451
541
  let thinkingContent = ''
@@ -633,6 +723,9 @@ export class QueryEngine {
633
723
  backgroundAgentRegistry: getBackgroundAgentRegistry(),
634
724
  })
635
725
 
726
+ // Track touched files for rules matching
727
+ this.trackTouchedFile(name, effectiveParams)
728
+
636
729
  // Run PostToolUse hooks
637
730
  if (this.hookEngine) {
638
731
  await this.hookEngine.executePostToolUse(name, effectiveParams, result, 'session-1')
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
+ }