@miphamai/cli 0.73.0 → 0.74.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.73.0",
3
+ "version": "0.74.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -70,6 +70,7 @@ export const ANCHOR_CONTRACT_IDS: ReadonlySet<string> = new Set([
70
70
  'red-team-zero-gaps',
71
71
  'producer-rule-shape',
72
72
  'producer-rule-idempotent',
73
+ 'self-report-diagnostic',
73
74
  ])
74
75
 
75
76
  /** 细粒度防回退:返回 role==='anchor' 且已 FAIL 的契约 id。空 = 无 anchor 回退。 */
@@ -333,6 +334,21 @@ export function runEval(): EvalReport {
333
334
  results.push({ ...judgeBehaviorTask(task, ruleEngine), role: 'target' })
334
335
  }
335
336
 
337
+ // ── 自报分数只作诊断:评分路径无 LLM,分数来自 ground-truth 契约而非模型自报 ──
338
+ // anchor 锁死「评分组件不暴露 LLM 的 chat 能力」。4 个组件(ruleEngine/constitution/
339
+ // errorDB/preflight)都是确定性组件(runEval 同步评分)。若未来有人把 LLM 注入评分
340
+ // 路径(给组件挂 chat / Llm 接口),此契约立即 FAIL,anchor gate 拒绝固化——即「自报
341
+ // 分数不可信」的机器可判定信号。
342
+ const scoringComponents = [ruleEngine, constitution, errorDB, preflight]
343
+ const llmInjected = scoringComponents.some(
344
+ (c) => typeof (c as { chat?: unknown }).chat === 'function',
345
+ )
346
+ results.push({
347
+ id: 'self-report-diagnostic',
348
+ description: '评分无 LLM:机制哨兵组件不暴露 chat 能力(分数只来自 ground-truth,非模型自报)',
349
+ passed: !llmInjected,
350
+ })
351
+
336
352
  // 角色标注:anchor 走集中清单(门保护面单一真源),target 已在上方循环内联。
337
353
  for (const r of results) {
338
354
  if (ANCHOR_CONTRACT_IDS.has(r.id)) r.role = 'anchor'
@@ -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
@@ -630,6 +630,28 @@ export async function runApp(options: RunOptions): Promise<void> {
630
630
 
631
631
  // Auto-save session on exit
632
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
+
633
655
  const saveAndExit = () => {
634
656
  saved = true
635
657
  clearInterval(heartbeatInterval)
@@ -637,22 +659,7 @@ export async function runApp(options: RunOptions): Promise<void> {
637
659
  // P2-1: Trigger SessionEnd hooks before cleanup (best-effort)
638
660
  hookEngine.executeSessionEnd(sessionName).catch(() => {})
639
661
  artifactServer.stop()
640
- if (context.getMessageCount() > 0) {
641
- const log = context.getLog()
642
- if (log) {
643
- SessionStore.saveLog(sessionName, log, {
644
- provider: defaultProvider,
645
- model: defaultModel,
646
- cwd: process.cwd(),
647
- })
648
- } else {
649
- SessionStore.save(sessionName, context.getMessages(), {
650
- provider: defaultProvider,
651
- model: defaultModel,
652
- cwd: process.cwd(),
653
- })
654
- }
655
- }
662
+ persistSession()
656
663
  // Finalize the session — write session summary + flush CRSI effectiveness
657
664
  // (evaluate rules and apply auto-degrade/disable). Best-effort: the
658
665
  // self-improvement closeout must never block session exit.
@@ -667,27 +674,25 @@ export async function runApp(options: RunOptions): Promise<void> {
667
674
 
668
675
  process.on('SIGINT', saveAndExit)
669
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)
670
680
 
671
- // 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.
672
684
  process.on('exit', () => {
673
685
  clearInterval(heartbeatInterval)
674
686
  unregisterSession(sessionName)
675
687
  // Finalize the session on non-interactive exit paths
676
- // (daemon worker / crash / kill) that bypass saveAndExit. saveAndExit
677
- // already finalizes; guard on !saved to avoid double-evaluating.
688
+ // (daemon worker / crash / kill) that bypass saveAndExit.
678
689
  if (!saved) {
679
690
  try {
680
691
  engine.getAutoMemory().finalizeSession()
681
692
  } catch {
682
693
  // ignore — CRSI closeout is non-critical
683
694
  }
684
- }
685
- if (!saved && context.getMessageCount() > 0) {
686
- SessionStore.autoSave(context.getMessages(), {
687
- provider: defaultProvider,
688
- model: defaultModel,
689
- cwd: process.cwd(),
690
- })
695
+ persistSession()
691
696
  // Distill learnings from the last 5 user messages into memory
692
697
  const allMessages = context.getMessages()
693
698
  const userMessages = allMessages
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.73.0' as const
12
+ export const PACKAGE_VERSION = '0.74.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -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 {} +`
package/src/ui/input.tsx CHANGED
@@ -1,6 +1,5 @@
1
1
  import React, { useState, useEffect, useRef, useMemo } from 'react'
2
2
  import { Box, Text, useInput } from 'ink'
3
- import TextInput from 'ink-text-input'
4
3
  import { getCommandList } from './commands.js'
5
4
  import { CommandPicker } from './command-picker.js'
6
5
  import { useI18n } from '../i18n-context'
@@ -80,11 +79,11 @@ function pick<T>(arr: T[]): T {
80
79
  }
81
80
 
82
81
  /**
83
- * 判断一次输入是否为「批量」(长度跳变 >1:粘贴 / IME 替换)。
84
- * 批量输入需节流防渲染风暴;普通单字符输入应立即显示,无节流延迟。
82
+ * 归一化输入:多行粘贴折叠成单行(CR/LF/Tab → 空格)。
83
+ * ink-text-input 是单行组件,嵌入的 \r 会拉回行首覆盖显示、\n 会 trap 光标。
85
84
  */
86
- export function isBulkInput(prev: string, next: string): boolean {
87
- return Math.abs(next.length - prev.length) > 1
85
+ export function normalizeInput(input: string): string {
86
+ return input.replace(/[\r\n\t]+/g, ' ')
88
87
  }
89
88
 
90
89
  /** True when typing a leading `/` should auto-open the slash-command picker. */
@@ -92,6 +91,81 @@ export function shouldAutoOpenPicker(value: string, prevValue: string, enabled:
92
91
  return enabled && value.startsWith('/') && !prevValue.startsWith('/')
93
92
  }
94
93
 
94
+ /**
95
+ * Mipham 自有的单行文本输入,替代 ink-text-input。
96
+ *
97
+ * 为什么不用 ink-text-input:它把粘贴按「光标偏移切片插入」逐块处理,而
98
+ * cursorOffset 与受控 value 都来自渲染闭包——Ink 会把长粘贴按 stdin read()
99
+ * 边界拆成多块,同一轮 synchronous flush 里后续块读到的仍是旧值,于是「覆盖
100
+ * 前块 / 插到中段」,表现为粘贴内容乱序、丢失、冻住。
101
+ *
102
+ * 这里用 ref 做同步真值:每块按当前 ref 原子追加(光标恒在末尾),不依赖
103
+ * React 渲染时序,分块粘贴自然累积成完整文本。
104
+ */
105
+ function MiphamTextInput({
106
+ value,
107
+ onChange,
108
+ onSubmit,
109
+ placeholder = '',
110
+ focus = true,
111
+ }: {
112
+ value: string
113
+ onChange: (next: string) => void
114
+ onSubmit: (value: string) => void
115
+ placeholder?: string
116
+ focus?: boolean
117
+ }) {
118
+ // 同步真值:valueRef 永远是最新文本;受控 value 仅在渲染时落后于 ref。
119
+ const valueRef = useRef(value)
120
+
121
+ // 外部改动(箭头历史回填、提交清空)时,把真值对齐回受控 prop。
122
+ useEffect(() => {
123
+ valueRef.current = value
124
+ }, [value])
125
+
126
+ useInput(
127
+ (input, key) => {
128
+ if (!focus) return
129
+ // 上行/下行/制表/Ctrl 由 InputBar 自己的 useInput 处理(历史导航、热键)。
130
+ if (key.upArrow || key.downArrow || key.tab || (key.shift && key.tab) || key.ctrl) return
131
+
132
+ if (key.return) {
133
+ onSubmit(valueRef.current)
134
+ return
135
+ }
136
+ if (key.backspace || key.delete) {
137
+ if (valueRef.current.length > 0) {
138
+ const next = valueRef.current.slice(0, -1)
139
+ valueRef.current = next
140
+ onChange(next)
141
+ }
142
+ return
143
+ }
144
+
145
+ // 打字 / 粘贴:归一化控制符后原子追加(光标恒在末尾)。
146
+ const cleaned = normalizeInput(input)
147
+ if (!cleaned) return
148
+ const next = valueRef.current + cleaned
149
+ valueRef.current = next
150
+ onChange(next)
151
+ },
152
+ { isActive: focus },
153
+ )
154
+
155
+ return (
156
+ <Text>
157
+ {value.length === 0 && placeholder ? (
158
+ <Text dimColor>{placeholder}</Text>
159
+ ) : (
160
+ <>
161
+ {value}
162
+ <Text inverse> </Text>
163
+ </>
164
+ )}
165
+ </Text>
166
+ )
167
+ }
168
+
95
169
  export function InputBar({
96
170
  onSubmit,
97
171
  isLoading,
@@ -110,10 +184,7 @@ export function InputBar({
110
184
  const { t } = useI18n()
111
185
  const [value, setValue] = useState('')
112
186
  // Ref mirror of value — used by useInput to read latest without stale closure.
113
- // Also used by throttled onChange to hold the latest pending value.
114
187
  const valueRef = useRef(value)
115
- // Throttle timer for onChange — prevents React render floods during paste.
116
- const onChangeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
117
188
  const [verb, setVerb] = useState(() => t(pick(LOADING_KEYS)))
118
189
  const [completionVerb, setCompletionVerb] = useState<string | null>(null)
119
190
  const prevLoading = useRef(isLoading)
@@ -190,15 +261,6 @@ export function InputBar({
190
261
  prevLoading.current = isLoading
191
262
  }, [isLoading])
192
263
 
193
- // Track pre-shortcut value so we can revert ink-text-input's Ctrl-key insertions.
194
- // ink-text-input inserts 'p'/'f'/'o' for Ctrl+P/F/O (Ink normalizes control chars
195
- // to their letter names before passing to useInput).
196
- const valueBeforeShortcut = useRef(value)
197
- // Keep the ref in sync with state on every change that isn't a Ctrl shortcut revert.
198
- useEffect(() => {
199
- valueBeforeShortcut.current = value
200
- }, [value])
201
-
202
264
  useInput((input, key) => {
203
265
  // ── Escape: cancel loading → clear draft ──
204
266
  if (key.escape) {
@@ -220,9 +282,9 @@ export function InputBar({
220
282
  onCyclePermission?.()
221
283
  return
222
284
  }
223
- // Tab → 接受 ghost-text 建议(复用 Ctrl-key 的 revert 手法)
285
+ // Tab → 接受 ghost-text 建议
224
286
  if (key.tab && !key.shift && suggestion) {
225
- const next = valueBeforeShortcut.current + suggestion
287
+ const next = valueRef.current + suggestion
226
288
  setValue(next)
227
289
  valueRef.current = next
228
290
  setSuggestion(null)
@@ -230,27 +292,22 @@ export function InputBar({
230
292
  }
231
293
  // Ctrl+P → toggle model picker
232
294
  // NOTE: Ink passes input=keypress.name (just 'p') when ctrl is true, not raw \x10.
233
- // ink-text-input inserts 'p' as literal text — revert it.
234
295
  if (key.ctrl && input === 'p') {
235
- setValue(valueBeforeShortcut.current)
236
296
  onTogglePicker?.()
237
297
  return
238
298
  }
239
299
  // Ctrl+F → toggle focus mode
240
300
  if (key.ctrl && input === 'f') {
241
- setValue(valueBeforeShortcut.current)
242
301
  onToggleFocus?.()
243
302
  return
244
303
  }
245
304
  // Ctrl+O → expand/collapse last tool call
246
305
  if (key.ctrl && input === 'o') {
247
- setValue(valueBeforeShortcut.current)
248
306
  onToggleExpand?.()
249
307
  return
250
308
  }
251
309
  // Ctrl+G → toggle agent view dashboard
252
310
  if (key.ctrl && input === 'g') {
253
- setValue(valueBeforeShortcut.current)
254
311
  onToggleAgentView?.()
255
312
  return
256
313
  }
@@ -267,13 +324,7 @@ export function InputBar({
267
324
  if (submittedHistory.length === 0) return
268
325
  // Save current draft the first time we enter history browsing
269
326
  if (historyIndexRef.current === -1) {
270
- // Flush any pending throttle so its deferred setValue doesn't overwrite
271
- // history navigation (paste → immediate ↑ race). Save the latest value
272
- // from the ref — state `value` is stale inside the throttle window.
273
- if (onChangeTimerRef.current) {
274
- clearTimeout(onChangeTimerRef.current)
275
- onChangeTimerRef.current = null
276
- }
327
+ // Save the latest value from the ref — state `value` may lag a render.
277
328
  savedDraftRef.current = valueRef.current
278
329
  }
279
330
  const newIndex = Math.min(historyIndexRef.current + 1, submittedHistory.length - 1)
@@ -319,13 +370,7 @@ export function InputBar({
319
370
  }, [value])
320
371
 
321
372
  const handleSubmit = (val: string) => {
322
- // Flush any pending throttled onChange before submitting
323
- if (onChangeTimerRef.current) {
324
- clearTimeout(onChangeTimerRef.current)
325
- onChangeTimerRef.current = null
326
- }
327
- // Use the latest value from the ref — state lags behind during throttle, and
328
- // the onSubmit `val` is the stale controlled prop in that window.
373
+ // Use the latest value from the ref; `val` is the value passed by MiphamTextInput.
329
374
  const finalValue = valueRef.current || val
330
375
  if (!finalValue.trim()) return
331
376
  // Submitting while a response streams interrupts it (Claude Code parity)
@@ -371,7 +416,7 @@ export function InputBar({
371
416
  <Box marginRight={1}>
372
417
  <Text color={isLoading ? 'yellow' : 'cyan'}>{'>'}</Text>
373
418
  </Box>
374
- <TextInput
419
+ <MiphamTextInput
375
420
  value={value}
376
421
  onChange={(val) => {
377
422
  // Reset history browsing when user starts typing
@@ -379,9 +424,6 @@ export function InputBar({
379
424
  historyIndexRef.current = -1
380
425
  savedDraftRef.current = ''
381
426
  }
382
- // Normalize newlines → spaces. ink-text-input is single-line; multi-line
383
- // paste would trap arrow-key navigation on the first line.
384
- const normalized = val.replace(/\n/g, ' ')
385
427
  // ── Ghost-text 自动补全:每次输入清 suggestion + 重排防抖 ──
386
428
  setSuggestion(null)
387
429
  const suggestionReqId = ++suggestionReqIdRef.current
@@ -389,16 +431,12 @@ export function InputBar({
389
431
  clearTimeout(suggestionTimerRef.current)
390
432
  suggestionTimerRef.current = null
391
433
  }
392
- if (
393
- llm &&
394
- autocompleteEnabled &&
395
- shouldAutocomplete(normalized, isLoading, pickerActive)
396
- ) {
434
+ if (llm && autocompleteEnabled && shouldAutocomplete(val, isLoading, pickerActive)) {
397
435
  suggestionTimerRef.current = setTimeout(() => {
398
436
  requestSuggestion(
399
437
  llm,
400
438
  recentMessages ?? [],
401
- normalized,
439
+ val,
402
440
  () => suggestionReqId !== suggestionReqIdRef.current,
403
441
  )
404
442
  .then((completion) => {
@@ -409,24 +447,8 @@ export function InputBar({
409
447
  })
410
448
  }, autocompleteDebounceMs)
411
449
  }
412
- // 批量输入(paste/IME 替换)节流防渲染风暴;普通单字符输入立即显示,
413
- // 避免 33ms trailing 的「慢半拍」尾巴。
414
- const bulk = isBulkInput(valueRef.current, normalized)
415
- valueRef.current = normalized
416
- if (!bulk) {
417
- if (onChangeTimerRef.current) {
418
- clearTimeout(onChangeTimerRef.current)
419
- onChangeTimerRef.current = null
420
- }
421
- setValue(normalized)
422
- return
423
- }
424
- if (onChangeTimerRef.current) return // timer pending, latest value in ref
425
- setValue(normalized)
426
- onChangeTimerRef.current = setTimeout(() => {
427
- onChangeTimerRef.current = null
428
- setValue(valueRef.current)
429
- }, 33) // ~30fps
450
+ valueRef.current = val
451
+ setValue(val)
430
452
  }}
431
453
  onSubmit={handleSubmit}
432
454
  placeholder={