multiarena 0.1.0 → 0.1.3
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/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +282 -0
- package/dist/cli/args.d.ts +11 -0
- package/dist/cli/args.js +56 -0
- package/dist/config/loader.js +2 -2
- package/dist/config/types.d.ts +11 -1
- package/dist/core/deliberation.d.ts +53 -0
- package/dist/core/deliberation.js +356 -0
- package/dist/core/session.d.ts +3 -1
- package/dist/core/session.js +20 -17
- package/dist/core/turn.d.ts +2 -0
- package/dist/core/turn.js +32 -5
- package/dist/index.js +3 -49
- package/dist/isolation/worktree.d.ts +1 -1
- package/dist/isolation/worktree.js +8 -8
- package/dist/persistence/session.js +1 -1
- package/dist/provider/adapters/openai.d.ts +15 -0
- package/dist/provider/adapters/openai.js +67 -8
- package/dist/provider/provider.js +4 -0
- package/dist/tools/builtin/bash.js +6 -1
- package/dist/ui/app.js +426 -46
- package/dist/ui/components/BroadcastSummary.d.ts +1 -0
- package/dist/ui/components/BroadcastSummary.js +24 -8
- package/dist/ui/components/DeliberationView.d.ts +17 -0
- package/dist/ui/components/DeliberationView.js +81 -0
- package/dist/ui/components/InputBar.d.ts +3 -0
- package/dist/ui/components/InputBar.js +18 -8
- package/dist/ui/components/ModelDetail.js +16 -4
- package/dist/ui/components/OutputArea.d.ts +8 -0
- package/dist/ui/components/OutputArea.js +32 -4
- package/dist/ui/components/formatTokens.d.ts +1 -0
- package/dist/ui/components/formatTokens.js +7 -0
- package/dist/ui/modeTransitions.d.ts +80 -0
- package/dist/ui/modeTransitions.js +176 -0
- package/package.json +13 -8
- package/dist/ui/components/StatusBar.d.ts +0 -9
- package/dist/ui/components/StatusBar.js +0 -51
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { runTurn } from "./turn.js";
|
|
2
|
+
import { ToolRegistry } from "../tools/registry.js";
|
|
3
|
+
import { PermissionManager } from "../tools/permission.js";
|
|
4
|
+
const ROLE_LABELS = {
|
|
5
|
+
draft: "起草",
|
|
6
|
+
revise: "修订",
|
|
7
|
+
polish: "润色",
|
|
8
|
+
review: "终审",
|
|
9
|
+
};
|
|
10
|
+
function buildSystemPrompt(role, task, isFinalRound, constraint, previousDocument, draftAuthor) {
|
|
11
|
+
const constraintBlock = constraint
|
|
12
|
+
? `\n\n## 约束文档(必须遵守)\n${constraint}`
|
|
13
|
+
: "";
|
|
14
|
+
const header = "你正在参与一个多模型协作起草流程(R2D2:轮转审议起草)。";
|
|
15
|
+
switch (role) {
|
|
16
|
+
case "draft":
|
|
17
|
+
return `${header}你的角色是**起草者**。
|
|
18
|
+
|
|
19
|
+
## 任务
|
|
20
|
+
${task}
|
|
21
|
+
${constraintBlock}
|
|
22
|
+
|
|
23
|
+
## 要求
|
|
24
|
+
- 写出一份完整的初稿
|
|
25
|
+
- 严格对照约束文档,确保无违规
|
|
26
|
+
- 覆盖任务描述中的所有要点
|
|
27
|
+
- 后续其他模型会在你的基础上修改,请尽量全面
|
|
28
|
+
- 输出完整的文档内容`;
|
|
29
|
+
case "revise": {
|
|
30
|
+
const finalRoundBlock = isFinalRound
|
|
31
|
+
? `\n## 重要:这是最后一轮
|
|
32
|
+
你是最终输出者。请输出一份面向用户的干净终稿:
|
|
33
|
+
- 直接在你的修订版正文中完成所有修改,不要使用 [修订:] 标注
|
|
34
|
+
- 如果之前文档中有 [修订:] 或 [补充:] 标注,将它们全部清理掉,只保留修改后的干净正文
|
|
35
|
+
- 文档读起来应该像一篇自然的成品,没有任何过程标记`
|
|
36
|
+
: "";
|
|
37
|
+
return `${header}你的角色是**修订者**。
|
|
38
|
+
${finalRoundBlock}
|
|
39
|
+
## 原始任务
|
|
40
|
+
${task}
|
|
41
|
+
${constraintBlock}
|
|
42
|
+
|
|
43
|
+
## ${draftAuthor ?? "起草者"} 的初稿
|
|
44
|
+
${previousDocument}
|
|
45
|
+
|
|
46
|
+
## 要求
|
|
47
|
+
- 在初稿基础上修订,不要推倒重写
|
|
48
|
+
- 对照约束文档,逐条检查违规项并修正
|
|
49
|
+
- 补充你发现遗漏的要点
|
|
50
|
+
- 改进表达不清或逻辑不严谨的地方
|
|
51
|
+
${isFinalRound
|
|
52
|
+
? "- 输出完整的干净终稿,不要包含任何过程标注"
|
|
53
|
+
: "- 在修改处用 [修订: 原文片段 → 修改后文本] 标注\n- 禁止笼统赞美,只做实质性修改\n- 输出完整的修订版文档"}`;
|
|
54
|
+
}
|
|
55
|
+
case "polish": {
|
|
56
|
+
const finalRoundBlock = isFinalRound
|
|
57
|
+
? `\n## 重要:这是最后一轮
|
|
58
|
+
你是最终输出者。请输出一份面向用户的干净终稿:
|
|
59
|
+
- 如果之前文档中有 [修订:] 或 [补充:] 标注,将它们全部清理掉,只保留修改后的干净正文
|
|
60
|
+
- 你新增的改进直接写入正文,不要使用 [补充:] 标注
|
|
61
|
+
- 文档读起来应该像一篇自然的成品,没有任何过程标记`
|
|
62
|
+
: "";
|
|
63
|
+
return `${header}你的角色是**润色者**。
|
|
64
|
+
${finalRoundBlock}
|
|
65
|
+
## 原始任务
|
|
66
|
+
${task}
|
|
67
|
+
${constraintBlock}
|
|
68
|
+
|
|
69
|
+
## 经过修订的文档
|
|
70
|
+
${previousDocument}
|
|
71
|
+
|
|
72
|
+
## 要求
|
|
73
|
+
- 在现有基础上最终润色
|
|
74
|
+
- 再次对照约束文档做合规检查
|
|
75
|
+
- 优化语言流畅度和可读性
|
|
76
|
+
${isFinalRound
|
|
77
|
+
? "- 输出完整的干净终稿,不要包含任何过程标注"
|
|
78
|
+
: "- 补充你独有的见解(标注 [补充: 你的贡献])\n- 保留所有之前的修订标注\n- 输出完整的文档"}`;
|
|
79
|
+
}
|
|
80
|
+
case "review":
|
|
81
|
+
return `${header}你的角色是**终审者**(最后一轮)。
|
|
82
|
+
|
|
83
|
+
## 原始任务
|
|
84
|
+
${task}
|
|
85
|
+
${constraintBlock}
|
|
86
|
+
|
|
87
|
+
## 经过多轮修改的文档
|
|
88
|
+
${previousDocument}
|
|
89
|
+
|
|
90
|
+
## 要求
|
|
91
|
+
- 审查是否有修改偏离了原意
|
|
92
|
+
- 对照约束文档逐条再过一遍
|
|
93
|
+
- 清理所有 [修订:] 和 [补充:] 等过程标注,输出干净的最终版
|
|
94
|
+
- 如果认可某处修改,直接保留正文;如果需要回退,直接改回并保持正文流畅
|
|
95
|
+
- 这是一份面向用户的交付文档,不要包含任何过程标记或审查意见
|
|
96
|
+
- 输出最终确认版`;
|
|
97
|
+
default:
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Run the R2D2 (Round-Robin Deliberative Drafting) pipeline.
|
|
103
|
+
*
|
|
104
|
+
* Models take turns in sequence: the first drafts, the second revises,
|
|
105
|
+
* the third polishes, and optionally a fourth reviews. Each round's
|
|
106
|
+
* system prompt injects the task, constraint document, and previous
|
|
107
|
+
* round output for context. No central synthesizer — the document
|
|
108
|
+
* emerges through sequential refinement.
|
|
109
|
+
*/
|
|
110
|
+
export async function* runDeliberation(task, roundConfigs, constraint, worktreePath) {
|
|
111
|
+
const documents = [];
|
|
112
|
+
const totalRounds = roundConfigs.length;
|
|
113
|
+
for (let i = 0; i < roundConfigs.length; i++) {
|
|
114
|
+
const rc = roundConfigs[i];
|
|
115
|
+
const previousDocument = i > 0 ? documents[i - 1] : undefined;
|
|
116
|
+
const draftAuthor = i > 0 ? roundConfigs[0].modelName : undefined;
|
|
117
|
+
const systemPrompt = buildSystemPrompt(rc.role, task, i === roundConfigs.length - 1, constraint, previousDocument, draftAuthor);
|
|
118
|
+
yield {
|
|
119
|
+
type: "round_start",
|
|
120
|
+
round: i + 1,
|
|
121
|
+
totalRounds,
|
|
122
|
+
modelName: rc.modelName,
|
|
123
|
+
role: rc.role,
|
|
124
|
+
};
|
|
125
|
+
const messages = [
|
|
126
|
+
{
|
|
127
|
+
role: "user",
|
|
128
|
+
content: rc.role === "draft"
|
|
129
|
+
? `请起草以下文档:\n\n${task}`
|
|
130
|
+
: "请根据你的角色要求和上述文档内容,输出修改后的完整文档。不要输出任何前言或后记,直接输出文档内容。",
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
let buffer = "";
|
|
134
|
+
try {
|
|
135
|
+
const stream = runTurn({
|
|
136
|
+
modelName: rc.modelName,
|
|
137
|
+
config: rc.config,
|
|
138
|
+
messages,
|
|
139
|
+
systemPrompt,
|
|
140
|
+
tools: [],
|
|
141
|
+
registry: new ToolRegistry(),
|
|
142
|
+
permission: new PermissionManager(),
|
|
143
|
+
worktreePath: worktreePath ?? process.cwd(),
|
|
144
|
+
});
|
|
145
|
+
for await (const event of stream) {
|
|
146
|
+
if (event.type === "text") {
|
|
147
|
+
buffer += event.content;
|
|
148
|
+
yield {
|
|
149
|
+
type: "text",
|
|
150
|
+
round: i + 1,
|
|
151
|
+
totalRounds,
|
|
152
|
+
modelName: rc.modelName,
|
|
153
|
+
role: rc.role,
|
|
154
|
+
content: event.content,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
else if (event.type === "error") {
|
|
158
|
+
buffer += `\n[错误: ${event.message}]`;
|
|
159
|
+
yield {
|
|
160
|
+
type: "text",
|
|
161
|
+
round: i + 1,
|
|
162
|
+
totalRounds,
|
|
163
|
+
modelName: rc.modelName,
|
|
164
|
+
role: rc.role,
|
|
165
|
+
content: `\n[错误: ${event.message}]`,
|
|
166
|
+
};
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
yield {
|
|
173
|
+
type: "error",
|
|
174
|
+
round: i + 1,
|
|
175
|
+
totalRounds,
|
|
176
|
+
modelName: rc.modelName,
|
|
177
|
+
role: rc.role,
|
|
178
|
+
error: err.message,
|
|
179
|
+
};
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
documents.push(buffer);
|
|
183
|
+
// Extract revision annotations for the process summary
|
|
184
|
+
const revisionMatches = buffer.match(/\[修订:\s*([^\]]+?)\]/g) ?? [];
|
|
185
|
+
const changeSamples = revisionMatches
|
|
186
|
+
.map((m) => m.replace(/^\[修订:\s*/, "").replace(/\]$/, ""))
|
|
187
|
+
.slice(0, 5);
|
|
188
|
+
yield {
|
|
189
|
+
type: "round_end",
|
|
190
|
+
round: i + 1,
|
|
191
|
+
totalRounds,
|
|
192
|
+
modelName: rc.modelName,
|
|
193
|
+
role: rc.role,
|
|
194
|
+
document: buffer,
|
|
195
|
+
changeCount: revisionMatches.length,
|
|
196
|
+
changeSamples: changeSamples.length > 0 ? changeSamples : undefined,
|
|
197
|
+
};
|
|
198
|
+
// Yield to the event loop so React renders the round's output
|
|
199
|
+
// before the next round_start event arrives, preventing batch
|
|
200
|
+
// collapse between round_end and round_start.
|
|
201
|
+
if (i < roundConfigs.length - 1) {
|
|
202
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
yield {
|
|
206
|
+
type: "done",
|
|
207
|
+
round: totalRounds,
|
|
208
|
+
totalRounds,
|
|
209
|
+
document: documents[documents.length - 1],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/** Build round configs with mirror pattern: A→B→C→B→A. */
|
|
213
|
+
export function autoAssignRounds(modelNames, models) {
|
|
214
|
+
const active = modelNames.filter((n) => models[n]);
|
|
215
|
+
if (active.length < 2)
|
|
216
|
+
return [];
|
|
217
|
+
const forwardRoles = ["draft", "revise", "polish"];
|
|
218
|
+
if (active.length >= 4)
|
|
219
|
+
forwardRoles.push("review");
|
|
220
|
+
// Forward pass: assign roles to first N models
|
|
221
|
+
const result = [];
|
|
222
|
+
const fwdCount = Math.min(active.length, forwardRoles.length);
|
|
223
|
+
for (let i = 0; i < fwdCount; i++) {
|
|
224
|
+
result.push({
|
|
225
|
+
modelName: active[i],
|
|
226
|
+
role: forwardRoles[i],
|
|
227
|
+
config: models[active[i]],
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// Reverse pass: middle models revise again, first model reviews
|
|
231
|
+
if (active.length >= 2) {
|
|
232
|
+
// Middle models in reverse (skip first and last of forward pass)
|
|
233
|
+
for (let i = fwdCount - 2; i >= 1; i--) {
|
|
234
|
+
result.push({
|
|
235
|
+
modelName: active[i],
|
|
236
|
+
role: "revise",
|
|
237
|
+
config: models[active[i]],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// First model does final review
|
|
241
|
+
result.push({
|
|
242
|
+
modelName: active[0],
|
|
243
|
+
role: "review",
|
|
244
|
+
config: models[active[0]],
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
/** Human-readable label for a round role. */
|
|
250
|
+
export function roundLabel(role) {
|
|
251
|
+
return ROLE_LABELS[role];
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Synthesize existing model outputs into one final document.
|
|
255
|
+
* Single-turn: one model acts as the merger, combining all outputs
|
|
256
|
+
* into a coherent document with source annotations and conflict notes.
|
|
257
|
+
*/
|
|
258
|
+
export async function* runMerge(task, outputs, mergerConfig, mergerName) {
|
|
259
|
+
const outputBlock = outputs
|
|
260
|
+
.map((o) => `### ${o.modelName}\n\n${o.content}`)
|
|
261
|
+
.join("\n\n---\n\n");
|
|
262
|
+
const systemPrompt = `你正在执行多模型输出的合并合成任务。
|
|
263
|
+
|
|
264
|
+
## 原始任务
|
|
265
|
+
${task}
|
|
266
|
+
|
|
267
|
+
## 各模型的输出
|
|
268
|
+
${outputBlock}
|
|
269
|
+
|
|
270
|
+
## 合并要求
|
|
271
|
+
|
|
272
|
+
1. **识别共识** — 找出所有模型中一致或高度相似的论点、事实、建议,合并后作为 [共识] 部分
|
|
273
|
+
2. **保留独有贡献** — 每个模型独有的观点、细节、角度,标注 [来源: 模型名]
|
|
274
|
+
3. **标注分歧** — 如果模型之间对某个问题有不同意见,客观列出各方观点,标注 [分歧]
|
|
275
|
+
4. **去重合并** — 相似内容合并而不是重复
|
|
276
|
+
5. **保持完整** — 不要遗漏任何模型的任何实质内容
|
|
277
|
+
6. **语言流畅** — 最终输出应读起来像一篇连贯的文档,而不是拼凑
|
|
278
|
+
|
|
279
|
+
## 输出格式
|
|
280
|
+
直接输出合并后的完整文档。每个段落/章节末尾用 [] 标注来源。
|
|
281
|
+
禁止输出前言或后记。`;
|
|
282
|
+
yield {
|
|
283
|
+
type: "round_start",
|
|
284
|
+
round: 1,
|
|
285
|
+
totalRounds: 1,
|
|
286
|
+
modelName: mergerName,
|
|
287
|
+
role: "draft",
|
|
288
|
+
};
|
|
289
|
+
const messages = [
|
|
290
|
+
{ role: "user", content: "请根据各模型的输出,合并生成一份最终文档。" },
|
|
291
|
+
];
|
|
292
|
+
let buffer = "";
|
|
293
|
+
try {
|
|
294
|
+
const stream = runTurn({
|
|
295
|
+
modelName: mergerName,
|
|
296
|
+
config: mergerConfig,
|
|
297
|
+
messages,
|
|
298
|
+
systemPrompt,
|
|
299
|
+
tools: [],
|
|
300
|
+
registry: new ToolRegistry(),
|
|
301
|
+
permission: new PermissionManager(),
|
|
302
|
+
worktreePath: process.cwd(),
|
|
303
|
+
});
|
|
304
|
+
for await (const event of stream) {
|
|
305
|
+
if (event.type === "text") {
|
|
306
|
+
buffer += event.content;
|
|
307
|
+
yield {
|
|
308
|
+
type: "text",
|
|
309
|
+
round: 1,
|
|
310
|
+
totalRounds: 1,
|
|
311
|
+
modelName: mergerName,
|
|
312
|
+
role: "draft",
|
|
313
|
+
content: event.content,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
else if (event.type === "error") {
|
|
317
|
+
buffer += `\n[错误: ${event.message}]`;
|
|
318
|
+
yield {
|
|
319
|
+
type: "text",
|
|
320
|
+
round: 1,
|
|
321
|
+
totalRounds: 1,
|
|
322
|
+
modelName: mergerName,
|
|
323
|
+
role: "draft",
|
|
324
|
+
content: `\n[错误: ${event.message}]`,
|
|
325
|
+
};
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
yield {
|
|
332
|
+
type: "error",
|
|
333
|
+
round: 1,
|
|
334
|
+
totalRounds: 1,
|
|
335
|
+
modelName: mergerName,
|
|
336
|
+
error: err.message,
|
|
337
|
+
};
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
yield {
|
|
341
|
+
type: "round_end",
|
|
342
|
+
round: 1,
|
|
343
|
+
totalRounds: 1,
|
|
344
|
+
modelName: mergerName,
|
|
345
|
+
role: "draft",
|
|
346
|
+
document: buffer,
|
|
347
|
+
};
|
|
348
|
+
// Yield to the event loop so the UI renders before "done"
|
|
349
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
350
|
+
yield {
|
|
351
|
+
type: "done",
|
|
352
|
+
round: 1,
|
|
353
|
+
totalRounds: 1,
|
|
354
|
+
document: buffer,
|
|
355
|
+
};
|
|
356
|
+
}
|
package/dist/core/session.d.ts
CHANGED
|
@@ -28,7 +28,8 @@ export declare class Session {
|
|
|
28
28
|
addAssistantMessage(modelName: string, content: string): void;
|
|
29
29
|
/** Append tool result to a model's history */
|
|
30
30
|
addToolResult(modelName: string, toolCallId: string, result: string): void;
|
|
31
|
-
|
|
31
|
+
setTarget(target: TargetMode): void;
|
|
32
|
+
/** Cycle Tab through unmuted targets: broadcast → model1 → model2 → ... → broadcast */
|
|
32
33
|
cycleTarget(): TargetMode;
|
|
33
34
|
jumpToModel(modelName: string): void;
|
|
34
35
|
jumpToBroadcast(): void;
|
|
@@ -38,3 +39,4 @@ export declare class Session {
|
|
|
38
39
|
toJSON(): SessionSnapshot;
|
|
39
40
|
private findModel;
|
|
40
41
|
}
|
|
42
|
+
export declare function contextLimitForModel(provider: string, explicit?: number): number;
|
package/dist/core/session.js
CHANGED
|
@@ -23,7 +23,7 @@ export class Session {
|
|
|
23
23
|
buffer: "",
|
|
24
24
|
isStreaming: false,
|
|
25
25
|
usage: { input: 0, output: 0 },
|
|
26
|
-
contextLimit: contextLimitForModel(mc?.
|
|
26
|
+
contextLimit: contextLimitForModel(mc?.provider ?? "", mc?.context_limit),
|
|
27
27
|
};
|
|
28
28
|
});
|
|
29
29
|
this.state = {
|
|
@@ -71,18 +71,22 @@ export class Session {
|
|
|
71
71
|
m.messages.push({ role: "tool", content: result, tool_call_id: toolCallId });
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
-
|
|
74
|
+
setTarget(target) {
|
|
75
|
+
this.state.targetMode = target;
|
|
76
|
+
}
|
|
77
|
+
/** Cycle Tab through unmuted targets: broadcast → model1 → model2 → ... → broadcast */
|
|
75
78
|
cycleTarget() {
|
|
76
79
|
const current = this.state.targetMode;
|
|
80
|
+
const unmuted = this.state.models.filter((m) => !m.muted);
|
|
77
81
|
if (current.type === "broadcast") {
|
|
78
|
-
const first =
|
|
82
|
+
const first = unmuted[0];
|
|
79
83
|
this.state.targetMode = first
|
|
80
84
|
? { type: "directed", modelName: first.name }
|
|
81
85
|
: current;
|
|
82
86
|
}
|
|
83
87
|
else {
|
|
84
|
-
const idx =
|
|
85
|
-
const next =
|
|
88
|
+
const idx = unmuted.findIndex((m) => m.name === current.modelName);
|
|
89
|
+
const next = unmuted[idx + 1];
|
|
86
90
|
this.state.targetMode = next
|
|
87
91
|
? { type: "directed", modelName: next.name }
|
|
88
92
|
: { type: "broadcast" };
|
|
@@ -138,18 +142,17 @@ export class Session {
|
|
|
138
142
|
return this.state.models.find((m) => m.name === name);
|
|
139
143
|
}
|
|
140
144
|
}
|
|
141
|
-
function contextLimitForModel(
|
|
145
|
+
export function contextLimitForModel(provider, explicit) {
|
|
142
146
|
if (explicit && explicit > 0)
|
|
143
147
|
return explicit;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return 128000;
|
|
148
|
-
|
|
149
|
-
return
|
|
150
|
-
|
|
151
|
-
return
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return 128000;
|
|
148
|
+
// Sensible defaults per provider. Users can override with context_limit in config.
|
|
149
|
+
switch (provider) {
|
|
150
|
+
case "anthropic": return 200000;
|
|
151
|
+
case "openai": return 128000;
|
|
152
|
+
case "google": return 1048576;
|
|
153
|
+
case "deepseek": return 1048576;
|
|
154
|
+
case "minimax": return 1048576;
|
|
155
|
+
case "ollama": return 128000;
|
|
156
|
+
default: return 128000;
|
|
157
|
+
}
|
|
155
158
|
}
|
package/dist/core/turn.d.ts
CHANGED
|
@@ -29,3 +29,5 @@ export interface TurnResult {
|
|
|
29
29
|
* have been pushed into ctx.messages — the caller only needs to persist.
|
|
30
30
|
*/
|
|
31
31
|
export declare function runTurn(ctx: TurnContext): AsyncGenerator<StreamEvent>;
|
|
32
|
+
/** Turn a tool name + args into a human-readable action label. */
|
|
33
|
+
export declare function friendlyToolLabel(name: string, args: Record<string, unknown>): string;
|
package/dist/core/turn.js
CHANGED
|
@@ -65,15 +65,19 @@ export async function* runTurn(ctx) {
|
|
|
65
65
|
});
|
|
66
66
|
// Execute tools and feed results back
|
|
67
67
|
for (const tc of pendingToolCalls) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
if (!tc.name || tc.name.trim() === "") {
|
|
69
|
+
const errMsg = "Tool call with empty name — skipped";
|
|
70
|
+
ctx.messages.push({ role: "tool", content: errMsg, tool_call_id: tc.id });
|
|
71
|
+
allText.push(errMsg + "\n");
|
|
72
|
+
yield { type: "text", content: errMsg + "\n" };
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
71
75
|
let args;
|
|
72
76
|
try {
|
|
73
|
-
args = JSON.parse(tc.arguments);
|
|
77
|
+
args = JSON.parse(tc.arguments || "{}");
|
|
74
78
|
}
|
|
75
79
|
catch {
|
|
76
|
-
const errMsg = `Failed to parse tool arguments: ${tc.arguments}`;
|
|
80
|
+
const errMsg = `Failed to parse tool arguments: ${tc.arguments || "(empty)"}`;
|
|
77
81
|
ctx.messages.push({ role: "tool", content: errMsg, tool_call_id: tc.id });
|
|
78
82
|
allText.push(errMsg + "\n");
|
|
79
83
|
yield { type: "text", content: errMsg + "\n" };
|
|
@@ -87,6 +91,10 @@ export async function* runTurn(ctx) {
|
|
|
87
91
|
yield { type: "text", content: errMsg + "\n" };
|
|
88
92
|
continue;
|
|
89
93
|
}
|
|
94
|
+
// Friendly label — shows what the model is doing in plain language
|
|
95
|
+
const label = friendlyToolLabel(tc.name, args);
|
|
96
|
+
allText.push(label);
|
|
97
|
+
yield { type: "text", content: label };
|
|
90
98
|
let result;
|
|
91
99
|
try {
|
|
92
100
|
result = await ctx.registry.execute(tc.name, args, ctx.worktreePath);
|
|
@@ -110,3 +118,22 @@ export async function* runTurn(ctx) {
|
|
|
110
118
|
provider?.abort();
|
|
111
119
|
}
|
|
112
120
|
}
|
|
121
|
+
/** Turn a tool name + args into a human-readable action label. */
|
|
122
|
+
export function friendlyToolLabel(name, args) {
|
|
123
|
+
switch (name) {
|
|
124
|
+
case "bash":
|
|
125
|
+
return `\n$ ${args.command ?? "(no command)"}\n`;
|
|
126
|
+
case "read_file":
|
|
127
|
+
return `\nReading ${args.file_path ?? "(?)"}\n`;
|
|
128
|
+
case "write_file":
|
|
129
|
+
return `\nWriting ${args.file_path ?? "(?)"}\n`;
|
|
130
|
+
case "edit_file":
|
|
131
|
+
return `\nEditing ${args.file_path ?? "(?)"}\n`;
|
|
132
|
+
case "glob":
|
|
133
|
+
return `\nFinding ${args.pattern ?? "(?)"}\n`;
|
|
134
|
+
case "grep":
|
|
135
|
+
return `\nSearching "${args.pattern ?? "(?)"}"\n`;
|
|
136
|
+
default:
|
|
137
|
+
return `\nRunning ${name}...\n`;
|
|
138
|
+
}
|
|
139
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,62 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { render } from "ink";
|
|
4
|
-
import * as fs from "node:fs";
|
|
5
|
-
import * as path from "node:path";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
4
|
import { App } from "./ui/app.js";
|
|
8
5
|
import { listSessions } from "./persistence/session.js";
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
try {
|
|
12
|
-
const pkgPath = path.join(__dirname, "..", "package.json");
|
|
13
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
14
|
-
return pkg.version ?? "0.1.0";
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
return "0.1.0";
|
|
18
|
-
}
|
|
19
|
-
})();
|
|
20
|
-
const HELP = `Arena — Multi-Model AI Coding Assistant
|
|
21
|
-
|
|
22
|
-
Usage:
|
|
23
|
-
arena [options]
|
|
24
|
-
|
|
25
|
-
Options:
|
|
26
|
-
--new Start a new session (default)
|
|
27
|
-
--resume <id> Resume a saved session
|
|
28
|
-
--list List saved sessions
|
|
29
|
-
--help Show this help
|
|
30
|
-
--version Show version`;
|
|
31
|
-
function parseArgs() {
|
|
32
|
-
const args = process.argv.slice(2);
|
|
33
|
-
if (args.includes("--help") || args.includes("-h")) {
|
|
34
|
-
return { listOnly: false, showHelp: true, showVersion: false };
|
|
35
|
-
}
|
|
36
|
-
if (args.includes("--version") || args.includes("-v")) {
|
|
37
|
-
return { listOnly: false, showHelp: false, showVersion: true };
|
|
38
|
-
}
|
|
39
|
-
const resumeIdx = args.indexOf("--resume");
|
|
40
|
-
if (resumeIdx >= 0 && args[resumeIdx + 1]) {
|
|
41
|
-
return {
|
|
42
|
-
sessionId: args[resumeIdx + 1],
|
|
43
|
-
listOnly: false,
|
|
44
|
-
showHelp: false,
|
|
45
|
-
showVersion: false,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
if (args.includes("--list") || args.includes("--list-sessions")) {
|
|
49
|
-
return { listOnly: true, showHelp: false, showVersion: false };
|
|
50
|
-
}
|
|
51
|
-
return { listOnly: false, showHelp: false, showVersion: false };
|
|
52
|
-
}
|
|
53
|
-
const { sessionId, listOnly, showHelp, showVersion } = parseArgs();
|
|
6
|
+
import { parseArgs, getPkgVersion, HELP } from "./cli/args.js";
|
|
7
|
+
const { sessionId, listOnly, showHelp, showVersion } = parseArgs(process.argv.slice(2));
|
|
54
8
|
if (showHelp) {
|
|
55
9
|
console.log(HELP);
|
|
56
10
|
process.exit(0);
|
|
57
11
|
}
|
|
58
12
|
if (showVersion) {
|
|
59
|
-
console.log(`
|
|
13
|
+
console.log(`multiarena v${getPkgVersion()}`);
|
|
60
14
|
process.exit(0);
|
|
61
15
|
}
|
|
62
16
|
if (listOnly) {
|
|
@@ -2,7 +2,7 @@ export declare class WorktreeManager {
|
|
|
2
2
|
private git;
|
|
3
3
|
private worktrees;
|
|
4
4
|
constructor(repoPath: string);
|
|
5
|
-
/** Clean up orphaned
|
|
5
|
+
/** Clean up orphaned multiarena branches and worktree directories from prior crashes. */
|
|
6
6
|
sweepOrphans(): Promise<number>;
|
|
7
7
|
setup(taskId: string, modelNames: string[]): Promise<Map<string, string>>;
|
|
8
8
|
getWorktreePath(modelName: string): string | undefined;
|
|
@@ -8,7 +8,7 @@ export class WorktreeManager {
|
|
|
8
8
|
constructor(repoPath) {
|
|
9
9
|
this.git = simpleGit(repoPath);
|
|
10
10
|
}
|
|
11
|
-
/** Clean up orphaned
|
|
11
|
+
/** Clean up orphaned multiarena branches and worktree directories from prior crashes. */
|
|
12
12
|
async sweepOrphans() {
|
|
13
13
|
let cleaned = 0;
|
|
14
14
|
// Parse registered worktrees from `git worktree list --porcelain`
|
|
@@ -23,7 +23,7 @@ export class WorktreeManager {
|
|
|
23
23
|
registeredPaths.add(currentPath);
|
|
24
24
|
}
|
|
25
25
|
else if (line.startsWith("branch ") && currentPath) {
|
|
26
|
-
// branch line looks like "branch refs/heads/
|
|
26
|
+
// branch line looks like "branch refs/heads/multiarena/..."
|
|
27
27
|
const ref = line.slice("branch ".length);
|
|
28
28
|
const branchName = ref.replace("refs/heads/", "");
|
|
29
29
|
registeredBranches.add(branchName);
|
|
@@ -33,10 +33,10 @@ export class WorktreeManager {
|
|
|
33
33
|
catch {
|
|
34
34
|
return cleaned;
|
|
35
35
|
}
|
|
36
|
-
// Remove orphaned
|
|
36
|
+
// Remove orphaned multiarena branches (branch exists but no worktree)
|
|
37
37
|
const branches = await this.git.branchLocal();
|
|
38
38
|
for (const branch of branches.all) {
|
|
39
|
-
if (!branch.startsWith("
|
|
39
|
+
if (!branch.startsWith("multiarena/"))
|
|
40
40
|
continue;
|
|
41
41
|
if (!registeredBranches.has(branch)) {
|
|
42
42
|
await this.git.deleteLocalBranch(branch, true).catch(() => { });
|
|
@@ -44,7 +44,7 @@ export class WorktreeManager {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
// Remove orphaned worktree directories (dir exists but not registered)
|
|
47
|
-
const arenaDir = path.join(os.tmpdir(), "
|
|
47
|
+
const arenaDir = path.join(os.tmpdir(), "multiarena-worktrees");
|
|
48
48
|
if (fs.existsSync(arenaDir)) {
|
|
49
49
|
let entries = [];
|
|
50
50
|
try {
|
|
@@ -67,10 +67,10 @@ export class WorktreeManager {
|
|
|
67
67
|
return cleaned;
|
|
68
68
|
}
|
|
69
69
|
async setup(taskId, modelNames) {
|
|
70
|
-
const baseName = `
|
|
70
|
+
const baseName = `multiarena/${taskId}`;
|
|
71
71
|
for (const name of modelNames) {
|
|
72
72
|
const branchName = `${baseName}-${name}`;
|
|
73
|
-
const worktreePath = path.join(os.tmpdir(), "
|
|
73
|
+
const worktreePath = path.join(os.tmpdir(), "multiarena-worktrees", `${taskId}-${name}`);
|
|
74
74
|
// Ensure the parent directory exists; git worktree add creates the leaf directory
|
|
75
75
|
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
76
76
|
// Remove leftover directory from a previous run that wasn't cleaned up
|
|
@@ -109,7 +109,7 @@ export class WorktreeManager {
|
|
|
109
109
|
fs.rmSync(wtPath, { recursive: true, force: true });
|
|
110
110
|
}
|
|
111
111
|
await this.git
|
|
112
|
-
.deleteLocalBranch(`
|
|
112
|
+
.deleteLocalBranch(`multiarena/${taskId}-${modelName}`, true)
|
|
113
113
|
.catch(() => { });
|
|
114
114
|
this.worktrees.delete(modelName);
|
|
115
115
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as os from "node:os";
|
|
4
|
-
const SESSIONS_DIR = path.join(os.homedir(), ".
|
|
4
|
+
const SESSIONS_DIR = path.join(os.homedir(), ".multiarena", "sessions");
|
|
5
5
|
export function saveSession(session) {
|
|
6
6
|
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
7
7
|
const filePath = path.join(SESSIONS_DIR, `${session.id}.json`);
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { Provider } from "../provider.js";
|
|
2
2
|
import { ChatRequest, StreamEvent } from "../types.js";
|
|
3
|
+
export interface ThinkFilterState {
|
|
4
|
+
inThink: boolean;
|
|
5
|
+
buf: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Filter think blocks from reasoning model output (DeepSeek-R1, MiniMax).
|
|
9
|
+
* Returns the text that should be yielded to the user, and updates state.
|
|
10
|
+
* Callers yield the returned text and replace their state with the returned state.
|
|
11
|
+
*/
|
|
12
|
+
export declare function filterThinkText(deltaText: string, state: ThinkFilterState): {
|
|
13
|
+
text: string;
|
|
14
|
+
state: ThinkFilterState;
|
|
15
|
+
};
|
|
16
|
+
/** Called when the stream ends (finish_reason = stop). Flush any buffered think content. */
|
|
17
|
+
export declare function flushThinkBuf(state: ThinkFilterState): string;
|
|
3
18
|
export declare class OpenAIProvider implements Provider {
|
|
4
19
|
private client;
|
|
5
20
|
private activeController;
|