@miphamai/cli 0.72.0 → 0.73.1

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.72.0",
3
+ "version": "0.73.1",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -553,9 +553,7 @@ async function setupStep3(ctx: CommandContext): Promise<CommandResult> {
553
553
  lines.push(` ${p.id}${p.id === ctx.providerId ? ' ← current' : ''}`)
554
554
  for (const m of p.models.filter((m) => m.status === 'active')) {
555
555
  const marker = m.id === ctx.modelId ? ' ★' : ' '
556
- lines.push(
557
- `${marker} ${m.id.padEnd(30)} ${m.contextWindow.toLocaleString()} ctx ${m.vision ? '🖼' : '📝'}`,
558
- )
556
+ lines.push(`${marker} ${m.id.padEnd(30)} ${m.vision ? '🖼' : '📝'}`)
559
557
  }
560
558
  lines.push('')
561
559
  }
@@ -251,8 +251,37 @@ export function wildcardMatch(pattern: string, input: string): boolean {
251
251
  return new RegExp(regexStr).test(input)
252
252
  }
253
253
 
254
+ /**
255
+ * Validate a rule pattern string's structure, mirroring exactly what
256
+ * `matchBashRule` / `ruleMatches` will actually match. A pattern that is
257
+ * syntactically valid but never matches (e.g. `Bash(ls) x`, `Read(foo`,
258
+ * `Bash()`) is silently dead today — this returns a human-readable reason so
259
+ * the caller can report it as an invalid setting instead of ignoring it.
260
+ *
261
+ * Returns null when the pattern is valid, or a reason string when malformed.
262
+ */
263
+ export function validateRulePattern(pattern: string): string | null {
264
+ if (!pattern.trim()) return 'rule pattern is empty'
265
+
266
+ if (pattern.includes('(')) {
267
+ if (!pattern.includes(')')) return 'unclosed parenthesis'
268
+ // Empty parameter: `Bash()` or `Bash( )`
269
+ if (/\(\s*\)$/.test(pattern)) return 'empty parameter'
270
+ // Must be exactly `ToolName(param)` with nothing before or after.
271
+ if (!/^(\w+)\((.+)\)$/.test(pattern)) {
272
+ return 'unexpected text after the closing parenthesis'
273
+ }
274
+ return null
275
+ }
276
+
277
+ // No parenthesis → must be a plain tool name (matched via `pattern === tool.name`).
278
+ if (!/^\w+$/.test(pattern)) return 'not a single tool name'
279
+ return null
280
+ }
281
+
254
282
  /** Compile a rule pattern string into a PermissionRuleEntry. */
255
283
  export function compileRule(pattern: string, level: 'allow' | 'deny' | 'ask'): PermissionRuleEntry {
284
+ const invalid = validateRulePattern(pattern)
256
285
  const regexStr =
257
286
  '^' +
258
287
  pattern
@@ -261,5 +290,5 @@ export function compileRule(pattern: string, level: 'allow' | 'deny' | 'ask'): P
261
290
  .replace(/\\\*/g, '.*')
262
291
  .replace(/\\\?/g, '.') +
263
292
  '$'
264
- return { pattern, level, compiled: new RegExp(regexStr) }
293
+ return { pattern, level, compiled: new RegExp(regexStr), ...(invalid ? { invalid } : {}) }
265
294
  }
@@ -489,6 +489,24 @@ export class PermissionSystem {
489
489
  this.invalidateCache()
490
490
  }
491
491
 
492
+ /**
493
+ * Report any rule whose pattern is structurally invalid and can therefore
494
+ * never match (e.g. `Bash(ls) x`, `Read(foo`, `Bash()`). These are silently
495
+ * dead today — callers should surface them as invalid settings rather than
496
+ * let a deny rule fail closed without the user noticing.
497
+ */
498
+ getInvalidRules(): string[] {
499
+ const invalid: string[] = []
500
+ for (const entry of [...this.allowRules, ...this.denyRules, ...this.askRules]) {
501
+ if (entry.invalid) {
502
+ invalid.push(
503
+ `permission rule "${entry.pattern}" is invalid (${entry.invalid}) and will never match`,
504
+ )
505
+ }
506
+ }
507
+ return invalid
508
+ }
509
+
492
510
  removeRule(toolName: string): void {
493
511
  this.legacyRules.delete(toolName)
494
512
  this.removeRuleFromArrays(toolName)
@@ -95,6 +95,9 @@ export function buildDaemonPermission(
95
95
  for (const rule of rules.allow ?? []) permission.allow(rule)
96
96
  for (const rule of rules.deny ?? []) permission.deny(rule)
97
97
  }
98
+ for (const msg of permission.getInvalidRules()) {
99
+ process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
100
+ }
98
101
  return permission
99
102
  }
100
103
 
@@ -780,7 +780,7 @@
780
780
  "ui": {
781
781
  "banner": {
782
782
  "title": "Mipham Code",
783
- "subtitle": "AI-Powered Programming Assistant",
783
+ "subtitle": "AI-Powered Super Agent",
784
784
  "tagline": "Multi-model / Multi-provider / Skills & Tools / Open-core",
785
785
  "start_message": "Type a message to start. /help for commands",
786
786
  "controls_hint": "Ctrl+P pick model · Esc to exit",
@@ -780,7 +780,7 @@
780
780
  "ui": {
781
781
  "banner": {
782
782
  "title": "Mipham Code",
783
- "subtitle": "AI 驱动的编程助手",
783
+ "subtitle": "AI 驱动的超级智能体",
784
784
  "tagline": "多模型 / 多提供商 / 技能与工具 / 开源核心",
785
785
  "start_message": "输入消息开始。 /help 查看命令",
786
786
  "controls_hint": "Ctrl+P 选择模型 · Esc 退出",
package/src/index.tsx CHANGED
@@ -559,6 +559,12 @@ export async function runApp(options: RunOptions): Promise<void> {
559
559
  for (const rule of settingsJson.permissions.allow) engine.getPermission().allow(rule)
560
560
  for (const rule of settingsJson.permissions.deny) engine.getPermission().deny(rule)
561
561
 
562
+ // Surface malformed permission rules as warnings instead of silently letting
563
+ // them never match (e.g. a deny rule typed `Bash(ls) x` would fail closed).
564
+ for (const msg of engine.getPermission().getInvalidRules()) {
565
+ process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
566
+ }
567
+
562
568
  // Initialize agent registry and load plugin agents/skills/MCP/hooks
563
569
  const agentRegistry = new AgentRegistry()
564
570
  agentRegistry.loadUserAgents()
@@ -624,6 +630,28 @@ export async function runApp(options: RunOptions): Promise<void> {
624
630
 
625
631
  // Auto-save session on exit
626
632
  let saved = false
633
+
634
+ // Persist the current session under its real name. Shared by saveAndExit and
635
+ // the process 'exit' safety net — the two paths must stay identical, or the
636
+ // session gets saved under a different (timestamped) name and /resume breaks.
637
+ const persistSession = () => {
638
+ if (context.getMessageCount() === 0) return
639
+ const log = context.getLog()
640
+ if (log) {
641
+ SessionStore.saveLog(sessionName, log, {
642
+ provider: defaultProvider,
643
+ model: defaultModel,
644
+ cwd: process.cwd(),
645
+ })
646
+ } else {
647
+ SessionStore.save(sessionName, context.getMessages(), {
648
+ provider: defaultProvider,
649
+ model: defaultModel,
650
+ cwd: process.cwd(),
651
+ })
652
+ }
653
+ }
654
+
627
655
  const saveAndExit = () => {
628
656
  saved = true
629
657
  clearInterval(heartbeatInterval)
@@ -631,22 +659,7 @@ export async function runApp(options: RunOptions): Promise<void> {
631
659
  // P2-1: Trigger SessionEnd hooks before cleanup (best-effort)
632
660
  hookEngine.executeSessionEnd(sessionName).catch(() => {})
633
661
  artifactServer.stop()
634
- if (context.getMessageCount() > 0) {
635
- const log = context.getLog()
636
- if (log) {
637
- SessionStore.saveLog(sessionName, log, {
638
- provider: defaultProvider,
639
- model: defaultModel,
640
- cwd: process.cwd(),
641
- })
642
- } else {
643
- SessionStore.save(sessionName, context.getMessages(), {
644
- provider: defaultProvider,
645
- model: defaultModel,
646
- cwd: process.cwd(),
647
- })
648
- }
649
- }
662
+ persistSession()
650
663
  // Finalize the session — write session summary + flush CRSI effectiveness
651
664
  // (evaluate rules and apply auto-degrade/disable). Best-effort: the
652
665
  // self-improvement closeout must never block session exit.
@@ -661,27 +674,25 @@ export async function runApp(options: RunOptions): Promise<void> {
661
674
 
662
675
  process.on('SIGINT', saveAndExit)
663
676
  process.on('SIGTERM', saveAndExit)
677
+ // Terminal close / SSH disconnect sends SIGHUP (not SIGINT/SIGTERM); without
678
+ // a handler the session log is silently dropped on those paths.
679
+ process.on('SIGHUP', saveAndExit)
664
680
 
665
- // Safety net: auto-save on exit for paths that bypass saveAndExit
681
+ // Safety net: persist on exit for paths that bypass saveAndExit (e.g. /exit
682
+ // and /quit call process.exit(0) directly). saveAndExit already persists;
683
+ // guard on !saved to avoid double-writing.
666
684
  process.on('exit', () => {
667
685
  clearInterval(heartbeatInterval)
668
686
  unregisterSession(sessionName)
669
687
  // Finalize the session on non-interactive exit paths
670
- // (daemon worker / crash / kill) that bypass saveAndExit. saveAndExit
671
- // already finalizes; guard on !saved to avoid double-evaluating.
688
+ // (daemon worker / crash / kill) that bypass saveAndExit.
672
689
  if (!saved) {
673
690
  try {
674
691
  engine.getAutoMemory().finalizeSession()
675
692
  } catch {
676
693
  // ignore — CRSI closeout is non-critical
677
694
  }
678
- }
679
- if (!saved && context.getMessageCount() > 0) {
680
- SessionStore.autoSave(context.getMessages(), {
681
- provider: defaultProvider,
682
- model: defaultModel,
683
- cwd: process.cwd(),
684
- })
695
+ persistSession()
685
696
  // Distill learnings from the last 5 user messages into memory
686
697
  const allMessages = context.getMessages()
687
698
  const userMessages = allMessages
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Format a model's context window (token count) into a friendly unit string.
3
+ * Decimal K/M: 16384 → "16K", 131072 → "131K", 200000 → "200K", 1000000 → "1M".
4
+ * Used only on the developer-facing `/models` command — the first-run model
5
+ * picker deliberately omits context window (raw token counts make Mipham's own
6
+ * models look small next to 1M-context competitors).
7
+ */
8
+ export function formatContextWindow(tokens: number): string {
9
+ if (tokens >= 1_000_000) {
10
+ const m = tokens / 1_000_000
11
+ return `${Number.isInteger(m) ? m : m.toFixed(1)}M`
12
+ }
13
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`
14
+ return `${tokens}`
15
+ }
@@ -11,34 +11,34 @@
11
11
  "models": [
12
12
  {
13
13
  "id": "om-v5-flash",
14
- "name": "OM V5 Flash",
14
+ "name": "om-V5-Flash",
15
15
  "providerId": "mipham",
16
16
  "contextWindow": 16384,
17
17
  "maxOutput": 4096,
18
18
  "vision": false,
19
19
  "status": "active"
20
20
  },
21
- {
22
- "id": "om-v5-pro",
23
- "name": "OM V5 Pro",
24
- "providerId": "mipham",
25
- "contextWindow": 131072,
26
- "maxOutput": 16384,
27
- "vision": false,
28
- "status": "active"
29
- },
30
21
  {
31
22
  "id": "om-v5-visual",
32
- "name": "OM V5 Visual",
23
+ "name": "om-V5-Visual",
33
24
  "providerId": "mipham",
34
25
  "contextWindow": 65536,
35
26
  "maxOutput": 4096,
36
27
  "vision": true,
37
28
  "status": "active"
38
29
  },
30
+ {
31
+ "id": "om-v5-pro",
32
+ "name": "om-V5-Pro",
33
+ "providerId": "mipham",
34
+ "contextWindow": 131072,
35
+ "maxOutput": 16384,
36
+ "vision": false,
37
+ "status": "active"
38
+ },
39
39
  {
40
40
  "id": "om-v5-apex",
41
- "name": "OM V5 Apex",
41
+ "name": "om-V5-Apex",
42
42
  "providerId": "mipham",
43
43
  "contextWindow": 200000,
44
44
  "maxOutput": 32768,
@@ -46,5 +46,5 @@
46
46
  "status": "active"
47
47
  }
48
48
  ],
49
- "synced_at": "2026-08-04T00:00:00Z"
49
+ "synced_at": "2026-09-04T02:32:33.479Z"
50
50
  }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.72.0' as const
12
+ export const PACKAGE_VERSION = '0.73.1' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -315,6 +315,8 @@ export interface PermissionRuleEntry {
315
315
  pattern: string // e.g., "Bash(git:*)"
316
316
  level: 'allow' | 'deny' | 'ask'
317
317
  compiled: RegExp
318
+ /** Set when the pattern is structurally invalid and can never match. */
319
+ invalid?: string
318
320
  }
319
321
 
320
322
  export interface PermissionRule {
@@ -1,3 +1,5 @@
1
+ import { homedir } from 'node:os'
2
+ import { parse } from 'node:path'
1
3
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
2
4
  import { resolveSafe } from '../../security/path'
3
5
  import type { Service } from '../../vajra'
@@ -37,6 +39,15 @@ export function truncateGrepOutput(stdout: string): string {
37
39
  return `${out.slice(0, GREP_MAX_OUTPUT_CHARS)}\n\n... (truncated)`
38
40
  }
39
41
 
42
+ /**
43
+ * 判断搜索根是否是「顶层目录」(家目录或文件系统根)。这类范围扫描的是海量
44
+ * 文件树(macOS ~/Library 有数百万受保护文件),会让 rg exit 2、find 回退卡
45
+ * 满 120s。顶层范围应 fail-fast,让模型指定项目目录,而不是静默全盘扫。
46
+ */
47
+ export function isTopLevelScope(searchPath: string, home = homedir()): boolean {
48
+ return searchPath === home || searchPath === parse(searchPath).root
49
+ }
50
+
40
51
  export function createGrepTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
41
52
  return {
42
53
  name: 'Grep',
@@ -65,6 +76,20 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
65
76
  const searchPath = resolveSafe(ctx.cwd, (params.path as string) || '.')
66
77
  const include = params.include as string | undefined
67
78
 
79
+ // Scope guard: a top-level search root (home or filesystem root) scans an
80
+ // enormous tree and stalls the find fallback for minutes. Fail fast and
81
+ // ask for a project-scoped path instead of silently scanning everything.
82
+ if (!params.path && isTopLevelScope(searchPath)) {
83
+ return {
84
+ success: false,
85
+ content: '',
86
+ error:
87
+ `Search scope is the home directory / filesystem root (${searchPath}) — too large ` +
88
+ 'and contains protected directories. Specify a project directory with "path", ' +
89
+ 'or cd into the project first.',
90
+ }
91
+ }
92
+
68
93
  // 1. ripgrep (fast path)
69
94
  const rgArgs = ['rg', '-n', '--heading', '--color=never', '-M', '500', pattern]
70
95
  if (include) rgArgs.push('--glob', include)
@@ -85,9 +110,30 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
85
110
  content: maskSearchOutput(stdout || '(no matches)', credentialConfig, 'heading'),
86
111
  }
87
112
  }
88
- // rg exit 2 (error) or null (killed) → fall through to grep
113
+ // rg exit 2 (error, e.g. permission denied on protected dirs) — do NOT
114
+ // fall back to the slow `find -type f` scan (it hits the same unreadable
115
+ // paths and stalls on huge trees). Return partial matches if any, else
116
+ // a clear narrow-scope error.
117
+ if (stdout && stdout.trim()) {
118
+ return {
119
+ success: true,
120
+ content: maskSearchOutput(
121
+ stdout +
122
+ '\n\n(rg exited 2 — some paths unreadable; narrow scope for complete results)',
123
+ credentialConfig,
124
+ 'heading',
125
+ ),
126
+ }
127
+ }
128
+ return {
129
+ success: false,
130
+ content: '',
131
+ error:
132
+ 'rg error (exit 2) — likely permission denied on a large/protected tree. ' +
133
+ 'Narrow scope with "path" (project directory) and "include".',
134
+ }
89
135
  } catch {
90
- // rg not installed → fall through to grep
136
+ // rg not installed → fall through to grep (find + grep fallback)
91
137
  }
92
138
 
93
139
  // 2. fallback: `find -type f -exec grep -Hn {} +`
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { QueryEngine } from '../core/engine'
8
8
  import type { MiphamConfig } from '../shared/index.ts'
9
+ import { formatContextWindow } from '../shared/format'
9
10
  import type { SkillsLoader } from '../skills/loader'
10
11
  import type { PluginManager } from '../plugin/plugin-manager'
11
12
  import type { Message } from '../shared/types.js'
@@ -438,7 +439,7 @@ const modelsCmd: CommandHandler = (ctx) => {
438
439
  .filter((m) => m.status === 'active')
439
440
  .map(
440
441
  (m) =>
441
- ` ${p.id.padEnd(12)} ${m.id.padEnd(30)} ${m.contextWindow.toLocaleString()} ctx ${m.vision ? '🖼' : '📝'}`,
442
+ ` ${p.id.padEnd(12)} ${m.id.padEnd(30)} ${formatContextWindow(m.contextWindow)} ctx ${m.vision ? '🖼' : '📝'}`,
442
443
  ),
443
444
  )
444
445
 
package/src/ui/picker.tsx CHANGED
@@ -198,7 +198,7 @@ export function ModelPicker({
198
198
  </Text>
199
199
  <Text dimColor>
200
200
  {' '}
201
- {m.id} · {m.contextWindow.toLocaleString()} ctx
201
+ {m.id}
202
202
  {m.vision ? ' · 🖼 vision' : ''}
203
203
  </Text>
204
204
  </Box>