@luchenglong/cli-bridge 0.2.9 → 0.2.10

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.
@@ -1,111 +1,270 @@
1
- // providers/mimo-pure.mjs — mimo 的纯解析/结果构造(无宿主依赖)。
2
- //
3
- // 从 providers/mimo.mjs 拆出,让两个文件都留在 600 行红线内(规则 §26)。
4
- // build 会把 providers/mimo-*.mjs 全部内联进 dynamic 沙箱同一作用域,故主文件
5
- // 仍可直接调用这些函数;ESM 形态走本文件的 import。
6
- // 注意:注释里绝不写出口令标记字面量(build 全局替换会把注释劈开)。
7
-
8
- import { shortLabel, summarizeArgs } from '../core/bridge-kit.mjs'
9
-
10
- export function parseMimoJson(stdoutText) {
11
- const trimmed = String(stdoutText || '').trim()
12
- if (!trimmed) return null
13
- const events = []
14
- const lines = trimmed.split(/\r?\n/)
15
- for (const raw of lines) {
16
- const ln = raw.trim()
17
- if (!ln.startsWith('{')) continue
18
- try {
19
- const obj = JSON.parse(ln)
20
- if (obj && typeof obj === 'object') events.push(obj)
21
- } catch (e) { /* tolerate log lines */ }
22
- }
23
- if (!events.length) {
24
- try {
25
- const obj = JSON.parse(trimmed)
26
- if (obj && typeof obj === 'object') events.push(obj)
27
- } catch (e) {
28
- return { events: [], text: trimmed, sessionId: null, error: null, steps: [] }
29
- }
30
- }
31
- let sessionId = null
32
- let text = ''
33
- let errorMessage = null
34
- let errorName = null
35
- let numTurns = 0
36
- let totalTokens = null
37
- const steps = []
38
- const textParts = []
39
- for (const ev of events) {
40
- if (ev.sessionID && typeof ev.sessionID === 'string') sessionId = ev.sessionID
41
- if (ev.sessionId && typeof ev.sessionId === 'string') sessionId = ev.sessionId
42
- if (ev.session && typeof ev.session === 'object' && ev.session.id) sessionId = ev.session.id
43
- if (ev.type === 'session' && ev.properties && ev.properties.id) sessionId = ev.properties.id
44
- const t = ev.type
45
- const p = ev.properties || ev
46
- if (t === 'error') {
47
- const err = ev.error || p
48
- errorMessage = (err.data && err.data.message) || err.message || err.name || 'error'
49
- errorName = err.name || 'Error'
50
- } else if (t === 'text' || t === 'message' || t === 'assistant' || t === 'output') {
51
- const chunk = p.text || p.content || p.message || p.output || (typeof ev.text === 'string' ? ev.text : '')
52
- if (typeof chunk === 'string' && chunk) {
53
- textParts.push(chunk)
54
- steps.push({ at: Date.now(), kind: 'text', label: shortLabel(chunk) })
55
- }
56
- } else if (t === 'step-start' || t === 'step-finish' || t === 'step_update') {
57
- numTurns += 1
58
- if (p.tokens && typeof p.tokens === 'object') {
59
- const tt = p.tokens.total ?? p.tokens.input ?? p.tokens.output
60
- if (typeof tt === 'number') totalTokens = (totalTokens || 0) + tt
61
- }
62
- if (t === 'step_update') {
63
- const name = p.tool_name || p.tool || p.name
64
- if (name) steps.push({ at: Date.now(), kind: 'tool', label: shortLabel(name + ' ' + JSON.stringify(summarizeArgs(p.arguments || p.args || p.input) || {})) })
65
- }
66
- } else if (t === 'tool' || t === 'tool-call' || t === 'tool_use') {
67
- const name = p.tool || p.name || p.tool_name || 'tool'
68
- steps.push({ at: Date.now(), kind: 'tool', label: shortLabel(name + ' ' + JSON.stringify(summarizeArgs(p.arguments || p.args || p.input) || {})) })
69
- }
70
- }
71
- if (textParts.length) text = textParts.join('\n')
72
- else {
73
- const last = events[events.length - 1] || {}
74
- const r = last.result || last
75
- if (typeof r.response === 'string') text = r.response
76
- else if (typeof r.text === 'string') text = r.text
77
- }
78
- return { events, text, sessionId, error: errorMessage, errorName, numTurns, totalTokens, steps }
79
- }
80
-
81
- export function buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
82
- const MAX_TRAIL = 12
83
- const exitCode = outcome ? outcome.exitCode : null
84
- const errText = (parsed && parsed.error) ? String(parsed.error) : ''
85
- const stderr = (stderrText ? String(stderrText).slice(-2000) : '') + (errText ? (stderrText ? ' ' : '') + errText : '')
86
- const text = parsed && typeof parsed.text === 'string' ? parsed.text : ''
87
- const failed = exitCode !== 0 || !!(parsed && parsed.error)
88
- const be = (parsed && parsed.backend) || (outcome && outcome.backend) || backend || null
89
- if (parsed) {
90
- return {
91
- ok: !failed && (!!text || parsed.accepted === true),
92
- status: failed ? (parsed.errorName || 'ERROR') : (parsed.accepted && !text ? 'ACCEPTED' : (text ? 'SUCCESS' : 'EMPTY')),
93
- response: text,
94
- sessionId: parsed.sessionId || null,
95
- durationSeconds: null,
96
- numTurns: parsed.numTurns || null,
97
- totalTokens: parsed.totalTokens || null,
98
- exitCode, mode, backend: be,
99
- stderr,
100
- errorMessage: parsed.error || '',
101
- accepted: parsed.accepted === true,
102
- steps: Array.isArray(parsed.steps) ? parsed.steps.slice(-MAX_TRAIL) : []
103
- }
104
- }
105
- return {
106
- ok: false, status: 'PARSE_ERROR', response: '', sessionId: null,
107
- durationSeconds: null, numTurns: null, totalTokens: null,
108
- exitCode, mode, backend: be, stderr, errorMessage: '',
109
- rawStdout: String(stdoutText || '').slice(-2000)
110
- }
111
- }
1
+ // providers/mimo-pure.mjs — mimo 的纯逻辑片文件(无宿主依赖):事件解析 / 结果构造 +
2
+ // 「怎么问本机 mimo 要模型清单」链路。
3
+ //
4
+ // 从 providers/mimo.mjs 拆出,让两个文件都留在 600 行红线内(规则 §26)。
5
+ // build 会把 providers/mimo-*.mjs 全部内联进 dynamic 沙箱同一作用域,故主文件
6
+ // 仍可直接调用这些函数;ESM 形态走本文件的 import。
7
+ // 沙箱纪律:禁用 import.meta 字面量;fs/path/child_process 一律经
8
+ // globalThis.process.getBuiltinModule + typeof 守卫读取(沙箱可能没有 node 能力)。
9
+ // 注意:注释里绝不写出口令标记字面量(build 全局替换会把注释劈开)。
10
+
11
+ import { shortLabel, summarizeArgs, clampInt } from '../core/bridge-kit.mjs'
12
+
13
+ // Node 内建探测(沙箱纪律:getBuiltinModule + typeof 守卫)。
14
+ // 名字带 mimo 前缀:本文件会与 bridge-kit / core/scheduling.mjs【文本内联进同一个动态
15
+ // 沙箱脚本作用域】,撞名即「Identifier already declared」构建错误。
16
+ function mimoBuiltin(name) {
17
+ try {
18
+ const proc = globalThis.process
19
+ return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
20
+ } catch (e) { return null }
21
+ }
22
+
23
+ // ── 可执行文件解析(env MIMO_BIN/MIMO_CLI > 已知安装位置 > PATH 名字)───────────
24
+ // MiMoCode CLI 官方便携安装目录:<USERPROFILE|HOME>/.mimocode/bin/mimo[.exe]。
25
+ // 沙箱内可能没有 fs —— 全部读取走 typeof 守卫,读不到就返回空数组(退回 PATH 查找)。
26
+ const MIMO_EXE_DIR = ['.mimocode', 'bin']
27
+
28
+ /** 探测本机 mimo 可执行文件候选路径(官方便携安装位置);无 fs 能力时返回空数组。 */
29
+ export function mimoCandidateExePaths(env) {
30
+ const e = env || {}
31
+ const out = []
32
+ const fs = mimoBuiltin('node:fs')
33
+ if (!fs || typeof fs.existsSync !== 'function') return out
34
+ const path = mimoBuiltin('node:path')
35
+ const join = (path && typeof path.join === 'function') ? ((a, b) => path.join(a, b)) : ((a, b) => a + '/' + b)
36
+ for (const k of ['USERPROFILE', 'HOME']) {
37
+ if (!e[k]) continue
38
+ const dir = join(join(String(e[k]), MIMO_EXE_DIR[0]), MIMO_EXE_DIR[1])
39
+ for (const base of ['mimo.exe', 'mimo']) {
40
+ const exe = join(dir, base)
41
+ try { if (fs.existsSync(exe) && out.indexOf(exe) < 0) out.push(exe) } catch (err) { /* 探测失败静默跳过 */ }
42
+ }
43
+ }
44
+ return out
45
+ }
46
+
47
+ /**
48
+ * 解析本次调用使用的 exe 前缀(数组或字符串)。
49
+ * 优先级:binPath(显式路径)> env MIMO_BIN/MIMO_CLI > 已知安装位置 > PATH 名字。
50
+ */
51
+ export function resolveMimoExePrefix(env, binPath) {
52
+ const e = env || {}
53
+ const bin = binPath || e.MIMO_BIN || e.MIMO_CLI || ''
54
+ if (bin) {
55
+ if (/\.(mjs|cjs|js)$/i.test(bin)) {
56
+ const proc = globalThis.process
57
+ return [(proc && proc.execPath) ? proc.execPath : 'node', bin]
58
+ }
59
+ return [String(bin)]
60
+ }
61
+ const found = mimoCandidateExePaths(e)
62
+ return found.length ? [found[0]] : ['mimo']
63
+ }
64
+
65
+ // ── 模型列表(`mimo models`;设置面板下拉用)───────────────────────────────────
66
+ // 实测输出形如每行 `provider/model — window 1M, compacts at 900K`(分隔符是中文破折号
67
+ // U+2014)。模型名自身含连字符(xiaomi/mimo-v2.6-pro-ultraspeed),所以只认「行首的
68
+ // provider/model」,其后一律当描述文本;表头/说明/空行一律丢弃,绝不抛。
69
+ const MIMO_MODEL_ID_RE = /^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.@#+-]+)(?=$|[\s—–:|,])/
70
+
71
+ /** 解析 `mimo models` 文本输出为 [{id, provider, name, note}](去重、保序)。 */
72
+ export function parseMimoModels(text) {
73
+ const out = []
74
+ const seen = Object.create(null)
75
+ for (const raw of String(text || '').split(/\r?\n/)) {
76
+ // 去掉列表记号(• * - 1. 等)与行首空白,避免把记号当 provider 名。
77
+ const ln = raw.replace(/^[^A-Za-z0-9]+/, '').trim()
78
+ if (!ln) continue
79
+ const m = ln.match(MIMO_MODEL_ID_RE)
80
+ if (!m) continue
81
+ const id = m[1]
82
+ if (seen[id]) continue
83
+ seen[id] = true
84
+ const slash = id.indexOf('/')
85
+ out.push({ id: id, provider: id.slice(0, slash), name: id.slice(slash + 1), note: ln.slice(id.length).replace(/^[\s—–:|,-]+/, '').trim() })
86
+ }
87
+ return out
88
+ }
89
+
90
+ // 诊断文本压成一行(CLI 的 stderr/stdout 常带换行与表格)。
91
+ function mimoOneLine(text, max) {
92
+ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim()
93
+ const cap = max || 200
94
+ return s.length > cap ? s.slice(0, cap) + '…' : s
95
+ }
96
+
97
+ /**
98
+ * 真正跑一次 `mimo models`,**带可操作失败原因**(契约 §3.2:失败必须 ok:false)。
99
+ *
100
+ * 为什么不沿用「失败返回空数组」:空数组区分不了「没装 CLI」「没登录 / 免费 API 已终止」
101
+ * 「超时」「输出格式变了」,而面板与用户下一步动作完全不同。
102
+ * @returns {{ok:boolean, models:Array, exe:string, error?:string}}
103
+ */
104
+ export function fetchMimoModelsDetailed(env, timeoutMs) {
105
+ const prefix = resolveMimoExePrefix(env)
106
+ const exe = prefix.join(' ')
107
+ const child = mimoBuiltin('node:child_process')
108
+ if (!child || typeof child.spawnSync !== 'function') {
109
+ return { ok: false, models: [], exe: exe, error: '当前宿主没有子进程能力(spawnSync 不可用)—— 请在有 Node 子进程能力的形态(DSH 主进程 / MCP 进程)里点「拉取可用模型」' }
110
+ }
111
+ let r = null
112
+ try {
113
+ r = child.spawnSync(prefix[0], prefix.slice(1).concat(['models']), {
114
+ encoding: 'utf8', timeout: clampInt(timeoutMs, 20000, 2000, 60000),
115
+ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe']
116
+ })
117
+ } catch (e) {
118
+ return { ok: false, models: [], exe: exe, error: '启动 `' + exe + ' models` 失败:' + mimoOneLine(e && e.message || e) }
119
+ }
120
+ if (!r) return { ok: false, models: [], exe: exe, error: '`' + exe + ' models` 没有任何返回(spawnSync 返回 null),请确认可执行文件路径' }
121
+ if (r.error) {
122
+ const code = String(r.error.code || '')
123
+ if (code === 'ENOENT') {
124
+ return { ok: false, models: [], exe: exe, error: '未找到 mimo 可执行文件(' + exe + ')—— 请安装 MiMoCode CLI(官方便携安装位置 ~/.mimocode/bin/mimo.exe),或设置 MIMO_BIN 指向其绝对路径' }
125
+ }
126
+ if (code === 'ETIMEDOUT' || code === 'ABORT_ERR') {
127
+ return { ok: false, models: [], exe: exe, error: '`' + exe + ' models` 超时 —— 网络或鉴权可能卡住:请先在终端手动跑一次 `mimo models` 确认,必要时执行 `mimo auth login`' }
128
+ }
129
+ return { ok: false, models: [], exe: exe, error: '`' + exe + ' models` 启动失败(' + (code || 'unknown') + '):' + mimoOneLine(r.error.message || r.error) }
130
+ }
131
+ const stdout = String(r.stdout || '')
132
+ const stderr = String(r.stderr || '')
133
+ const models = parseMimoModels(stdout)
134
+ if (models.length) return { ok: true, models: models, exe: exe }
135
+ if (r.status !== 0) {
136
+ return { ok: false, models: [], exe: exe, error: '`mimo models` 退出码 ' + r.status + ':' + mimoOneLine(stderr || stdout) + ' —— 若提示未登录 / 免费 API 已终止,请先在终端执行 `mimo auth login`' }
137
+ }
138
+ return { ok: false, models: [], exe: exe, error: '`mimo models` 成功退出但没有解析到任何 provider/model 行' + (stdout ? '(输出开头:' + mimoOneLine(stdout, 160) + ')' : '(输出为空)') + ' —— 请先在终端手动跑一次 `mimo models` 确认输出格式与登录状态' }
139
+ }
140
+
141
+ /** 兼容薄封装:失败返回空数组(旧语义,供不关心原因的调用方)。 */
142
+ export function fetchMimoModelsSync(env, timeoutMs) {
143
+ const d = fetchMimoModelsDetailed(env, timeoutMs)
144
+ return d.ok ? d.models : []
145
+ }
146
+
147
+ // ── 手动拉取缓存(契约 §3.2)─────────────────────────────────────────────────
148
+ // `meta()` 每次面板渲染都会被调用:**只读本缓存,绝不 spawn**。缓存只存在进程内存里
149
+ // (宿主进程重启即失效,不会把过时清单落盘成"配置");用户点按钮才刷新它。
150
+ const MIMO_MODEL_CACHE_TTL_MS = 6 * 60 * 60 * 1000
151
+ const MIMO_MODEL_CACHE = { at: 0, models: [] }
152
+
153
+ /** 读缓存(未拉过 / 已过期 → 空数组,面板据此提示「点按钮拉取」)。 */
154
+ export function cachedMimoModels(nowMs) {
155
+ const at = Number(nowMs) || Date.now()
156
+ if (!MIMO_MODEL_CACHE.models.length) return []
157
+ if (at - MIMO_MODEL_CACHE.at >= MIMO_MODEL_CACHE_TTL_MS) return []
158
+ return MIMO_MODEL_CACHE.models.slice()
159
+ }
160
+
161
+ /** 手动拉取成功后回填缓存;空清单绝不入缓存(否则下次渲染"有缓存但没内容")。 */
162
+ export function rememberMimoModels(models, nowMs) {
163
+ if (!Array.isArray(models) || !models.length) return false
164
+ MIMO_MODEL_CACHE.models = models.slice()
165
+ MIMO_MODEL_CACHE.at = Number(nowMs) || Date.now()
166
+ return true
167
+ }
168
+
169
+ export function parseMimoJson(stdoutText) {
170
+ const trimmed = String(stdoutText || '').trim()
171
+ if (!trimmed) return null
172
+ const events = []
173
+ const lines = trimmed.split(/\r?\n/)
174
+ for (const raw of lines) {
175
+ const ln = raw.trim()
176
+ if (!ln.startsWith('{')) continue
177
+ try {
178
+ const obj = JSON.parse(ln)
179
+ if (obj && typeof obj === 'object') events.push(obj)
180
+ } catch (e) { /* tolerate log lines */ }
181
+ }
182
+ if (!events.length) {
183
+ try {
184
+ const obj = JSON.parse(trimmed)
185
+ if (obj && typeof obj === 'object') events.push(obj)
186
+ } catch (e) {
187
+ return { events: [], text: trimmed, sessionId: null, error: null, steps: [] }
188
+ }
189
+ }
190
+ let sessionId = null
191
+ let text = ''
192
+ let errorMessage = null
193
+ let errorName = null
194
+ let numTurns = 0
195
+ let totalTokens = null
196
+ const steps = []
197
+ const textParts = []
198
+ for (const ev of events) {
199
+ if (ev.sessionID && typeof ev.sessionID === 'string') sessionId = ev.sessionID
200
+ if (ev.sessionId && typeof ev.sessionId === 'string') sessionId = ev.sessionId
201
+ if (ev.session && typeof ev.session === 'object' && ev.session.id) sessionId = ev.session.id
202
+ if (ev.type === 'session' && ev.properties && ev.properties.id) sessionId = ev.properties.id
203
+ const t = ev.type
204
+ const p = ev.properties || ev
205
+ if (t === 'error') {
206
+ const err = ev.error || p
207
+ errorMessage = (err.data && err.data.message) || err.message || err.name || 'error'
208
+ errorName = err.name || 'Error'
209
+ } else if (t === 'text' || t === 'message' || t === 'assistant' || t === 'output') {
210
+ const chunk = p.text || p.content || p.message || p.output || (typeof ev.text === 'string' ? ev.text : '')
211
+ if (typeof chunk === 'string' && chunk) {
212
+ textParts.push(chunk)
213
+ steps.push({ at: Date.now(), kind: 'text', label: shortLabel(chunk) })
214
+ }
215
+ } else if (t === 'step-start' || t === 'step-finish' || t === 'step_update') {
216
+ numTurns += 1
217
+ if (p.tokens && typeof p.tokens === 'object') {
218
+ const tt = p.tokens.total ?? p.tokens.input ?? p.tokens.output
219
+ if (typeof tt === 'number') totalTokens = (totalTokens || 0) + tt
220
+ }
221
+ if (t === 'step_update') {
222
+ const name = p.tool_name || p.tool || p.name
223
+ if (name) steps.push({ at: Date.now(), kind: 'tool', label: shortLabel(name + ' ' + JSON.stringify(summarizeArgs(p.arguments || p.args || p.input) || {})) })
224
+ }
225
+ } else if (t === 'tool' || t === 'tool-call' || t === 'tool_use') {
226
+ const name = p.tool || p.name || p.tool_name || 'tool'
227
+ steps.push({ at: Date.now(), kind: 'tool', label: shortLabel(name + ' ' + JSON.stringify(summarizeArgs(p.arguments || p.args || p.input) || {})) })
228
+ }
229
+ }
230
+ if (textParts.length) text = textParts.join('\n')
231
+ else {
232
+ const last = events[events.length - 1] || {}
233
+ const r = last.result || last
234
+ if (typeof r.response === 'string') text = r.response
235
+ else if (typeof r.text === 'string') text = r.text
236
+ }
237
+ return { events, text, sessionId, error: errorMessage, errorName, numTurns, totalTokens, steps }
238
+ }
239
+
240
+ export function buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
241
+ const MAX_TRAIL = 12
242
+ const exitCode = outcome ? outcome.exitCode : null
243
+ const errText = (parsed && parsed.error) ? String(parsed.error) : ''
244
+ const stderr = (stderrText ? String(stderrText).slice(-2000) : '') + (errText ? (stderrText ? ' ' : '') + errText : '')
245
+ const text = parsed && typeof parsed.text === 'string' ? parsed.text : ''
246
+ const failed = exitCode !== 0 || !!(parsed && parsed.error)
247
+ const be = (parsed && parsed.backend) || (outcome && outcome.backend) || backend || null
248
+ if (parsed) {
249
+ return {
250
+ ok: !failed && (!!text || parsed.accepted === true),
251
+ status: failed ? (parsed.errorName || 'ERROR') : (parsed.accepted && !text ? 'ACCEPTED' : (text ? 'SUCCESS' : 'EMPTY')),
252
+ response: text,
253
+ sessionId: parsed.sessionId || null,
254
+ durationSeconds: null,
255
+ numTurns: parsed.numTurns || null,
256
+ totalTokens: parsed.totalTokens || null,
257
+ exitCode, mode, backend: be,
258
+ stderr,
259
+ errorMessage: parsed.error || '',
260
+ accepted: parsed.accepted === true,
261
+ steps: Array.isArray(parsed.steps) ? parsed.steps.slice(-MAX_TRAIL) : []
262
+ }
263
+ }
264
+ return {
265
+ ok: false, status: 'PARSE_ERROR', response: '', sessionId: null,
266
+ durationSeconds: null, numTurns: null, totalTokens: null,
267
+ exitCode, mode, backend: be, stderr, errorMessage: '',
268
+ rawStdout: String(stdoutText || '').slice(-2000)
269
+ }
270
+ }