@miphamai/cli 0.39.1 → 0.40.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/bin/mipham.ts CHANGED
@@ -1094,17 +1094,9 @@ async function main() {
1094
1094
  process.on('SIGINT', restoreTerminal)
1095
1095
  process.on('SIGTERM', restoreTerminal)
1096
1096
 
1097
- // ── Read version fresh from package.json at startup (bypasses Bun module cache) ──
1098
- const { readFileSync } = await import('node:fs')
1099
- const { join } = await import('node:path')
1100
- let APP_VERSION = '0.0.0'
1101
- try {
1102
- const pkgPath = join(import.meta.dirname!, '..', 'package.json')
1103
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
1104
- APP_VERSION = pkg.version
1105
- } catch {
1106
- // fallback — version will be '0.0.0'
1107
- }
1097
+ // ── Version: build-time constant (bundled into the compiled binary) ──
1098
+ const { PACKAGE_NAME, PACKAGE_VERSION } = await import('../src/shared/package-info')
1099
+ const APP_VERSION = PACKAGE_VERSION
1108
1100
 
1109
1101
  // ── Version flag ──────────────────────────────────────────────────────────
1110
1102
  if (
@@ -1112,8 +1104,7 @@ async function main() {
1112
1104
  process.argv.includes('-v') ||
1113
1105
  process.argv.includes('-V')
1114
1106
  ) {
1115
- const pkg = JSON.parse(readFileSync(join(import.meta.dirname!, '..', 'package.json'), 'utf-8'))
1116
- console.log(`${pkg.name} v${pkg.version}`)
1107
+ console.log(`${PACKAGE_NAME} v${PACKAGE_VERSION}`)
1117
1108
  process.exit(0)
1118
1109
  }
1119
1110
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.39.1",
3
+ "version": "0.40.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -29,15 +29,14 @@
29
29
  "bin/",
30
30
  "src/",
31
31
  "skills/",
32
- "assets/",
33
- "dist/"
32
+ "assets/"
34
33
  ],
35
34
  "engines": {
36
35
  "node": ">=22.0.0"
37
36
  },
38
37
  "scripts": {
39
38
  "dev": "bun run bin/mipham.ts",
40
- "build": "bun build --compile --minify --external react-devtools-core ./bin/mipham.ts --outfile dist/mipham",
39
+ "build": "bun build --compile --minify ./bin/mipham.ts --outfile dist/mipham",
41
40
  "typecheck": "tsc --noEmit",
42
41
  "test": "vitest run"
43
42
  },
@@ -17,6 +17,7 @@ import type { ExperienceRuleEngine, ToolRule } from './rule-engine.js'
17
17
  import type { EffectivenessTracker } from '../agent/effectiveness-tracker.js'
18
18
  import type { ErrorSignatureDB } from './error-signature-db.js'
19
19
  import type { CrsiProvenanceBridge } from '../agent/crsi-provenance-bridge.js'
20
+ import { getMetrics } from './metrics'
20
21
  import { join } from 'node:path'
21
22
  import { homedir } from 'node:os'
22
23
 
@@ -228,11 +229,28 @@ export class AutoMemoryEngine {
228
229
  this.writeSessionSummary()
229
230
  }
230
231
 
231
- // Flush CRSI state — evaluate first (reports verdicts to megasystem,
232
- // applies auto-degrade/disable/upgrade), then persist.
233
- if (this.effectivenessTracker) {
234
- this.effectivenessTracker.evaluate()
235
- this.effectivenessTracker.persist()
232
+ // Flush CRSI effectiveness (evaluate + apply auto-management).
233
+ this.flushEffectiveness()
234
+ }
235
+
236
+ /**
237
+ * Evaluate rule effectiveness and apply auto-management to the rule engine:
238
+ * ineffective rules are disabled so intercept() stops injecting them, and
239
+ * recovered rules are re-enabled. Without this, `evaluate()`'s verdicts are
240
+ * computed but never acted upon — the self-improvement loop stays open.
241
+ */
242
+ flushEffectiveness(): void {
243
+ if (!this.effectivenessTracker) return
244
+
245
+ const { upgrades, disables } = this.effectivenessTracker.evaluate()
246
+ this.effectivenessTracker.persist()
247
+
248
+ if (this.ruleEngine) {
249
+ for (const id of disables) {
250
+ this.ruleEngine.setRuleEnabled(id, false)
251
+ getMetrics().crsiRuleDisables.inc()
252
+ }
253
+ for (const id of upgrades) this.ruleEngine.setRuleEnabled(id, true)
236
254
  }
237
255
  }
238
256
 
@@ -35,6 +35,7 @@ import { DreamEngine } from './dream-engine.js'
35
35
  import { ConstitutionLoader } from './constitution-loader.js'
36
36
  import { SelfCritique } from './self-critique.js'
37
37
  import { UsageTracker } from './usage-tracker'
38
+ import { getMetrics } from './metrics'
38
39
  import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
39
40
  import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
40
41
  import { getMessageBus, type AgentMessage } from '../agent/message-bus'
@@ -921,6 +922,7 @@ export class QueryEngine {
921
922
  }
922
923
 
923
924
  private async executeTool(name: string, params: Record<string, unknown>): Promise<ToolResult> {
925
+ getMetrics().toolCalls.inc({ tool_name: name })
924
926
  const tool = this.tools.get(name)
925
927
  if (!tool) {
926
928
  // P2-4: Provide a more helpful error for unavailable tools
@@ -970,6 +972,7 @@ export class QueryEngine {
970
972
  if (this.crsiConfig?.preToolHook !== false && this.ruleEngine) {
971
973
  const ruleResult = this.ruleEngine.intercept(name, effectiveParams)
972
974
  if (Object.keys(ruleResult.modified).length > 0) {
975
+ getMetrics().crsiRuleApplications.inc()
973
976
  effectiveParams = ruleResult.modified
974
977
  }
975
978
  if (ruleResult.warnings.length > 0) {
@@ -980,6 +983,7 @@ export class QueryEngine {
980
983
  // ── SIS PreFlightChecker — known error pattern interception ──
981
984
  const preflight = this.getPreFlightChecker().check(name, effectiveParams)
982
985
  if (preflight.action === 'block') {
986
+ getMetrics().sisInterceptions.inc()
983
987
  return {
984
988
  success: false,
985
989
  content: preflight.warning || 'SIS 免疫系统拦截了此操作',
@@ -987,6 +991,7 @@ export class QueryEngine {
987
991
  }
988
992
  }
989
993
  if (preflight.action === 'fix' && preflight.modifiedParams) {
994
+ getMetrics().sisInterceptions.inc()
990
995
  effectiveParams = preflight.modifiedParams
991
996
  if (preflight.warning) {
992
997
  hookWarnings = [...hookWarnings, preflight.warning]
@@ -6,6 +6,11 @@
6
6
  * collected in-memory and can be exported in Prometheus text format
7
7
  * or JSON.
8
8
  *
9
+ * All metric types support *labels*: calling `.inc({ tool_name: 'bash' })`
10
+ * records a value for the `tool_name="bash"` series, distinct from the
11
+ * unlabelled series. The registry deduplicates metric families by
12
+ * `name + base labels`, so a family is created once and reused.
13
+ *
9
14
  * Usage:
10
15
  * import { getMetrics } from '../core/metrics.js'
11
16
  *
@@ -25,7 +30,7 @@ export interface MetricLabels {
25
30
  [key: string]: string
26
31
  }
27
32
 
28
- /** Format labels into Prometheus {...} string. */
33
+ /** Format labels into Prometheus {...} string (keys sorted for determinism). */
29
34
  function formatLabels(labels?: MetricLabels): string {
30
35
  if (!labels || Object.keys(labels).length === 0) return ''
31
36
  const parts = Object.entries(labels)
@@ -34,36 +39,53 @@ function formatLabels(labels?: MetricLabels): string {
34
39
  return `{${parts.join(',')}}`
35
40
  }
36
41
 
42
+ /** Merge base labels with an extra set and format the result. */
43
+ function mergedLabels(base: MetricLabels, extra?: MetricLabels): string {
44
+ return formatLabels({ ...base, ...(extra ?? {}) })
45
+ }
46
+
37
47
  // ── Counter ──────────────────────────────────────────────────────────────
38
48
 
39
49
  export class Counter {
40
50
  readonly name: string
41
51
  readonly help: string
42
- readonly labels: MetricLabels
43
- private _value = 0
52
+ readonly baseLabels: MetricLabels
53
+ /** key = formatted merged labels, value = running count. */
54
+ private _series = new Map<string, number>()
44
55
 
45
56
  constructor(name: string, help: string, labels?: MetricLabels) {
46
57
  this.name = name
47
58
  this.help = help
48
- this.labels = labels ?? {}
59
+ this.baseLabels = labels ?? {}
60
+ // Seed the base series so the metric is always present (even at 0).
61
+ this._series.set(formatLabels(this.baseLabels), 0)
49
62
  }
50
63
 
51
- inc(amount = 1): void {
52
- this._value += amount
64
+ /** Increment. Accepts either an amount, labels, or both via a second arg. */
65
+ inc(amountOrLabels?: number | MetricLabels, maybeAmount?: number): void {
66
+ let amount = 1
67
+ let extra: MetricLabels | undefined
68
+ if (typeof amountOrLabels === 'number') amount = amountOrLabels
69
+ else if (amountOrLabels) extra = amountOrLabels
70
+ if (maybeAmount !== undefined) amount = maybeAmount
71
+ const key = mergedLabels(this.baseLabels, extra)
72
+ this._series.set(key, (this._series.get(key) ?? 0) + amount)
53
73
  }
54
74
 
55
- get value(): number {
56
- return this._value
75
+ /** Current value of a labelled series (base series when no labels given). */
76
+ value(extra?: MetricLabels): number {
77
+ return this._series.get(mergedLabels(this.baseLabels, extra)) ?? 0
57
78
  }
58
79
 
59
- /** Full metric name with labels for dedup key. */
80
+ /** Identity key for registry dedup (name + base labels). */
60
81
  get key(): string {
61
- return this.name + formatLabels(this.labels)
82
+ return this.name + formatLabels(this.baseLabels)
62
83
  }
63
84
 
64
85
  toPrometheus(): string {
65
- const labelStr = formatLabels(this.labels)
66
- return `${this.name}${labelStr} ${this._value}`
86
+ return Array.from(this._series.entries())
87
+ .map(([labelStr, v]) => `${this.name}${labelStr} ${v}`)
88
+ .join('\n')
67
89
  }
68
90
 
69
91
  toJSON(): object {
@@ -71,8 +93,7 @@ export class Counter {
71
93
  name: this.name,
72
94
  type: 'counter',
73
95
  help: this.help,
74
- labels: this.labels,
75
- value: this._value,
96
+ series: Array.from(this._series.entries()).map(([labels, value]) => ({ labels, value })),
76
97
  }
77
98
  }
78
99
  }
@@ -82,38 +103,52 @@ export class Counter {
82
103
  export class Gauge {
83
104
  readonly name: string
84
105
  readonly help: string
85
- readonly labels: MetricLabels
86
- private _value = 0
106
+ readonly baseLabels: MetricLabels
107
+ private _series = new Map<string, number>()
87
108
 
88
109
  constructor(name: string, help: string, labels?: MetricLabels) {
89
110
  this.name = name
90
111
  this.help = help
91
- this.labels = labels ?? {}
112
+ this.baseLabels = labels ?? {}
113
+ this._series.set(formatLabels(this.baseLabels), 0)
92
114
  }
93
115
 
94
- inc(amount = 1): void {
95
- this._value += amount
116
+ inc(amountOrLabels?: number | MetricLabels, maybeAmount?: number): void {
117
+ let amount = 1
118
+ let extra: MetricLabels | undefined
119
+ if (typeof amountOrLabels === 'number') amount = amountOrLabels
120
+ else if (amountOrLabels) extra = amountOrLabels
121
+ if (maybeAmount !== undefined) amount = maybeAmount
122
+ const key = mergedLabels(this.baseLabels, extra)
123
+ this._series.set(key, (this._series.get(key) ?? 0) + amount)
96
124
  }
97
125
 
98
- dec(amount = 1): void {
99
- this._value -= amount
126
+ dec(amountOrLabels?: number | MetricLabels, maybeAmount?: number): void {
127
+ let amount = 1
128
+ let extra: MetricLabels | undefined
129
+ if (typeof amountOrLabels === 'number') amount = amountOrLabels
130
+ else if (amountOrLabels) extra = amountOrLabels
131
+ if (maybeAmount !== undefined) amount = maybeAmount
132
+ const key = mergedLabels(this.baseLabels, extra)
133
+ this._series.set(key, (this._series.get(key) ?? 0) - amount)
100
134
  }
101
135
 
102
- set(value: number): void {
103
- this._value = value
136
+ set(value: number, extra?: MetricLabels): void {
137
+ this._series.set(mergedLabels(this.baseLabels, extra), value)
104
138
  }
105
139
 
106
- get value(): number {
107
- return this._value
140
+ value(extra?: MetricLabels): number {
141
+ return this._series.get(mergedLabels(this.baseLabels, extra)) ?? 0
108
142
  }
109
143
 
110
144
  get key(): string {
111
- return this.name + formatLabels(this.labels)
145
+ return this.name + formatLabels(this.baseLabels)
112
146
  }
113
147
 
114
148
  toPrometheus(): string {
115
- const labelStr = formatLabels(this.labels)
116
- return `${this.name}${labelStr} ${this._value}`
149
+ return Array.from(this._series.entries())
150
+ .map(([labelStr, v]) => `${this.name}${labelStr} ${v}`)
151
+ .join('\n')
117
152
  }
118
153
 
119
154
  toJSON(): object {
@@ -121,8 +156,7 @@ export class Gauge {
121
156
  name: this.name,
122
157
  type: 'gauge',
123
158
  help: this.help,
124
- labels: this.labels,
125
- value: this._value,
159
+ series: Array.from(this._series.entries()).map(([labels, value]) => ({ labels, value })),
126
160
  }
127
161
  }
128
162
  }
@@ -131,82 +165,100 @@ export class Gauge {
131
165
 
132
166
  const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
133
167
 
168
+ interface HistogramSeries {
169
+ labels: MetricLabels
170
+ count: number
171
+ sum: number
172
+ bucketCounts: number[]
173
+ }
174
+
134
175
  export class Histogram {
135
176
  readonly name: string
136
177
  readonly help: string
137
- readonly labels: MetricLabels
178
+ readonly baseLabels: MetricLabels
138
179
  readonly buckets: number[]
139
- private _count = 0
140
- private _sum = 0
141
- private _bucketCounts: number[]
180
+ private _series = new Map<string, HistogramSeries>()
142
181
 
143
182
  constructor(name: string, help: string, buckets?: number[], labels?: MetricLabels) {
144
183
  this.name = name
145
184
  this.help = help
146
- this.labels = labels ?? {}
147
185
  this.buckets = buckets ?? DEFAULT_BUCKETS
148
- this._bucketCounts = new Array(this.buckets.length).fill(0)
186
+ this.baseLabels = labels ?? {}
187
+ this._series.set(formatLabels(this.baseLabels), {
188
+ labels: this.baseLabels,
189
+ count: 0,
190
+ sum: 0,
191
+ bucketCounts: new Array(this.buckets.length).fill(0),
192
+ })
193
+ }
194
+
195
+ private getSeries(extra?: MetricLabels): HistogramSeries {
196
+ const key = mergedLabels(this.baseLabels, extra)
197
+ let s = this._series.get(key)
198
+ if (!s) {
199
+ s = {
200
+ labels: { ...this.baseLabels, ...(extra ?? {}) },
201
+ count: 0,
202
+ sum: 0,
203
+ bucketCounts: new Array(this.buckets.length).fill(0),
204
+ }
205
+ this._series.set(key, s)
206
+ }
207
+ return s
149
208
  }
150
209
 
151
- observe(value: number): void {
152
- this._count++
153
- this._sum += value
210
+ observe(value: number, extra?: MetricLabels): void {
211
+ const s = this.getSeries(extra)
212
+ s.count++
213
+ s.sum += value
154
214
  for (let i = 0; i < this.buckets.length; i++) {
155
215
  if (value <= (this.buckets[i] ?? Infinity)) {
156
- this._bucketCounts[i] = (this._bucketCounts[i] ?? 0) + 1
216
+ s.bucketCounts[i] = (s.bucketCounts[i] ?? 0) + 1
157
217
  }
158
218
  }
159
219
  }
160
220
 
161
- get count(): number {
162
- return this._count
221
+ get key(): string {
222
+ return this.name + formatLabels(this.baseLabels)
163
223
  }
164
224
 
165
- get sum(): number {
166
- return this._sum
225
+ /** Total observations in the base (unlabelled) series. */
226
+ get count(): number {
227
+ return this._series.get(formatLabels(this.baseLabels))?.count ?? 0
167
228
  }
168
229
 
169
- get key(): string {
170
- return this.name + formatLabels(this.labels)
230
+ /** Sum of observations in the base (unlabelled) series. */
231
+ get sum(): number {
232
+ return this._series.get(formatLabels(this.baseLabels))?.sum ?? 0
171
233
  }
172
234
 
173
235
  toPrometheus(): string {
174
- const labelStr = formatLabels(this.labels)
175
236
  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]}`)
237
+ for (const s of this._series.values()) {
238
+ for (let i = 0; i < this.buckets.length; i++) {
239
+ const leLabel = formatLabels({ ...s.labels, le: String(this.buckets[i]) })
240
+ lines.push(`${this.name}_bucket${leLabel} ${s.bucketCounts[i] ?? 0}`)
241
+ }
242
+ const infLabel = formatLabels({ ...s.labels, le: '+Inf' })
243
+ lines.push(`${this.name}_bucket${infLabel} ${s.count}`)
244
+ const labelStr = formatLabels(s.labels)
245
+ lines.push(`${this.name}_sum${labelStr} ${s.sum}`)
246
+ lines.push(`${this.name}_count${labelStr} ${s.count}`)
184
247
  }
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
248
  return lines.join('\n')
194
249
  }
195
250
 
196
251
  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
252
  return {
203
253
  name: this.name,
204
254
  type: 'histogram',
205
255
  help: this.help,
206
- labels: this.labels,
207
- count: this._count,
208
- sum: this._sum,
209
- buckets: bucketResults,
256
+ series: Array.from(this._series.values()).map((s) => ({
257
+ labels: s.labels,
258
+ count: s.count,
259
+ sum: s.sum,
260
+ buckets: this.buckets.map((le, i) => ({ le: String(le), count: s.bucketCounts[i] ?? 0 })),
261
+ })),
210
262
  }
211
263
  }
212
264
  }
@@ -238,6 +290,15 @@ export class MetricsRegistry {
238
290
  /** Active CLI sessions gauge. */
239
291
  readonly activeSessions: Gauge
240
292
 
293
+ /** CRSI auto-fix rule applications counter. */
294
+ readonly crsiRuleApplications: Counter
295
+
296
+ /** CRSI rules disabled for low effectiveness counter. */
297
+ readonly crsiRuleDisables: Counter
298
+
299
+ /** SIS immune system interceptions (block/fix of known error patterns). */
300
+ readonly sisInterceptions: Counter
301
+
241
302
  constructor() {
242
303
  // Pre-register standard metrics
243
304
  this.cliInvocations = this.counter(
@@ -264,6 +325,21 @@ export class MetricsRegistry {
264
325
  )
265
326
 
266
327
  this.activeSessions = this.gauge('mipham_code_active_sessions', 'Number of active CLI sessions')
328
+
329
+ this.crsiRuleApplications = this.counter(
330
+ 'mipham_code_crsi_rule_applications_total',
331
+ 'Number of CRSI auto-fix rule applications',
332
+ )
333
+
334
+ this.crsiRuleDisables = this.counter(
335
+ 'mipham_code_crsi_rule_disables_total',
336
+ 'Number of CRSI rules disabled for low effectiveness',
337
+ )
338
+
339
+ this.sisInterceptions = this.counter(
340
+ 'mipham_code_sis_interceptions_total',
341
+ 'Number of SIS immune system interceptions',
342
+ )
267
343
  }
268
344
 
269
345
  // ── Factory methods ─────────────────────────────────────────────
@@ -22,6 +22,7 @@ import { loadOrCreateToken } from './auth'
22
22
  import { RateLimiter } from './rate-limiter'
23
23
  import { PACKAGE_VERSION } from '../shared/package-info'
24
24
  import type { DaemonStatus } from './types'
25
+ import { logger } from './logger'
25
26
 
26
27
  const HOME = homedir()
27
28
  const MIPHAM_HOME = join(HOME, '.mipham')
@@ -133,7 +134,7 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
133
134
  if (sessionsCount === 0) {
134
135
  const migrated = db.migrateFromJsonl()
135
136
  if (migrated > 0) {
136
- console.log(`Daemon: migrated ${migrated} JSONL session(s) to SQLite`)
137
+ logger.info('migrated JSONL sessions to SQLite', { count: migrated })
137
138
  }
138
139
  }
139
140
 
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Minimal structured JSON logger for the Mipham Code daemon.
3
+ *
4
+ * Emits one JSON object per line to stdout (debug/info) or stderr
5
+ * (warn/error), so logs can be aggregated by the process manager
6
+ * (PM2, systemd, Docker) without ad-hoc string formatting.
7
+ *
8
+ * Usage:
9
+ * import { logger } from './logger'
10
+ * logger.info('migrated sessions', { count })
11
+ * logger.child({ sessionId }).error('prompt failed', { error: err })
12
+ */
13
+
14
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
15
+
16
+ export interface LogFields {
17
+ [key: string]: unknown
18
+ }
19
+
20
+ const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
21
+
22
+ /** Serialize a value for JSON logging, converting Error to {message, stack}. */
23
+ function sanitizeValue(v: unknown): unknown {
24
+ if (v instanceof Error) {
25
+ return { message: v.message, stack: v.stack }
26
+ }
27
+ return v
28
+ }
29
+
30
+ export class Logger {
31
+ constructor(
32
+ private readonly name: string,
33
+ private readonly fields: LogFields = {},
34
+ private readonly minLevel: LogLevel = 'info',
35
+ ) {}
36
+
37
+ /** Return a child logger with extra context fields merged in. */
38
+ child(fields: LogFields): Logger {
39
+ return new Logger(this.name, { ...this.fields, ...fields }, this.minLevel)
40
+ }
41
+
42
+ private emit(level: LogLevel, msg: string, fields?: LogFields): void {
43
+ if (ORDER[level] < ORDER[this.minLevel]) return
44
+
45
+ const entry: Record<string, unknown> = {
46
+ ts: new Date().toISOString(),
47
+ level,
48
+ name: this.name,
49
+ msg,
50
+ ...this.fields,
51
+ }
52
+ if (fields) {
53
+ for (const [k, v] of Object.entries(fields)) {
54
+ entry[k] = sanitizeValue(v)
55
+ }
56
+ }
57
+
58
+ const line = JSON.stringify(entry) + '\n'
59
+ if (level === 'warn' || level === 'error') {
60
+ process.stderr.write(line)
61
+ } else {
62
+ process.stdout.write(line)
63
+ }
64
+ }
65
+
66
+ debug(msg: string, fields?: LogFields): void {
67
+ this.emit('debug', msg, fields)
68
+ }
69
+
70
+ info(msg: string, fields?: LogFields): void {
71
+ this.emit('info', msg, fields)
72
+ }
73
+
74
+ warn(msg: string, fields?: LogFields): void {
75
+ this.emit('warn', msg, fields)
76
+ }
77
+
78
+ error(msg: string, fields?: LogFields): void {
79
+ this.emit('error', msg, fields)
80
+ }
81
+ }
82
+
83
+ /** Shared daemon logger. */
84
+ export const logger = new Logger('mipham-daemon')
@@ -5,6 +5,7 @@
5
5
  import type { DaemonDatabase } from './database'
6
6
  import type { WorkerPool } from './worker-pool'
7
7
  import type { DaemonSchedule } from './types'
8
+ import { logger } from './logger'
8
9
 
9
10
  // Simple minute-granularity cron expression parser.
10
11
  //
@@ -125,7 +126,7 @@ export class ScheduleManager {
125
126
  if (!worker) continue
126
127
 
127
128
  worker.processPrompt(schedule.prompt).catch((err: unknown) => {
128
- console.error(`ScheduleManager: error firing schedule ${schedule.id}:`, err)
129
+ logger.error('error firing schedule', { scheduleId: schedule.id, error: err })
129
130
  })
130
131
 
131
132
  const now = new Date()
@@ -12,6 +12,7 @@ import { RateLimiter } from './rate-limiter'
12
12
  import { corsMiddleware, addCorsHeaders } from './cors'
13
13
  import { PACKAGE_VERSION } from '../shared/package-info'
14
14
  import { WorkerPool } from './worker-pool'
15
+ import { logger } from './logger'
15
16
  import type { SessionWorker } from './session-worker'
16
17
  import type { ClientMessage } from './attach-protocol'
17
18
  import { QueryEngine } from '../core/engine'
@@ -192,7 +193,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
192
193
 
193
194
  return worker
194
195
  } catch (err) {
195
- console.error(`Server: error creating worker for session ${sessionId}:`, err)
196
+ logger.error('error creating worker', { sessionId, error: err })
196
197
  return null
197
198
  }
198
199
  }
@@ -208,6 +209,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
208
209
  const url = new URL(req.url)
209
210
  const path = url.pathname
210
211
  const method = req.method
212
+ logger.info('request', { method, path })
211
213
 
212
214
  // ── Rate limiting (skip health endpoint) ──────────
213
215
  if (path !== '/api/v1/health') {
@@ -296,7 +298,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
296
298
  const sessionId = sessionMatch[1]!
297
299
  // Stop worker (interrupt, persist, mark idle) before closing session
298
300
  pool.stopWorker(sessionId).catch((err: unknown) => {
299
- console.error(`Server: error stopping worker for session ${sessionId}:`, err)
301
+ logger.error('error stopping worker', { sessionId, error: err })
300
302
  })
301
303
  // Remove engine from cache
302
304
  engineCache.delete(sessionId)
@@ -346,7 +348,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
346
348
  // Fire and forget — respond immediately while processing
347
349
  // streams results to connected WebSocket clients.
348
350
  worker.processPrompt(prompt).catch((err: unknown) => {
349
- console.error(`Server: error processing prompt for session ${sessionId}:`, err)
351
+ logger.error('error processing prompt', { sessionId, error: err })
350
352
  })
351
353
 
352
354
  return json({ ok: true, data: { sessionId, status: 'processing' } }, { status: 202 })
@@ -628,7 +630,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
628
630
  if (!worker) return
629
631
 
630
632
  worker.processPrompt(parsed.prompt).catch((err: unknown) => {
631
- console.error(`Server: error processing WS prompt for session ${sessionId}:`, err)
633
+ logger.error('error processing WS prompt', { sessionId, error: err })
632
634
  })
633
635
  break
634
636
  }
@@ -22,6 +22,7 @@ import type { DaemonDatabase } from './database'
22
22
  import type { QueryEngine } from '../core/engine'
23
23
  import type { ContextManager } from '../core/context'
24
24
  import type { ProviderRegistry } from '../providers/registry'
25
+ import { logger } from './logger'
25
26
 
26
27
  export class WorkerPool {
27
28
  private workers: Map<string, SessionWorker>
@@ -149,7 +150,7 @@ export class WorkerPool {
149
150
  this.stopWorker(sessionId).catch((err) => {
150
151
  // Log but never crash — this runs in a Timer callback outside
151
152
  // any request context
152
- console.error(`WorkerPool: error auto-stopping idle worker for session ${sessionId}:`, err)
153
+ logger.error('error auto-stopping idle worker', { sessionId, error: err })
153
154
  })
154
155
  }, this.idleTimeoutMs)
155
156
 
package/src/index.tsx CHANGED
@@ -193,8 +193,9 @@ function SetupGate(props: SetupGateProps) {
193
193
  }
194
194
 
195
195
  export async function runApp(options: RunOptions): Promise<void> {
196
- // Metrics: count CLI invocation
196
+ // Metrics: count CLI invocation + active session
197
197
  getMetrics().cliInvocations.inc()
198
+ getMetrics().activeSessions.inc()
198
199
 
199
200
  // ── Workspace Trust Check ──
200
201
  await checkWorkspaceTrust()
@@ -591,6 +592,14 @@ export async function runApp(options: RunOptions): Promise<void> {
591
592
  })
592
593
  }
593
594
  }
595
+ // Flush CRSI effectiveness — evaluate rules and apply auto-degrade/disable.
596
+ // Best-effort: the self-improvement flush must never block session exit.
597
+ try {
598
+ engine.getAutoMemory().flushEffectiveness()
599
+ } catch {
600
+ // ignore — CRSI flush is non-critical
601
+ }
602
+ getMetrics().activeSessions.dec()
594
603
  process.exit(0)
595
604
  }
596
605
 
@@ -1,5 +1,6 @@
1
1
  import type { ProviderConfig, ModelInfo, Message, StreamChunk } from '../shared/index.ts'
2
2
  import type { Llm } from './llm'
3
+ import { getMetrics } from '../core/metrics'
3
4
 
4
5
  export interface ProviderInstance {
5
6
  config: ProviderConfig
@@ -115,6 +116,23 @@ export class ProviderRegistry implements Llm {
115
116
 
116
117
  async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
117
118
  const provider = this.getActive()
118
- yield* provider.chat({ ...req, model: req.model || this.activeModelId })
119
+ const providerId = this.activeProviderId
120
+ const modelId = req.model || this.activeModelId
121
+ const metrics = getMetrics()
122
+ const start = Date.now()
123
+ metrics.modelRequests.inc({ provider: providerId, model: modelId })
124
+ try {
125
+ for await (const chunk of provider.chat({ ...req, model: modelId })) {
126
+ if (chunk.type === 'error') {
127
+ metrics.modelRequestErrors.inc({ provider: providerId, error_type: 'api_error' })
128
+ }
129
+ yield chunk
130
+ }
131
+ } catch (err) {
132
+ metrics.modelRequestErrors.inc({ provider: providerId, error_type: 'exception' })
133
+ throw err
134
+ } finally {
135
+ metrics.modelRequestDurationMs.observe(Date.now() - start, { provider: providerId })
136
+ }
119
137
  }
120
138
  }
@@ -1,22 +1,15 @@
1
1
  /**
2
2
  * Mipham Code — package metadata (bundled version)
3
3
  *
4
- * Reads directly from the CLI's own package.json at runtime
5
- * so npm consumers don't need the @mipham/shared workspace package.
4
+ * Compile-time constants (bundled into the compiled binary — no runtime
5
+ * package.json read). Version is synced by scripts/bump-version.sh.
6
6
  */
7
7
 
8
- import { readFileSync } from 'node:fs'
9
- import path from 'node:path'
10
-
11
- // resolve package.json relative to this source file
12
- const PKG_PATH = path.join(import.meta.dirname!, '..', '..', 'package.json')
13
- const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf-8'))
14
-
15
8
  /** npm 包全名(含 scope) */
16
- export const PACKAGE_NAME: string = pkg.name
9
+ export const PACKAGE_NAME = '@miphamai/cli' as const
17
10
 
18
11
  /** 当前发布版本 */
19
- export const PACKAGE_VERSION: string = pkg.version
12
+ export const PACKAGE_VERSION = '0.40.0' as const
20
13
 
21
14
  /** npm install 全局安装命令 */
22
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -668,6 +668,13 @@ const crsiRestoreCmd: CommandHandler = (ctx, args) => {
668
668
  return { content: `Rule \`${ruleId}\` has been re-enabled.` }
669
669
  }
670
670
 
671
+ /** Render a tiny ASCII sparkline from a series of 0-1 values (success rates). */
672
+ function crsiSparkline(values: number[]): string {
673
+ if (values.length === 0) return '—'
674
+ const chars = '▁▂▃▄▅▆▇█'
675
+ return values.map((v) => chars[Math.min(Math.max(Math.round(v * 7), 0), 7)] ?? '▁').join('')
676
+ }
677
+
671
678
  const crsiStatsCmd: CommandHandler = async (ctx) => {
672
679
  const engine = ctx.engine.getRuleEngine()
673
680
  const tracker = ctx.engine.getEffectivenessTracker()
@@ -682,21 +689,43 @@ const crsiStatsCmd: CommandHandler = async (ctx) => {
682
689
  lines.push(`Auto-generated: ${rules.filter((r) => r.source === 'pattern-analyzer').length}`)
683
690
  lines.push(`Manual: ${rules.filter((r) => r.source === 'manual').length}`)
684
691
 
685
- if (tracker) {
692
+ const effs = tracker?.allRules ?? []
693
+ if (effs.length > 0) {
686
694
  let totalInterceptions = 0
687
695
  let totalSuccesses = 0
688
- for (const r of rules) {
689
- const eff = tracker.getEffectiveness(r.id)
690
- if (eff) {
691
- totalInterceptions += eff.appliedCount
692
- totalSuccesses += eff.successAfterCount
693
- }
696
+ for (const eff of effs) {
697
+ totalInterceptions += eff.appliedCount
698
+ totalSuccesses += eff.successAfterCount
694
699
  }
695
700
  lines.push('')
696
701
  lines.push(`Total interceptions: ${totalInterceptions}`)
697
702
  lines.push(
698
703
  `Success rate after rules: ${totalInterceptions > 0 ? Math.round((totalSuccesses / totalInterceptions) * 100) : 0}%`,
699
704
  )
705
+
706
+ lines.push('')
707
+ lines.push('### Rule effectiveness')
708
+ lines.push('| Rule | Status | Applied | Success | Trend |')
709
+ lines.push('|------|--------|---------|---------|-------|')
710
+ const statusIcon: Record<string, string> = { active: '🟢', degrading: '🟡', disabled: '⚫' }
711
+ for (const eff of effs) {
712
+ const icon = statusIcon[eff.status] ?? '⚪'
713
+ const rate =
714
+ eff.appliedCount > 0 ? Math.round((eff.successAfterCount / eff.appliedCount) * 100) : 0
715
+ const trend = crsiSparkline(eff.evaluationHistory.map((h) => 1 - h.failureRate))
716
+ lines.push(
717
+ `| ${eff.ruleId} | ${icon} ${eff.status} | ${eff.appliedCount} | ${rate}% | ${trend} |`,
718
+ )
719
+ }
720
+
721
+ const disabled = effs.filter((e) => e.status === 'disabled')
722
+ if (disabled.length > 0) {
723
+ lines.push('')
724
+ lines.push('### Disabled rules (retired)')
725
+ for (const d of disabled) {
726
+ lines.push(`- ${d.ruleId} (failure rate ${Math.round(d.postRuleFailureRate * 100)}%)`)
727
+ }
728
+ }
700
729
  }
701
730
 
702
731
  return { content: lines.join('\n') }
@@ -907,6 +936,21 @@ const sisStatsCmd: CommandHandler = (ctx) => {
907
936
  )
908
937
  lines.push(`平均成功率: ${Math.round(stats.avgSuccessRate * 100)}%`)
909
938
  lines.push(`总拦截次数: ${stats.totalInterceptions}`)
939
+
940
+ const active = db.getActive()
941
+ if (active.length > 0) {
942
+ lines.push('')
943
+ lines.push('### 签名明细(按拦截次数排序)')
944
+ lines.push('| 签名 | 状态 | 拦截 | 成功率 |')
945
+ lines.push('|------|------|------|--------|')
946
+ for (const s of active) {
947
+ const icon = s.status === 'active' ? '🟢' : '🟡'
948
+ lines.push(
949
+ `| ${s.id} | ${icon} ${s.status} | ${s.occurrences} | ${Math.round(s.successRate * 100)}% |`,
950
+ )
951
+ }
952
+ }
953
+
910
954
  return { content: lines.join('\n') }
911
955
  }
912
956