mocode-ai 1.5.8 → 1.5.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.
- package/dist/agent/model-turn.js +10 -1
- package/dist/agent/run-coordinator.js +93 -1
- package/dist/agent/stages/history-manager.js +13 -0
- package/dist/agent/stages/tool-dispatcher.js +66 -1
- package/dist/agent/tool-helpers.js +13 -1
- package/dist/agent/tool-turn.js +7 -1
- package/dist/config/index.js +107 -7
- package/dist/context/vision-window.js +220 -0
- package/dist/i18n/index.js +42 -0
- package/dist/llm/providers/anthropic.js +37 -5
- package/dist/repl/commands/registry.js +2 -0
- package/dist/repl/commands/router.js +141 -0
- package/dist/repl/commands/tool-group.js +33 -1
- package/dist/repl/commands.js +11 -1
- package/dist/repl/runtime.js +14 -15
- package/dist/session/compact.js +4 -0
- package/dist/session/gui-actions.js +164 -0
- package/dist/tools/builtins/index.js +4 -1
- package/dist/tools/builtins/read-file.js +3 -0
- package/dist/tools/constants.js +11 -1
- package/dist/tools/jev-client.js +192 -0
- package/dist/tools/router.js +85 -2
- package/dist/ui/layout-internal/content-write.js +70 -4
- package/dist/ui/layout-internal/state.js +3 -0
- package/package.json +1 -1
package/dist/session/compact.js
CHANGED
|
@@ -531,6 +531,10 @@ async function defaultSummarize(older, focus, signal, runtime = defaultCompactio
|
|
|
531
531
|
'## Key Facts — only what later steps cannot work without: exact paths, symbols/API shapes, artifact IDs/hashes, open questions, failed approaches and errors to avoid repeating. User requests and constraints are pinned verbatim by the system from the raw user messages — do not duplicate them here; you own the technical facts.\n' +
|
|
532
532
|
'What counts as important (keep): user requests and constraints; final state of each modified file; conclusions and results, not the steps that led there; decisions with reasons; precise references (paths, symbols, hashes, commands) that later steps must cite; failures and what was tried, so mistakes are not repeated.\n' +
|
|
533
533
|
'What to drop: verbatim file contents and tool-output dumps, step-by-step recaps, exploration dead-ends that led nowhere, polite chatter, anything re-derivable by re-reading files.\n' +
|
|
534
|
+
// GUI 例外(§10.6):GUI 任务里动作序列本身就是状态——桌面上没有可回查的落点,压成散文
|
|
535
|
+
// 会导致'填过的字段被重填、点过的按钮被重点'。这条是依赖模型守规矩的兜底,硬保证是 L2
|
|
536
|
+
// 台账(gui-actions.log,每步写盘、跨 compact 存活)。
|
|
537
|
+
'GUI EXCEPTION to "step-by-step recaps": for computer/screen-control actions keep the SEQUENCE as an ordered list — one line per action: `N. <action> (<coordinates or target>) → <observed result, e.g. no visible change / re-captured diff X%>`. Never collapse it into prose ("clicked around the form") and never drop which attempts produced no visible change.\n' +
|
|
534
538
|
'Rules: total ≤ 400 words. State conclusions and locations (path:line where useful), never paste content. ' +
|
|
535
539
|
'The Key Facts section is carried forward verbatim into future summaries: write each fact as one self-contained line ' +
|
|
536
540
|
'(no pronouns, no "as above", no cross-references), so it still reads correctly after later compactions. ' +
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// session/gui-actions.ts - GUI 动作台账(L2)的存储与格式化。
|
|
2
|
+
//
|
|
3
|
+
// 与 notes.md 平级的**独立文件** `gui-actions.log`,每步一个 computer 动作追加一行,
|
|
4
|
+
// 只保留最近 N 条。它回答的是"我做过什么 / 做到哪",与视觉窗口(最新几帧)互补:
|
|
5
|
+
// 像素是空间轴(旧图快速贬值),台账是时间轴(第 3 步点过什么、哪次没生效,10 步后仍有价值)。
|
|
6
|
+
//
|
|
7
|
+
// 为什么是独立文件而不是写进 notes.md:notes.md 的 mtime 在本仓是**被监听的信号**
|
|
8
|
+
// (tool-turn.ts 前后比对 getNotesMtime(),变了就清零 stepsSincePlanTouch),台账每步都写
|
|
9
|
+
// 会让 plan nag 永不触发;且 note_append 走 ResourceLockManager 的 session-notepad 串行锁,
|
|
10
|
+
// 每步争锁没必要。详见 design-notes/vision-window.md §10.3。
|
|
11
|
+
//
|
|
12
|
+
// 契约:
|
|
13
|
+
// - 写入失败**静默**:台账是可观测性,绝不能反过来影响 computer 动作的结果;
|
|
14
|
+
// - 顺序为追加语义,行首序号单调递增且在滚动裁剪后保持不变(§10.8 风险 3);
|
|
15
|
+
// - 语义纪律:记"意图 + 观察",不记"已达成"——"no visible change" 不升级成"成功"。
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { getSandboxRoot } from '../sandbox/root.js';
|
|
19
|
+
import { getCurrentSessionId } from './state.js';
|
|
20
|
+
/** 台账文件名:与 notes.md 同目录(会话目录)。 */
|
|
21
|
+
export const GUI_ACTIONS_FILENAME = 'gui-actions.log';
|
|
22
|
+
/** 台账上界:只保留最近 N 条。24 ≈ 一个表单级任务的动作量,且与注入 token 预算匹配。 */
|
|
23
|
+
export const GUI_ACTIONS_MAX_ENTRIES = 24;
|
|
24
|
+
/** 一行台账的意图列宽(含对齐空格);超长不裁剪,仅退化为一个空格分隔。 */
|
|
25
|
+
const INTENT_COLUMN = 26;
|
|
26
|
+
/** 单条观察/错误文本上限:异常 output(栈、巨大 HTML)不该把一个 Monitor 动作撑成一屏。 */
|
|
27
|
+
const DETAIL_LIMIT = 80;
|
|
28
|
+
/** 注入时的段标题。 */
|
|
29
|
+
const SECTION_HEADING = '## GUI actions (most recent last; from the live action ledger)';
|
|
30
|
+
/**
|
|
31
|
+
* 注入时的纪律说明。§10.5 的硬约束:台账永远不能覆盖像素。
|
|
32
|
+
* 本实现不给条目加显式状态位(用户决策:沿用既有措辞),由这段说明统一给出冲突时的解法——
|
|
33
|
+
* 台账说"点过了"而屏幕没变化 → 以屏幕为准,该动作视为 attempted, unverified。
|
|
34
|
+
*/
|
|
35
|
+
const DISCIPLINE_LINE = 'These lines record intent + observation, never confirmed success: if the current screenshot disagrees with a line, the screenshot wins and that line counts as attempted but unverified.';
|
|
36
|
+
/** 当前会话 gui-actions.log 的绝对路径;无会话时返 null。 */
|
|
37
|
+
export function getGuiActionsFilePath(sessionId = getCurrentSessionId()) {
|
|
38
|
+
if (!sessionId)
|
|
39
|
+
return null;
|
|
40
|
+
const root = getSandboxRoot() ?? process.cwd();
|
|
41
|
+
return path.join(root, '.mocode', 'sessions', sessionId, GUI_ACTIONS_FILENAME);
|
|
42
|
+
}
|
|
43
|
+
function truncateDetail(raw) {
|
|
44
|
+
const flat = raw.replace(/\s+/g, ' ').trim();
|
|
45
|
+
return flat.length > DETAIL_LIMIT ? `${flat.slice(0, DETAIL_LIMIT)}…` : flat;
|
|
46
|
+
}
|
|
47
|
+
/** `left_click at (512, 300)` → `left_click (512, 300)`;`typed 18 characters` → `type 18 chars`。 */
|
|
48
|
+
function normalizeIntent(summary) {
|
|
49
|
+
let text = summary.replace(/\s+/g, ' ').trim();
|
|
50
|
+
// 顺序敏感:先处理带自身句式的动作,最后才是泛化的 "<action> at (x, y)"。
|
|
51
|
+
text = text.replace(/^moved cursor to (\(.*\))$/, 'mouse_move $1');
|
|
52
|
+
text = text.replace(/^typed ([\d.]+) characters$/, 'type $1 chars');
|
|
53
|
+
text = text.replace(/^pressed key combo (".*")$/, 'key $1');
|
|
54
|
+
text = text.replace(/^dragged from (\([^)]*\)) to (\([^)]*\))$/, 'drag $1 -> $2');
|
|
55
|
+
text = text.replace(/^scrolled (\w+) by ([\d.]+) at (\(.*\))$/, 'scroll $1 $2 $3');
|
|
56
|
+
text = text.replace(/^waited ([\d.]+)ms$/, 'wait $1ms');
|
|
57
|
+
text = text.replace(/^cursor at physical \([^)]*\) = normalized (\(.*\))$/, 'cursor_position $1');
|
|
58
|
+
text = text.replace(/^(\w+) at (\(.*\))$/, '$1 $2');
|
|
59
|
+
return text;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 把 computer 工具的 output 解析成一条台账的"意图 + 观察"。
|
|
63
|
+
*
|
|
64
|
+
* 素材是 `src/tools/builtins/computer.ts` 已有的输出文本(existing per-action summary +
|
|
65
|
+
* 差分命中/回灌后缀),因此不需要新开采集通道。解析不出意图时返 null(调用方跳过)。
|
|
66
|
+
*/
|
|
67
|
+
export function parseComputerOutput(output) {
|
|
68
|
+
const text = (output ?? '').trim();
|
|
69
|
+
if (!text)
|
|
70
|
+
return null;
|
|
71
|
+
if (text.startsWith('computer action failed:')) {
|
|
72
|
+
return { intent: 'failed', observation: `error: ${truncateDetail(text.slice('computer action failed:'.length))}` };
|
|
73
|
+
}
|
|
74
|
+
if (text.startsWith('Zoomed into region ')) {
|
|
75
|
+
// zoom 会作废差分基准(computer.ts 置 lastFrame = null),因此必带新图、且只含局部区域。
|
|
76
|
+
return { intent: 'zoom', observation: 're-captured (zoomed region only)' };
|
|
77
|
+
}
|
|
78
|
+
if (text.startsWith('Screenshot captured (')) {
|
|
79
|
+
return { intent: 'screenshot', observation: 're-captured' };
|
|
80
|
+
}
|
|
81
|
+
const diffMatch = /\.\s*No visible change on screen \(frame diff ([\d.]+)%/.exec(text);
|
|
82
|
+
if (diffMatch) {
|
|
83
|
+
// 「no visible change」即"未达成"的现成措辞:保留原措辞,不升级成任何成功断言(§10.5)。
|
|
84
|
+
return {
|
|
85
|
+
intent: normalizeIntent(text.slice(0, diffMatch.index)),
|
|
86
|
+
observation: `no visible change, diff ${diffMatch[1]}%`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const recapture = /\.\s*Screen re-captured \(/.exec(text);
|
|
90
|
+
if (recapture) {
|
|
91
|
+
return { intent: normalizeIntent(text.slice(0, recapture.index)), observation: 're-captured' };
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/** 渲染一行台账;序号用于跨 compact / 跨裁剪保持"第几步"的可追溯性。 */
|
|
96
|
+
export function formatGuiActionLine(seq, entry) {
|
|
97
|
+
const pad = ' '.repeat(Math.max(1, INTENT_COLUMN - entry.intent.length));
|
|
98
|
+
return `${seq}. ${entry.intent}${pad}→ ${entry.observation}`;
|
|
99
|
+
}
|
|
100
|
+
/** 读台账原文行(已含序号);文件缺失/不可读返空数组。 */
|
|
101
|
+
export function readGuiActionEntries(sessionId = getCurrentSessionId()) {
|
|
102
|
+
const filePath = getGuiActionsFilePath(sessionId);
|
|
103
|
+
if (!filePath)
|
|
104
|
+
return [];
|
|
105
|
+
try {
|
|
106
|
+
return fs
|
|
107
|
+
.readFileSync(filePath, 'utf8')
|
|
108
|
+
.split('\n')
|
|
109
|
+
.map((line) => line.replace(/\r$/, ''))
|
|
110
|
+
.filter((line) => line.trim().length > 0);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** 从既有行里取序号:滚动裁剪后序号必须继续递增,不能因为丢行而回退。 */
|
|
117
|
+
function nextSequence(lines) {
|
|
118
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
119
|
+
const match = /^(\d+)\.\s/.exec(lines[i]);
|
|
120
|
+
if (match)
|
|
121
|
+
return Number(match[1]) + 1;
|
|
122
|
+
}
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 追加一条 computer 动作到台账。**失败静默**:写盘失败(只读目录 / ENOTDIR / 权限)不影响
|
|
127
|
+
* 动作本身,台账是可观测性而非功能依赖。
|
|
128
|
+
*
|
|
129
|
+
* @returns 写入后的行数(失败时为 0)。
|
|
130
|
+
*/
|
|
131
|
+
export function appendGuiAction(output, sessionId = getCurrentSessionId()) {
|
|
132
|
+
const entry = parseComputerOutput(output);
|
|
133
|
+
if (!entry)
|
|
134
|
+
return 0;
|
|
135
|
+
const filePath = getGuiActionsFilePath(sessionId);
|
|
136
|
+
if (!filePath)
|
|
137
|
+
return 0;
|
|
138
|
+
try {
|
|
139
|
+
const previous = readGuiActionEntries(sessionId);
|
|
140
|
+
const line = formatGuiActionLine(nextSequence(previous), entry);
|
|
141
|
+
const kept = [...previous, line].slice(-GUI_ACTIONS_MAX_ENTRIES);
|
|
142
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
143
|
+
fs.writeFileSync(filePath, `${kept.join('\n')}\n`, 'utf8');
|
|
144
|
+
return kept.length;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// 台账不可写不得影响 computer 动作:这里是唯一允许吞掉错误的地方。
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 构造注入用的台账段(供 {@link buildSessionStateReminder} 拼进尾部 ephemeral system 消息)。
|
|
153
|
+
* 空台账返 ''(零开销,调用方此时不追加任何消息)。
|
|
154
|
+
*/
|
|
155
|
+
export function buildGuiActionsSection(sessionId = getCurrentSessionId()) {
|
|
156
|
+
const entries = readGuiActionEntries(sessionId);
|
|
157
|
+
if (entries.length === 0)
|
|
158
|
+
return '';
|
|
159
|
+
return [SECTION_HEADING, ...entries, DISCIPLINE_LINE].join('\n');
|
|
160
|
+
}
|
|
161
|
+
/** 供 /cu status 展示的条数:0 表示该会话还没有任何 computer 动作入账。 */
|
|
162
|
+
export function countGuiActions(sessionId = getCurrentSessionId()) {
|
|
163
|
+
return readGuiActionEntries(sessionId).length;
|
|
164
|
+
}
|
|
@@ -74,11 +74,14 @@ const CAPABILITIES = {
|
|
|
74
74
|
computer: { effect: 'process', concurrency: 'serial', resources: () => ['desktop'], supportsAbort: true },
|
|
75
75
|
// sub-agent 与主 agent 同权,直接写工作区(无 overlay)。编排器本身不持锁——锁由嵌套工具
|
|
76
76
|
// 各自获取,否则子 agent 内的 run_command 会等父持有的 workspace 锁而自锁。
|
|
77
|
-
//
|
|
77
|
+
// parallelOrchestration:同一轮派发的多个子 agent 按 subAgentConcurrency 上限成批并行
|
|
78
|
+
// (调度见 tool-helpers.isParallelOrchestrationCall 与 run-coordinator 的编排批);
|
|
79
|
+
// isResourceLockedCall 仍显式排除 sub-agent,使其不混入 file-mutation 批。
|
|
78
80
|
'sub-agent': {
|
|
79
81
|
effect: 'write',
|
|
80
82
|
concurrency: 'resource-locked',
|
|
81
83
|
delegatesResourceLocks: true,
|
|
84
|
+
parallelOrchestration: true,
|
|
82
85
|
supportsAbort: true,
|
|
83
86
|
},
|
|
84
87
|
};
|
|
@@ -12,6 +12,9 @@ export const readFileTool = {
|
|
|
12
12
|
description: 'Read file content with line numbers. Read before editing.\n' +
|
|
13
13
|
'For files >500 lines: grep first to locate regions, then call read_file multiple times ' +
|
|
14
14
|
'with offset+limit (e.g. offset=350, limit=120). Do NOT read an entire large file in one call.\n' +
|
|
15
|
+
'When you need several regions of the SAME file, issue those read_file calls together in one ' +
|
|
16
|
+
'response (they run concurrently) instead of paging through it one page after another — ' +
|
|
17
|
+
'avoid sequential offset+=limit walks of the same file.\n' +
|
|
15
18
|
'For files ≤500 lines you may read the whole file in one call. Independent region reads ' +
|
|
16
19
|
'may be issued in the same response — they run concurrently, saving a round-trip each.\n' +
|
|
17
20
|
'For architecture or call-chain questions, prefer loading the `codegraph` skill (use_skill) over reading files one at a time.',
|
package/dist/tools/constants.js
CHANGED
|
@@ -63,7 +63,17 @@ export const MAX_GRAPH_ENTITIES = 500;
|
|
|
63
63
|
export const MAX_GRAPH_EDGES = 2000;
|
|
64
64
|
// .codegraph:codegraph 索引目录(codegraph.db 是 SQLite 二进制 + daemon.log),
|
|
65
65
|
// grep/glob 扫它无意义且会产出数 KB 的超长「行」,污染 TUI 展开渲染。
|
|
66
|
-
|
|
66
|
+
// 构建产物目录同理:rust/(cargo target,数千个 .o/.rmeta 二进制)、dist/ 与 dist-tests/
|
|
67
|
+
// (tsc 产物,与 src 的 .ts 一一对应的 .js,扫它们只会重复命中并拖慢全库 grep——
|
|
68
|
+
// 实测无 glob 限定的 grep 扫全库耗时 205s,其中大半耗在这类目录)。
|
|
69
|
+
export const IGNORE = [
|
|
70
|
+
'**/node_modules/**',
|
|
71
|
+
'**/.git/**',
|
|
72
|
+
'**/.codegraph/**',
|
|
73
|
+
'**/rust/**',
|
|
74
|
+
'**/dist/**',
|
|
75
|
+
'**/dist-tests/**',
|
|
76
|
+
];
|
|
67
77
|
// ── legacy profile fallback(官方 TUI/stdio 已由 ToolPolicy 控制)────────────────
|
|
68
78
|
// 保留导出名以兼容未传 ToolPolicy 的嵌入调用与旧测试。
|
|
69
79
|
export const FRONTEND_TOOLS = new Set(TOOL_GROUPS.frontend);
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev / TypeSafe systemone 协议客户端(工具预路由的第二后端)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么是独立文件而不是塞进 src/llm/index.ts:
|
|
5
|
+
* Jev 不是 OpenAI 兼容的 /chat/completions 模型——它一次问一组「是/否」问题、每组独立
|
|
6
|
+
* 返回一个 0~1 的概率(noul 原语),没有 messages/tool_calls 概念。硬套 chat() 的
|
|
7
|
+
* ChatTransport 契约会污染主链路。故单独一个极薄的 HTTP 客户端,只做:
|
|
8
|
+
* 构造 body → POST → 解析 answers[<group>].noul → 归一化成 {组名: 概率}。
|
|
9
|
+
*
|
|
10
|
+
* 失败语义:除用户取消(AbortError)外**永不抛错**,一律返回 {ok:false,error}。
|
|
11
|
+
* router 侧据此走 fallbackDecision(沿用上一 turn 簇)。503/529/429 等瞬时错误内部退避重试。
|
|
12
|
+
*
|
|
13
|
+
* 键名陷阱:API 的 answers 键用下划线(browser_debug),而 mocode 的簇名用连字符
|
|
14
|
+
* (browser-debug)。本文件用 underscoreKey() 单点转换,调用方只见连字符簇名。
|
|
15
|
+
*/
|
|
16
|
+
/** 默认官方端点;本地部署(如 0xBakeer/arbiter 兼容层)可经配置覆盖 baseUrl。 */
|
|
17
|
+
export const DEFAULT_JEV_BASE_URL = 'https://api.typesafe.ai';
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
19
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504, 529]);
|
|
20
|
+
const MAX_ATTEMPTS = 4;
|
|
21
|
+
let fetchImplOverride = null;
|
|
22
|
+
/** 仅供单测注入;生产路径使用 Node 18+ 全局 fetch。 */
|
|
23
|
+
export function __setJevFetchImpl(impl) {
|
|
24
|
+
fetchImplOverride = impl;
|
|
25
|
+
}
|
|
26
|
+
/** 簇名 → API 键:连字符转下划线(browser-debug → browser_debug)。 */
|
|
27
|
+
export function underscoreKey(group) {
|
|
28
|
+
return group.replace(/-/g, '_');
|
|
29
|
+
}
|
|
30
|
+
/** 把外部 signal 与超时合并成一个 signal;返回的 cleanup 必须调用。 */
|
|
31
|
+
function withTimeout(signal, timeoutMs) {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => controller.abort(new Error('jev request timeout')), timeoutMs);
|
|
34
|
+
const onAbort = () => controller.abort(signal?.reason);
|
|
35
|
+
if (signal) {
|
|
36
|
+
if (signal.aborted)
|
|
37
|
+
controller.abort(signal.reason);
|
|
38
|
+
else
|
|
39
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
40
|
+
}
|
|
41
|
+
const cleanup = () => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
signal?.removeEventListener('abort', onAbort);
|
|
44
|
+
};
|
|
45
|
+
return { signal: controller.signal, cleanup };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 仅当**外部** signal 已 abort 才算「用户取消」。
|
|
49
|
+
*
|
|
50
|
+
* 不能靠 error.name === 'AbortError' 判断:内部超时也是用 AbortController 触发的,
|
|
51
|
+
* 同样抛出 AbortError;而 runtime 每个 turn 都会传外部 signal,若按错误名判断,一次
|
|
52
|
+
* 慢响应(超时)就会被误当用户取消而上抛,直接中止用户整轮——这与「失败降级」契约相反。
|
|
53
|
+
* 故只认外部 signal 自身的 aborted 标志:超时走普通失败回报(可重试/降级)。
|
|
54
|
+
*/
|
|
55
|
+
function isExternalAbort(signal) {
|
|
56
|
+
return signal?.aborted === true;
|
|
57
|
+
}
|
|
58
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
59
|
+
function finiteProbability(value) {
|
|
60
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
61
|
+
return Math.max(0, Math.min(1, value));
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 问一次 Jev。返回各组概率;失败返回 {ok:false}。
|
|
66
|
+
* 用户取消(signal.aborted)会抛出 AbortError —— 与 chat() 的取消语义一致,由 router 决定
|
|
67
|
+
* 是中止本轮还是降级。
|
|
68
|
+
*/
|
|
69
|
+
export async function askJev(request) {
|
|
70
|
+
const startedAt = Date.now();
|
|
71
|
+
const base = (request.baseUrl ?? DEFAULT_JEV_BASE_URL).replace(/\/+$/, '');
|
|
72
|
+
const endpoint = `${base}/v1/systemone`;
|
|
73
|
+
const apiKey = request.apiKey ?? '';
|
|
74
|
+
const questions = {};
|
|
75
|
+
for (const [group, question] of Object.entries(request.questions)) {
|
|
76
|
+
questions[underscoreKey(group)] = question;
|
|
77
|
+
}
|
|
78
|
+
const body = {
|
|
79
|
+
model: request.model || 'jev-latest',
|
|
80
|
+
state: {
|
|
81
|
+
mode: request.mode ?? 'AUTO',
|
|
82
|
+
previous_groups: [...(request.previousGroups ?? [])],
|
|
83
|
+
task: request.task,
|
|
84
|
+
},
|
|
85
|
+
questions,
|
|
86
|
+
};
|
|
87
|
+
const maxAttempts = Math.max(1, request.maxAttempts ?? MAX_ATTEMPTS);
|
|
88
|
+
let lastError = 'unknown error';
|
|
89
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
90
|
+
const { signal, cleanup } = withTimeout(request.signal, request.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
91
|
+
let response;
|
|
92
|
+
try {
|
|
93
|
+
const impl = fetchImplOverride ?? fetch;
|
|
94
|
+
response = await impl(endpoint, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: {
|
|
97
|
+
'content-type': 'application/json',
|
|
98
|
+
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
|
|
99
|
+
},
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
signal,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
cleanup();
|
|
106
|
+
// 只有外部 signal 自身已 abort(用户取消)才上抛;内部超时走下方重试/降级。
|
|
107
|
+
if (isExternalAbort(request.signal))
|
|
108
|
+
throw error;
|
|
109
|
+
lastError = `request failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
110
|
+
if (attempt < maxAttempts) {
|
|
111
|
+
await sleep(Math.min(1000 * 2 ** (attempt - 1), 8000));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
return { ok: false, error: lastError, latencyMs: Date.now() - startedAt };
|
|
115
|
+
}
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
// 非 JSON 错误页只保留前缀,避免把代理 HTML 大段带进日志。
|
|
118
|
+
let detail = '';
|
|
119
|
+
try {
|
|
120
|
+
detail = (await response.text()).slice(0, 180);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
detail = '';
|
|
124
|
+
}
|
|
125
|
+
cleanup();
|
|
126
|
+
lastError = `HTTP ${response.status}${detail ? ` ${detail}` : ''}`;
|
|
127
|
+
if (RETRYABLE_STATUS.has(response.status) && attempt < maxAttempts) {
|
|
128
|
+
await sleep(Math.min(1000 * 2 ** (attempt - 1), 8000));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
return { ok: false, error: lastError, latencyMs: Date.now() - startedAt };
|
|
132
|
+
}
|
|
133
|
+
let payload;
|
|
134
|
+
try {
|
|
135
|
+
payload = await response.json();
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
cleanup();
|
|
139
|
+
lastError = `invalid JSON response: ${error instanceof Error ? error.message : String(error)}`;
|
|
140
|
+
return { ok: false, error: lastError, latencyMs: Date.now() - startedAt };
|
|
141
|
+
}
|
|
142
|
+
cleanup();
|
|
143
|
+
const record = payload && typeof payload === 'object' ? payload : {};
|
|
144
|
+
const rawAnswers = record.answers && typeof record.answers === 'object' ? record.answers : {};
|
|
145
|
+
const probabilities = {};
|
|
146
|
+
let inheritPrevious;
|
|
147
|
+
for (const group of Object.keys(request.questions)) {
|
|
148
|
+
const answer = rawAnswers[underscoreKey(group)];
|
|
149
|
+
if (!answer || typeof answer !== 'object')
|
|
150
|
+
continue;
|
|
151
|
+
const probability = finiteProbability(answer.noul);
|
|
152
|
+
if (probability !== undefined)
|
|
153
|
+
probabilities[group] = probability;
|
|
154
|
+
}
|
|
155
|
+
const inheritAnswer = rawAnswers.inherit_previous;
|
|
156
|
+
if (inheritAnswer && typeof inheritAnswer === 'object') {
|
|
157
|
+
inheritPrevious = finiteProbability(inheritAnswer.noul);
|
|
158
|
+
}
|
|
159
|
+
const usage = record.usage && typeof record.usage === 'object' ? record.usage : {};
|
|
160
|
+
const inputTokens = typeof usage.input_tokens === 'number' && Number.isFinite(usage.input_tokens)
|
|
161
|
+
? usage.input_tokens
|
|
162
|
+
: undefined;
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
probabilities,
|
|
166
|
+
inheritPrevious,
|
|
167
|
+
latencyMs: Date.now() - startedAt,
|
|
168
|
+
model: typeof record.model === 'string' ? record.model : undefined,
|
|
169
|
+
inputTokens,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { ok: false, error: lastError, latencyMs: Date.now() - startedAt };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 探测端点连通性(供 /router test 使用):用最小问题集问一次,返回是否可用 + 延迟。
|
|
176
|
+
* 永不抛错(取消除外)。
|
|
177
|
+
*/
|
|
178
|
+
export async function probeJev(options) {
|
|
179
|
+
const result = await askJev({
|
|
180
|
+
task: 'connectivity probe',
|
|
181
|
+
questions: { probe: { type: 'noul', instructions: 'Is this a connectivity probe?' } },
|
|
182
|
+
baseUrl: options.baseUrl,
|
|
183
|
+
apiKey: options.apiKey,
|
|
184
|
+
model: options.model,
|
|
185
|
+
signal: options.signal,
|
|
186
|
+
timeoutMs: 20_000,
|
|
187
|
+
maxAttempts: 2,
|
|
188
|
+
});
|
|
189
|
+
if (result.ok)
|
|
190
|
+
return { ok: true, latencyMs: result.latencyMs, model: result.model };
|
|
191
|
+
return { ok: false, latencyMs: result.latencyMs, error: result.error };
|
|
192
|
+
}
|
package/dist/tools/router.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { chat } from '../llm/index.js';
|
|
2
2
|
import { COMMON_TOOL_NAMES, DEFAULT_ROUTE_GROUPS, TOOL_ROUTE_GROUPS, isToolRouteGroupName, } from '../config/profiles.js';
|
|
3
3
|
import { getRoutableToolRouteGroups, toolRouteCatalog } from './policy.js';
|
|
4
|
+
import { getRouterMode, getJevRouterConfig, isJevRouterConfigured } from '../config/index.js';
|
|
5
|
+
import { askJev } from './jev-client.js';
|
|
4
6
|
const ROUTER_TOOL_NAME = 'select_tool_groups';
|
|
5
7
|
const MAX_ROUTER_INPUT_CHARS = 12_000;
|
|
6
8
|
function routeSelectorTool(groups) {
|
|
@@ -82,8 +84,11 @@ function parseDecision(raw, available, previousGroups, startedAt) {
|
|
|
82
84
|
};
|
|
83
85
|
}
|
|
84
86
|
/**
|
|
85
|
-
* 每个真实用户 turn
|
|
86
|
-
*
|
|
87
|
+
* 每个真实用户 turn 强制执行一次无副作用预路由。后端由 /router 选择:
|
|
88
|
+
* - `llm`(默认):与主 Agent 同一后端,直出一个簇集合,无可调旋钮。
|
|
89
|
+
* - `jev`:TypeSafe systemone,每组独立出 0~1 概率,按阈值出簇(阈值可调是选它的核心理由)。
|
|
90
|
+
* 两条路径失败都沿用上一 turn 的簇;主 Agent 仍可通过 add_tool_groups 自救,
|
|
91
|
+
* 但绝不因路由失败直接暴露 full 工具集。
|
|
87
92
|
*/
|
|
88
93
|
export async function routeToolGroups(request) {
|
|
89
94
|
const startedAt = Date.now();
|
|
@@ -95,6 +100,84 @@ export async function routeToolGroups(request) {
|
|
|
95
100
|
if (availableGroups.length === 0) {
|
|
96
101
|
return fallbackDecision(startedAt, [], `No routable tool groups are currently available; using common tools plus always-on ${DEFAULT_ROUTE_GROUPS.join(', ')}.`);
|
|
97
102
|
}
|
|
103
|
+
if (getRouterMode() === 'jev') {
|
|
104
|
+
return routeWithJev(request, availableGroups, available, previousGroups, startedAt);
|
|
105
|
+
}
|
|
106
|
+
return routeWithLlm(request, availableGroups, available, previousGroups, startedAt);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Jev(TypeSafe systemone)路由:每组独立问一个 noul 问题,拿到 0~1 概率后按阈值出簇。
|
|
110
|
+
*
|
|
111
|
+
* 与 LLM 路径的差别:LLM 直出一个集合、没有可调旋钮;Jev 出概率,阈值才是可调旋钮——
|
|
112
|
+
* 这正是值得接它的理由(可按 假阴:假阳 的代价比调 operating point)。
|
|
113
|
+
*
|
|
114
|
+
* 问题正文复用 TOOL_ROUTE_GROUPS[g].description(与 LLM 路径的 catalog 同一来源),
|
|
115
|
+
* 避免「组语义」在多处各写一份而漂移。
|
|
116
|
+
*/
|
|
117
|
+
async function routeWithJev(request, availableGroups, available, previousGroups, startedAt) {
|
|
118
|
+
if (!isJevRouterConfigured()) {
|
|
119
|
+
return fallbackDecision(startedAt, previousGroups, 'Jev router selected but MOCODE_ROUTER_JEV_API_KEY is unset; reused previous groups. Run /router key <key>.');
|
|
120
|
+
}
|
|
121
|
+
const jevConfig = getJevRouterConfig();
|
|
122
|
+
const questions = {};
|
|
123
|
+
for (const group of availableGroups) {
|
|
124
|
+
questions[group] = {
|
|
125
|
+
type: 'noul',
|
|
126
|
+
instructions: `Does the next agent turn need this capability: ${TOOL_ROUTE_GROUPS[group].description}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
let result;
|
|
130
|
+
try {
|
|
131
|
+
result = await askJev({
|
|
132
|
+
task: request.input.slice(0, MAX_ROUTER_INPUT_CHARS),
|
|
133
|
+
questions,
|
|
134
|
+
mode: request.planMode ? 'PLAN' : 'AUTO',
|
|
135
|
+
previousGroups,
|
|
136
|
+
baseUrl: jevConfig.baseUrl,
|
|
137
|
+
apiKey: jevConfig.apiKey,
|
|
138
|
+
model: jevConfig.model,
|
|
139
|
+
signal: request.signal,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
// 只有用户取消会抛(见 jev-client):与 LLM 路径一致地继续上抛,由 runtime 撤回本轮。
|
|
144
|
+
if (request.signal?.aborted || (error instanceof Error && error.name === 'AbortError'))
|
|
145
|
+
throw error;
|
|
146
|
+
return fallbackDecision(startedAt, previousGroups, `Jev router failed (${error instanceof Error ? error.message : String(error)}); reused previous groups.`);
|
|
147
|
+
}
|
|
148
|
+
if (!result.ok) {
|
|
149
|
+
return fallbackDecision(startedAt, previousGroups, `Jev router failed (${result.error}); reused previous groups.`);
|
|
150
|
+
}
|
|
151
|
+
const selected = [];
|
|
152
|
+
for (const group of availableGroups) {
|
|
153
|
+
const probability = result.probabilities[group];
|
|
154
|
+
if (probability === undefined)
|
|
155
|
+
continue;
|
|
156
|
+
// mcp 用更高门槛:实测它是系统性假阳性磁铁(对无关任务也给 0.5–0.7)。
|
|
157
|
+
const threshold = group === 'mcp' ? jevConfig.confidenceMinMcp : jevConfig.confidenceMin;
|
|
158
|
+
if (probability >= threshold)
|
|
159
|
+
selected.push(group);
|
|
160
|
+
}
|
|
161
|
+
const inheritPrevious = (result.inheritPrevious ?? 0) >= 0.5;
|
|
162
|
+
const merged = new Set(inheritPrevious ? previousGroups : []);
|
|
163
|
+
for (const group of selected)
|
|
164
|
+
merged.add(group);
|
|
165
|
+
// confidence 取「已选簇里最小的那个概率」:最弱一环决定整体可信度,且随阈值单调。
|
|
166
|
+
const selectedProbabilities = selected
|
|
167
|
+
.map((group) => result.probabilities[group])
|
|
168
|
+
.filter((value) => value !== undefined);
|
|
169
|
+
const confidence = selectedProbabilities.length ? Math.min(...selectedProbabilities) : 0;
|
|
170
|
+
return {
|
|
171
|
+
groups: [...merged].filter((group) => available.has(group)),
|
|
172
|
+
inheritPrevious,
|
|
173
|
+
confidence,
|
|
174
|
+
reason: `Jev(${result.model ?? jevConfig.model}) selected ${merged.size ? [...merged].join(', ') : 'no extra groups'} at thresholds ${jevConfig.confidenceMin}/${jevConfig.confidenceMinMcp}.`,
|
|
175
|
+
latencyMs: Date.now() - startedAt,
|
|
176
|
+
fallback: false,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** LLM 路由(默认路径):一次 select_tool_groups 工具调用,直出簇集合。 */
|
|
180
|
+
async function routeWithLlm(request, availableGroups, available, previousGroups, startedAt) {
|
|
98
181
|
// 路由规则与示例、profiles.ts 的 TOOL_ROUTE_GROUPS descriptions 是三源:逐组启发式和
|
|
99
182
|
// Examples 是对 descriptions 的强化(对弱模型有真实价值),但组定义变更时三处需同步维护。
|
|
100
183
|
const system = `You are mocode's capability router. You do not solve the task and you cannot execute tools.
|
|
@@ -328,6 +328,7 @@ export function clearContent() {
|
|
|
328
328
|
// 欢迎引导块随 buffer 一起清掉(/clear / /resume 等路径),状态复位后可由 repl 重新写入。
|
|
329
329
|
state.welcomeStart = -1;
|
|
330
330
|
state.welcomeRows = 0;
|
|
331
|
+
state.welcomeSource = [];
|
|
331
332
|
// 清内容区必须同时作废旧菜单擦除坐标:picker(/resume /rollback /theme)把菜单画在内容区底部,
|
|
332
333
|
// 菜单行号缓存在 lastMenuStartRow/lastMenuRows;若不清零,后续 paintInput 会按旧坐标“擦菜单”,
|
|
333
334
|
// 把刚 renderHistory/contentWrite 写好的内容(如“已续接会话”提示)清掉,导致用户要滚动一下才刷新。
|
|
@@ -364,16 +365,72 @@ export function rewindContent(rowsToRewind) {
|
|
|
364
365
|
// ── 欢迎引导块(新会话开场)──
|
|
365
366
|
// 开场写在内容区(banner 之下),教用户怎么开始;首次提交任何输入(消息或斜杠命令)前
|
|
366
367
|
// 由 dismissWelcomeBlock 整块从 buffer 撤掉——「一打开就能看见,开始干活就消失」。
|
|
367
|
-
|
|
368
|
+
//
|
|
369
|
+
// 居中在本层现算:调用方(runtime.welcomeLines)只产出**逻辑行**(带色、不带缩进),
|
|
370
|
+
// 这里按写入当时的列宽补左缩进,并把逻辑行源记入 state.welcomeSource——终端 resize 后
|
|
371
|
+
// reflowWelcomeBlockForResize 按新列宽重新居中/折行重排整块。否则块停留在旧宽度:
|
|
372
|
+
// 缩窄后被 viewport 截断成「…」、放宽后仍偏在旧位置(「提示不随窗口变化」bug)。
|
|
373
|
+
/** 欢迎块逻辑行 → 待写物理行:按可见宽度(剥 ANSI、CJK 计 2)整行居中,余数归右。
|
|
374
|
+
* 空行原样;可见宽度 ≥ cols 的行不缩进,超宽部分由 contentWrite 状态机折行兜底。 */
|
|
375
|
+
export function renderWelcomeRows(lines, cols) {
|
|
376
|
+
return lines.map((line) => {
|
|
377
|
+
const w = ansiDisplayWidth(line);
|
|
378
|
+
if (w <= 0 || w >= cols)
|
|
379
|
+
return line;
|
|
380
|
+
return ' '.repeat(Math.floor((cols - w) / 2)) + line;
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/** 写欢迎引导块(逻辑行经 renderWelcomeRows 居中后写入,contentWrite 兜底折行);已在屏上则跳过。 */
|
|
368
384
|
export function writeWelcomeBlock(lines) {
|
|
369
385
|
if (!state.active || !ui.isTTY || lines.length === 0)
|
|
370
386
|
return;
|
|
371
387
|
if (state.welcomeRows > 0)
|
|
372
388
|
return; // 已在屏上,不重复写
|
|
389
|
+
state.welcomeSource = [...lines];
|
|
373
390
|
state.welcomeStart = content.committedRows();
|
|
374
|
-
contentWrite(lines.join('\n') + '\n');
|
|
391
|
+
contentWrite(renderWelcomeRows(lines, getGeo().cols).join('\n') + '\n');
|
|
375
392
|
state.welcomeRows = content.committedRows() - state.welcomeStart;
|
|
376
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* resize(列宽变化)后按新列宽重排欢迎引导块,返回 ReflowChange 供调用方统一迁移
|
|
396
|
+
* 选区 / batch / viewport 锚点;不可安全重排时返回 null,保持旧行不动。
|
|
397
|
+
*
|
|
398
|
+
* 前提:块可见期间它恒为缓冲尾(写入后、首次输入撤块前没有任何内容写在它后面;
|
|
399
|
+
* /clear 重写时同样在尾部)。据此用「删尾块 → 续写位回到块首 → 按新列宽重写」即可
|
|
400
|
+
* 原地重排,行数变化由 committedRows 差值重新记账。guard 拒绝例外场景:
|
|
401
|
+
* - 滚动回看中 / md 流式段活跃:welcome 与二者不应共存(块在首次输入前就撤掉),
|
|
402
|
+
* 真发生时不动块,退回旧行为;
|
|
403
|
+
* - 块后已有内容(不变式被破坏):重写会盖到后续内容,放弃。
|
|
404
|
+
*/
|
|
405
|
+
function reflowWelcomeBlockForResize(cols) {
|
|
406
|
+
const start = state.welcomeStart;
|
|
407
|
+
const n = state.welcomeRows;
|
|
408
|
+
const source = state.welcomeSource;
|
|
409
|
+
if (!source || source.length === 0 || n <= 0 || start < 0)
|
|
410
|
+
return null;
|
|
411
|
+
if (state.scrollOffset > 0 || state.mdActive)
|
|
412
|
+
return null;
|
|
413
|
+
if (start + n !== content.committedRows())
|
|
414
|
+
return null;
|
|
415
|
+
const oldLines = [];
|
|
416
|
+
for (let i = start; i < start + n; i++)
|
|
417
|
+
oldLines.push(content.lineAt(i) ?? '');
|
|
418
|
+
// 用 content 层原语(而非 layout 包装 contentDeleteFrom):batch 索引平移由调用方对返回的
|
|
419
|
+
// ReflowChange 统一做 shiftBatchesForReflow,这里再 shiftBatchesAfter 会双重平移。
|
|
420
|
+
// segmentStartRow / scrollOffset 已被 guard 排除(mdActive / scrolled 均返回 null),无需维护。
|
|
421
|
+
content.deleteFrom(start, n);
|
|
422
|
+
// 块是缓冲尾 → 删后续写位 = 块首。显式重锚(contentWrite 入口的归一化在块曾溢出屏、
|
|
423
|
+
// contentRow 已被钳到 contentBottom 时算不准),contentWrite 入口会再归一化一次,两处一致。
|
|
424
|
+
const g = getGeo();
|
|
425
|
+
state.contentRow = Math.min(start + 1, g.contentBottom);
|
|
426
|
+
state.contentCol = 1;
|
|
427
|
+
contentWrite(renderWelcomeRows(source, cols).join('\n') + '\n');
|
|
428
|
+
state.welcomeRows = content.committedRows() - start;
|
|
429
|
+
const newLines = [];
|
|
430
|
+
for (let i = start; i < start + state.welcomeRows; i++)
|
|
431
|
+
newLines.push(content.lineAt(i) ?? '');
|
|
432
|
+
return { start, oldCount: n, newCount: state.welcomeRows, delta: state.welcomeRows - n, oldLines, newLines };
|
|
433
|
+
}
|
|
377
434
|
/** 撤掉欢迎引导块:删 buffer 区间(若已被外部清空/裁掉则只复位状态)+ 续写位前移 + 钳位重画。 */
|
|
378
435
|
export function dismissWelcomeBlock() {
|
|
379
436
|
if (state.welcomeRows <= 0)
|
|
@@ -382,6 +439,7 @@ export function dismissWelcomeBlock() {
|
|
|
382
439
|
const n = state.welcomeRows;
|
|
383
440
|
state.welcomeRows = 0;
|
|
384
441
|
state.welcomeStart = -1;
|
|
442
|
+
state.welcomeSource = [];
|
|
385
443
|
if (start < 0 || start >= content.committedRows())
|
|
386
444
|
return; // 块已被 clear/trim,无需删
|
|
387
445
|
content.deleteFrom(start, n);
|
|
@@ -545,13 +603,21 @@ function remapLineForReflow(line, change) {
|
|
|
545
603
|
const relative = (line - change.start) / Math.max(1, change.oldCount - 1);
|
|
546
604
|
return change.start + Math.round(relative * Math.max(0, change.newCount - 1));
|
|
547
605
|
}
|
|
548
|
-
/**
|
|
606
|
+
/** 终端尺寸变化后更新正文布局。列宽变化时先重排欢迎块、再重排 markdown 段;仅高度变化时只重算屏幕锚点。 */
|
|
549
607
|
export function reflowContentForResize(cols, colsChanged) {
|
|
550
608
|
const oldTotal = content.totalRows();
|
|
551
609
|
const oldViewportStart = viewportAbsStart();
|
|
552
610
|
const oldViewportEnd = Math.max(oldViewportStart, oldTotal - state.scrollOffset - 1);
|
|
553
611
|
let mappedViewportEnd = oldViewportEnd;
|
|
554
|
-
const changes =
|
|
612
|
+
const changes = [];
|
|
613
|
+
if (colsChanged) {
|
|
614
|
+
// 欢迎块先于 markdown 段重排:它可见期间恒在缓冲头部区(banner 之后、任何 md 段之前),
|
|
615
|
+
// 其行数变化会平移后续段;先重排它,reflowMarkdown 报告的 start 才与它在同一坐标系。
|
|
616
|
+
const welcomeChange = reflowWelcomeBlockForResize(cols);
|
|
617
|
+
if (welcomeChange)
|
|
618
|
+
changes.push(welcomeChange);
|
|
619
|
+
changes.push(...content.reflowMarkdown(cols, renderMarkdown));
|
|
620
|
+
}
|
|
555
621
|
for (const change of changes) {
|
|
556
622
|
shiftBatchesForReflow(change.start, change.oldCount, change.newCount);
|
|
557
623
|
mappedViewportEnd = remapLineForReflow(mappedViewportEnd, change);
|
|
@@ -33,6 +33,9 @@ export const state = {
|
|
|
33
33
|
bannerH: 0,
|
|
34
34
|
welcomeStart: -1, // 块起点(content.committedRows 口径的绝对行索引)
|
|
35
35
|
welcomeRows: 0, // 块行数(0 = 屏上无欢迎块)
|
|
36
|
+
/** 欢迎块逻辑行源(带色、不带居中缩进)。writeWelcomeBlock 记录,列宽变化后由
|
|
37
|
+
* reflowWelcomeBlockForResize 按新列宽重新居中/折行重排;clear / dismiss 时清空。 */
|
|
38
|
+
welcomeSource: [],
|
|
36
39
|
// ── 滚动与流式暂停(回看冻结 / 打字期停物理写) ──
|
|
37
40
|
scrollOffset: 0, // 滚动回看距尾行数(0=尾,跟随新内容);>0 时 viewport 显历史、状态行显滚动指示
|
|
38
41
|
scrollLockUntil: 0, // 发消息轮首滚动锁(绝对时间戳 ms,0=未锁):吸收 stdin 残留滚轮事件,防 resetScroll 回尾后被重新滚上去
|