@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 +1 -1
- package/src/agent/agent-context.ts +60 -1
- package/src/agent/agent-registry.ts +1 -0
- package/src/agent/types.ts +2 -0
- package/src/artifacts/server.ts +39 -0
- package/src/config/defaults.ts +10 -1
- package/src/config/loader.ts +44 -2
- package/src/core/engine.ts +94 -1
- package/src/core/hooks.ts +23 -0
- package/src/core/inference-hook.ts +224 -0
- package/src/core/metrics.ts +364 -0
- package/src/core/output-styles.ts +95 -0
- package/src/core/rules-loader.ts +175 -0
- package/src/index.tsx +9 -1
- package/src/mcp/client.ts +9 -0
- package/src/shared/types.ts +57 -0
- package/src/tools/index.ts +2 -0
- package/src/tools/system/tool-search.ts +143 -0
- package/src/ui/commands.ts +161 -43
- package/dist/mipham +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OutputStylesLoader — loads custom response persona styles from .mipham/output-styles/.
|
|
3
|
+
*
|
|
4
|
+
* Each .md file in the directory is a named style. The active style's content
|
|
5
|
+
* is injected into the system prompt to shape the AI's tone and communication style.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const loader = new OutputStylesLoader(cwd)
|
|
9
|
+
* loader.list() // ['concise', 'academic', ...]
|
|
10
|
+
* loader.get('concise') // style content or undefined
|
|
11
|
+
* loader.setActive('concise') // switch active style
|
|
12
|
+
* loader.getActiveContent() // active style content (for system prompt)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
16
|
+
import { join, basename, extname } from 'node:path'
|
|
17
|
+
|
|
18
|
+
export class OutputStylesLoader {
|
|
19
|
+
private stylesDir: string
|
|
20
|
+
private activeStyle: string | null = null
|
|
21
|
+
|
|
22
|
+
constructor(cwd: string) {
|
|
23
|
+
this.stylesDir = join(cwd, '.mipham', 'output-styles')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* List all available style names (filename without .md extension).
|
|
28
|
+
*/
|
|
29
|
+
list(): string[] {
|
|
30
|
+
if (!existsSync(this.stylesDir)) return []
|
|
31
|
+
try {
|
|
32
|
+
return readdirSync(this.stylesDir)
|
|
33
|
+
.filter((f) => f.endsWith('.md'))
|
|
34
|
+
.map((f) => basename(f, extname(f)))
|
|
35
|
+
} catch {
|
|
36
|
+
return []
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the content of a named style. Returns undefined if not found.
|
|
42
|
+
*/
|
|
43
|
+
get(name: string): string | undefined {
|
|
44
|
+
if (!existsSync(this.stylesDir)) return undefined
|
|
45
|
+
const filepath = join(this.stylesDir, `${name}.md`)
|
|
46
|
+
if (!existsSync(filepath)) return undefined
|
|
47
|
+
try {
|
|
48
|
+
return readFileSync(filepath, 'utf-8').trim()
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Set the active output style.
|
|
56
|
+
*/
|
|
57
|
+
setActive(name: string): boolean {
|
|
58
|
+
const content = this.get(name)
|
|
59
|
+
if (!content) return false
|
|
60
|
+
this.activeStyle = name
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Clear the active output style (revert to default personality).
|
|
66
|
+
*/
|
|
67
|
+
clearActive(): void {
|
|
68
|
+
this.activeStyle = null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get the active style name (null if none selected).
|
|
73
|
+
*/
|
|
74
|
+
getActiveName(): string | null {
|
|
75
|
+
return this.activeStyle
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the active style content for system prompt injection.
|
|
80
|
+
* Returns empty string if no style is active.
|
|
81
|
+
*/
|
|
82
|
+
getActiveContent(): string {
|
|
83
|
+
if (!this.activeStyle) return ''
|
|
84
|
+
const content = this.get(this.activeStyle)
|
|
85
|
+
if (!content) return ''
|
|
86
|
+
return [
|
|
87
|
+
`[Output Style: ${this.activeStyle}]`,
|
|
88
|
+
'Adopt the following communication style in all your responses:',
|
|
89
|
+
'',
|
|
90
|
+
content,
|
|
91
|
+
'',
|
|
92
|
+
'---',
|
|
93
|
+
].join('\n')
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RulesLoader — path-scoped rules from .mipham/rules/.
|
|
3
|
+
*
|
|
4
|
+
* Rules are markdown files with YAML frontmatter. They are injected into
|
|
5
|
+
* the conversation when the AI touches matching files.
|
|
6
|
+
*
|
|
7
|
+
* Directory structure:
|
|
8
|
+
* .mipham/rules/
|
|
9
|
+
* always.md — always loaded (no paths filter)
|
|
10
|
+
* typescript.md — loaded when touching *.ts files
|
|
11
|
+
* security.md — loaded when touching auth/ or crypto/ paths
|
|
12
|
+
*
|
|
13
|
+
* Frontmatter:
|
|
14
|
+
* ---
|
|
15
|
+
* paths: "apps/cli/src/**\/*.ts"
|
|
16
|
+
* description: TypeScript coding standards
|
|
17
|
+
* ---
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
21
|
+
import { join, relative } from 'node:path'
|
|
22
|
+
|
|
23
|
+
interface RuleFile {
|
|
24
|
+
name: string
|
|
25
|
+
paths: string[] // glob patterns, empty = always loaded
|
|
26
|
+
description: string
|
|
27
|
+
content: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class RulesLoader {
|
|
31
|
+
private rules: RuleFile[] = []
|
|
32
|
+
private rulesDir: string
|
|
33
|
+
|
|
34
|
+
constructor(cwd: string) {
|
|
35
|
+
this.rulesDir = join(cwd, '.mipham', 'rules')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load all rules from .mipham/rules/. Call once at startup.
|
|
40
|
+
*/
|
|
41
|
+
load(): void {
|
|
42
|
+
this.rules = []
|
|
43
|
+
if (!existsSync(this.rulesDir)) return
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const files = readdirSync(this.rulesDir).filter((f) => f.endsWith('.md'))
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
const filepath = join(this.rulesDir, file)
|
|
49
|
+
try {
|
|
50
|
+
const raw = readFileSync(filepath, 'utf-8')
|
|
51
|
+
const { paths, description, content } = this.parseRule(raw, file)
|
|
52
|
+
this.rules.push({ name: file.replace(/\.md$/, ''), paths, description, content })
|
|
53
|
+
} catch {
|
|
54
|
+
// Skip unparseable files
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Directory read error — rules unavailable
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get rules that match the given file paths.
|
|
64
|
+
* Rules with no paths filter ("always") are always included.
|
|
65
|
+
*/
|
|
66
|
+
getMatchingRules(touchedFiles: string[]): RuleFile[] {
|
|
67
|
+
const matched: RuleFile[] = []
|
|
68
|
+
|
|
69
|
+
for (const rule of this.rules) {
|
|
70
|
+
// Always rules — no paths filter
|
|
71
|
+
if (rule.paths.length === 0) {
|
|
72
|
+
matched.push(rule)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check if any touched file matches any rule path pattern
|
|
77
|
+
for (const file of touchedFiles) {
|
|
78
|
+
for (const pattern of rule.paths) {
|
|
79
|
+
if (this.matchPath(file, pattern)) {
|
|
80
|
+
matched.push(rule)
|
|
81
|
+
// Break inner loops — rule already matched
|
|
82
|
+
break
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (matched.includes(rule)) break
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return matched
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a context block to inject into the conversation.
|
|
94
|
+
*/
|
|
95
|
+
buildContextBlock(touchedFiles: string[]): string {
|
|
96
|
+
const matched = this.getMatchingRules(touchedFiles)
|
|
97
|
+
if (matched.length === 0) return ''
|
|
98
|
+
|
|
99
|
+
const blocks = matched.map(
|
|
100
|
+
(r) => `[Rule: ${r.name}]${r.description ? ` — ${r.description}` : ''}\n${r.content}`,
|
|
101
|
+
)
|
|
102
|
+
return `\n<!-- Path-scoped rules matching: ${touchedFiles.join(', ')} -->\n${blocks.join('\n\n')}\n`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Count loaded rules.
|
|
107
|
+
*/
|
|
108
|
+
count(): number {
|
|
109
|
+
return this.rules.length
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* List loaded rule names.
|
|
114
|
+
*/
|
|
115
|
+
list(): string[] {
|
|
116
|
+
return this.rules.map((r) => r.name)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Simple glob matching. Supports **, *, and exact file matching.
|
|
121
|
+
* Returns true if file matches pattern.
|
|
122
|
+
*/
|
|
123
|
+
private matchPath(file: string, pattern: string): boolean {
|
|
124
|
+
// Convert glob pattern to regex
|
|
125
|
+
let regexStr = pattern
|
|
126
|
+
.replace(/\./g, '\\.')
|
|
127
|
+
.replace(/\*\*/g, '<<GLOBSTAR>>')
|
|
128
|
+
.replace(/\*/g, '[^/]*')
|
|
129
|
+
.replace(/<<GLOBSTAR>>/g, '.*')
|
|
130
|
+
|
|
131
|
+
// If pattern doesn't start with ** or *, anchor to be a suffix match
|
|
132
|
+
if (!pattern.startsWith('**') && !pattern.startsWith('*')) {
|
|
133
|
+
regexStr = regexStr + '$'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
return new RegExp(regexStr).test(file)
|
|
138
|
+
} catch {
|
|
139
|
+
// Invalid pattern — fallback to simple includes
|
|
140
|
+
return file.includes(pattern.replace(/\*\*/g, '').replace(/\*/g, ''))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse a rule file — extract frontmatter and body.
|
|
146
|
+
*/
|
|
147
|
+
private parseRule(
|
|
148
|
+
raw: string,
|
|
149
|
+
filename: string,
|
|
150
|
+
): { paths: string[]; description: string; content: string } {
|
|
151
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
|
|
152
|
+
if (!match) {
|
|
153
|
+
return { paths: [], description: '', content: raw.trim() }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const frontmatter = match[1] || ''
|
|
157
|
+
const body = (match[2] || '').trim()
|
|
158
|
+
|
|
159
|
+
const paths: string[] = []
|
|
160
|
+
let description = ''
|
|
161
|
+
|
|
162
|
+
for (const line of frontmatter.split('\n')) {
|
|
163
|
+
const pathMatch = line.match(/^paths:\s*"(.+)"$/)
|
|
164
|
+
if (pathMatch) {
|
|
165
|
+
pathMatch[1]!.split(',').forEach((p) => paths.push(p.trim()))
|
|
166
|
+
}
|
|
167
|
+
const descMatch = line.match(/^description:\s*(.+)$/)
|
|
168
|
+
if (descMatch) {
|
|
169
|
+
description = descMatch[1]!.trim()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { paths, description, content: body }
|
|
174
|
+
}
|
|
175
|
+
}
|
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)
|
package/src/mcp/client.ts
CHANGED
|
@@ -151,6 +151,15 @@ export class McpClient {
|
|
|
151
151
|
return this.connections.get(name)?.tools || []
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/** List all currently connected MCP server names. */
|
|
155
|
+
getConnectedServers(): string[] {
|
|
156
|
+
const names: string[] = []
|
|
157
|
+
for (const [name, conn] of this.connections) {
|
|
158
|
+
if (conn.status === 'connected') names.push(name)
|
|
159
|
+
}
|
|
160
|
+
return names
|
|
161
|
+
}
|
|
162
|
+
|
|
154
163
|
async callTool(
|
|
155
164
|
serverName: string,
|
|
156
165
|
toolName: string,
|