u-foo 2.5.4 → 2.5.6
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 +1 -1
- package/src/agents/launch/ptyRunner.js +2 -2
- package/src/agents/launch/ptyWrapper.js +2 -2
- package/src/agents/prompts/native/index.js +1 -1
- package/src/agents/prompts/native/toolDescriptions/bash.js +1 -1
- package/src/agents/prompts/native/toolDescriptions/read.js +3 -2
- package/src/app/chat/multiWindow/renderer.js +17 -30
- package/src/code/agent.js +34 -20
- package/src/code/launcher/ucode.js +5 -3
- package/src/code/launcher/ucodeDoctor.js +6 -3
- package/src/code/nativeRunner.js +31 -4
- package/src/code/taskDecomposer.js +11 -2
- package/src/code/tools/bash.js +19 -2
- package/src/code/tools/read.js +20 -2
- package/src/ui/ink/ChatApp.js +62 -71
- package/src/ui/ink/chatLogModel.js +202 -0
- package/src/ui/ink/chatReducer.js +7 -3
package/package.json
CHANGED
|
@@ -61,8 +61,8 @@ function stripAnsi(text) {
|
|
|
61
61
|
function rewriteTitleOscPrefix(text, prefix) {
|
|
62
62
|
if (!prefix || !text) return text;
|
|
63
63
|
return String(text).replace(/\x1b\]([012]);([^\x07\x1b]*)(\x07|\x1b\\)/g, (match, code, title, terminator) => {
|
|
64
|
-
if (!title || title.startsWith(`${prefix}
|
|
65
|
-
return `\x1b]${code};${prefix}
|
|
64
|
+
if (!title || title.startsWith(`${prefix}: `)) return match;
|
|
65
|
+
return `\x1b]${code};${prefix}: ${title}${terminator}`;
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
|
|
@@ -211,8 +211,8 @@ class PtyWrapper {
|
|
|
211
211
|
if (!this.titlePrefix || !text) return text;
|
|
212
212
|
const prefix = this.titlePrefix;
|
|
213
213
|
return text.replace(/\x1b\]([012]);([^\x07\x1b]*)(\x07|\x1b\\)/g, (match, code, title, terminator) => {
|
|
214
|
-
if (!title || title.startsWith(`${prefix}
|
|
215
|
-
return `\x1b]${code};${prefix}
|
|
214
|
+
if (!title || title.startsWith(`${prefix}: `)) return match;
|
|
215
|
+
return `\x1b]${code};${prefix}: ${title}${terminator}`;
|
|
216
216
|
});
|
|
217
217
|
}
|
|
218
218
|
|
|
@@ -70,7 +70,7 @@ function getSystemPrompt({
|
|
|
70
70
|
// --- Dynamic sections (may change per session/turn) ---
|
|
71
71
|
const dynamicSectionDefs = [
|
|
72
72
|
systemPromptSection("ufoo", () => getUfooIntegrationSection()),
|
|
73
|
-
|
|
73
|
+
uncachedSection("environment", () =>
|
|
74
74
|
getEnvironmentSection({ workspaceRoot, model, provider }),
|
|
75
75
|
),
|
|
76
76
|
uncachedSection("skills", () => {
|
|
@@ -6,7 +6,7 @@ function getBashToolDescription() {
|
|
|
6
6
|
return `Run a single shell command in the workspace directory.
|
|
7
7
|
|
|
8
8
|
Usage notes:
|
|
9
|
-
- Default timeout is 60 seconds. Use timeoutMs to adjust for longer operations.
|
|
9
|
+
- Default timeout is 60 seconds. Use timeoutMs to adjust for longer operations (maximum 600 seconds; larger values are clamped).
|
|
10
10
|
- Do NOT use bash for file operations when a dedicated tool exists:
|
|
11
11
|
- Use read instead of cat/head/tail.
|
|
12
12
|
- Use write instead of echo/cat heredoc.
|
|
@@ -7,11 +7,12 @@ function getReadToolDescription() {
|
|
|
7
7
|
|
|
8
8
|
Usage notes:
|
|
9
9
|
- The path parameter is relative to the workspace root.
|
|
10
|
-
- By default reads the entire file.
|
|
10
|
+
- By default reads the entire file. Files larger than ~4MB are only partially read from the start; in that case truncated is true.
|
|
11
|
+
- Use startLine and endLine to read specific line ranges.
|
|
11
12
|
- Use maxBytes to limit the amount of data returned (default ~200KB).
|
|
12
13
|
- Cannot read directories — use bash with \`ls\` for that.
|
|
13
14
|
- Always read a file before editing it to understand its current content and structure.
|
|
14
|
-
-
|
|
15
|
+
- The content field contains the raw file text without line numbers. The result also includes totalLines (lines in the portion that was read) and truncated (true when the content was cut short by maxBytes or the large-file limit).`;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
module.exports = { READ_TOOL_NAME, getReadToolDescription };
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
const BOX = { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘", t: "┬", b: "┴", l: "├", r: "┤", x: "┼" };
|
|
2
|
+
const {
|
|
3
|
+
classifyChatLogLine,
|
|
4
|
+
compactContinuationIndent,
|
|
5
|
+
compactDividerLabel,
|
|
6
|
+
} = require("../../../ui/ink/chatLogModel");
|
|
2
7
|
|
|
3
8
|
function createRenderer(options = {}) {
|
|
4
9
|
const {
|
|
@@ -239,49 +244,31 @@ function createRenderer(options = {}) {
|
|
|
239
244
|
return str.slice(0, i);
|
|
240
245
|
}
|
|
241
246
|
|
|
242
|
-
function classifyLogLine(raw = "") {
|
|
243
|
-
const clean = stripControl(raw)
|
|
244
|
-
.replace(/\{\/?[^{}\n]+\}/g, "")
|
|
245
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
246
|
-
.replace(/`([^`]+)`/g, "$1");
|
|
247
|
-
const trimmed = clean.trim();
|
|
248
|
-
if (!trimmed) return { kind: "spacer", text: " " };
|
|
249
|
-
if (/^[█▀▄ ]+$/.test(trimmed) || /^ufoo chat/i.test(trimmed)) return { kind: "banner", text: clean };
|
|
250
|
-
if (/^───.*───$/.test(trimmed)) return { kind: "divider", text: clean };
|
|
251
|
-
if (/^(error:|✗|failed\b)/i.test(trimmed)) return { kind: "error", marker: "!", speaker: "error", body: clean.replace(/^(error:\s*)/i, "") };
|
|
252
|
-
if (/^(✓|✔|done\b|closed\b)/i.test(trimmed)) return { kind: "success", marker: "✓", body: clean.replace(/^[✓✔]\s*/, "") };
|
|
253
|
-
const dot = clean.match(/^([^·:\n]{1,34})\s+·\s+(.*)$/);
|
|
254
|
-
if (dot) {
|
|
255
|
-
const speaker = dot[1].trim();
|
|
256
|
-
return {
|
|
257
|
-
kind: speaker.toLowerCase() === "ufoo" ? "assistant" : "agent",
|
|
258
|
-
marker: speaker.toLowerCase() === "ufoo" ? "◆" : "●",
|
|
259
|
-
speaker,
|
|
260
|
-
body: dot[2] || " ",
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
const colon = clean.match(/^([A-Za-z0-9_.:@/-]{1,34}):\s+(.*)$/);
|
|
264
|
-
if (colon) return { kind: "agent", marker: "●", speaker: colon[1], body: colon[2] || " " };
|
|
265
|
-
return { kind: "plain", marker: "│", body: clean };
|
|
266
|
-
}
|
|
267
|
-
|
|
268
247
|
function formatChatLogLine(raw = "", width = 80) {
|
|
269
|
-
const row =
|
|
248
|
+
const row = classifyChatLogLine(stripControl(raw));
|
|
270
249
|
const reset = "\x1b[0m";
|
|
271
250
|
if (row.kind === "spacer") return " ".repeat(width);
|
|
272
|
-
if (row.kind === "banner") return `\x1b[36;1m${truncateVisible(row.
|
|
273
|
-
if (row.kind === "divider") return `\x1b[90m${truncateVisible(row.
|
|
251
|
+
if (row.kind === "banner") return `\x1b[36;1m${truncateVisible(row.body, width)}${reset}`;
|
|
252
|
+
if (row.kind === "divider") return `\x1b[90m${truncateVisible(` ${compactDividerLabel(row.body)}`, width)}${reset}`;
|
|
274
253
|
|
|
275
254
|
const palette = {
|
|
276
255
|
assistant: { marker: "\x1b[36m", speaker: "\x1b[37;1m", body: "" },
|
|
277
256
|
agent: { marker: "\x1b[36m", speaker: "\x1b[36m", body: "" },
|
|
278
257
|
error: { marker: "\x1b[31;1m", speaker: "\x1b[31m", body: "\x1b[31m" },
|
|
279
258
|
success: { marker: "\x1b[32m", speaker: "\x1b[32m", body: "\x1b[32m" },
|
|
259
|
+
meta: { marker: "\x1b[90m", speaker: "\x1b[90m", body: "\x1b[90m" },
|
|
280
260
|
plain: { marker: "\x1b[90m", speaker: "\x1b[90m", body: "" },
|
|
281
261
|
};
|
|
282
262
|
const colors = palette[row.kind] || palette.plain;
|
|
283
263
|
const speaker = row.speaker ? `${colors.speaker}${row.speaker}${reset}\x1b[90m · ${reset}` : "";
|
|
284
|
-
const
|
|
264
|
+
const markerGlyph = row.kind === "agent" ? "●" : row.marker;
|
|
265
|
+
const marker = markerGlyph
|
|
266
|
+
? `${colors.marker}${markerGlyph}${reset} `
|
|
267
|
+
: " ";
|
|
268
|
+
const body = row.kind === "plain"
|
|
269
|
+
? compactContinuationIndent(row.body || row.text || " ")
|
|
270
|
+
: (row.body || row.text || " ");
|
|
271
|
+
const line = `${marker}${speaker}${colors.body || ""}${body}${reset}`;
|
|
285
272
|
return truncateVisible(line, width);
|
|
286
273
|
}
|
|
287
274
|
|
package/src/code/agent.js
CHANGED
|
@@ -2,7 +2,7 @@ const readline = require("readline");
|
|
|
2
2
|
const fs = require("fs");
|
|
3
3
|
const path = require("path");
|
|
4
4
|
const { execSync } = require("child_process");
|
|
5
|
-
const { runToolCall } = require("./dispatch");
|
|
5
|
+
const { runToolCall, TOOL_NAMES } = require("./dispatch");
|
|
6
6
|
const { runNativeAgentTask } = require("./nativeRunner");
|
|
7
7
|
const {
|
|
8
8
|
runDecomposedTask,
|
|
@@ -440,6 +440,7 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
|
|
|
440
440
|
const timeoutMs = Number.isFinite(state.timeoutMs) ? state.timeoutMs : 600000;
|
|
441
441
|
let streamed = false;
|
|
442
442
|
let streamLastChar = "";
|
|
443
|
+
let toolEventsThisAttempt = 0;
|
|
443
444
|
const onDelta = typeof options.onDelta === "function"
|
|
444
445
|
? options.onDelta
|
|
445
446
|
: null;
|
|
@@ -495,23 +496,27 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
|
|
|
495
496
|
: runNativeAgentTask;
|
|
496
497
|
const onPhase = typeof options.onPhase === "function" ? options.onPhase : null;
|
|
497
498
|
const onThinkingDelta = typeof options.onThinkingDelta === "function" ? options.onThinkingDelta : null;
|
|
498
|
-
const invokeNative = (sessionIdValue = "", timeoutOverrideMs = timeoutMs) =>
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
499
|
+
const invokeNative = (sessionIdValue = "", timeoutOverrideMs = timeoutMs) => {
|
|
500
|
+
toolEventsThisAttempt = 0;
|
|
501
|
+
return runNativeAgentImpl({
|
|
502
|
+
workspaceRoot,
|
|
503
|
+
provider,
|
|
504
|
+
model,
|
|
505
|
+
prompt: effectiveTaskPrompt,
|
|
506
|
+
systemPrompt: systemContext,
|
|
507
|
+
messages: Array.isArray(state.nlMessages) ? state.nlMessages : [],
|
|
508
|
+
sessionId: String(sessionIdValue || ""),
|
|
509
|
+
timeoutMs: timeoutOverrideMs,
|
|
510
|
+
onStreamDelta: onStream,
|
|
511
|
+
onThinkingDelta,
|
|
512
|
+
onPhase,
|
|
513
|
+
onToolEvent: (event) => {
|
|
514
|
+
toolEventsThisAttempt += 1;
|
|
515
|
+
pushToolLog(event);
|
|
516
|
+
},
|
|
517
|
+
signal: options.signal,
|
|
518
|
+
});
|
|
519
|
+
};
|
|
515
520
|
|
|
516
521
|
try {
|
|
517
522
|
let cliRes;
|
|
@@ -551,7 +556,9 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
|
|
|
551
556
|
|
|
552
557
|
if (!cliRes || cliRes.ok === false) {
|
|
553
558
|
const errMsg = String((cliRes && cliRes.error) || "");
|
|
554
|
-
|
|
559
|
+
// Only replay the whole task when this attempt ran no tool calls;
|
|
560
|
+
// retrying after executed write/edit/bash steps would replay side effects.
|
|
561
|
+
if (isCliTimeoutError(errMsg) && toolEventsThisAttempt === 0) {
|
|
555
562
|
const extendedTimeoutMs = computeExtendedTimeout(timeoutMs);
|
|
556
563
|
cliRes = await invokeNative(String(state.sessionId || ""), extendedTimeoutMs);
|
|
557
564
|
}
|
|
@@ -1317,6 +1324,13 @@ function runSingleCommand(line = "", workspaceRoot = process.cwd()) {
|
|
|
1317
1324
|
};
|
|
1318
1325
|
}
|
|
1319
1326
|
const tool = String(match[2] || "").trim().toLowerCase();
|
|
1327
|
+
if (String(match[1]).toLowerCase() === "run" && !TOOL_NAMES.includes(tool)) {
|
|
1328
|
+
// Natural language like "run the tests" is not a tool invocation.
|
|
1329
|
+
return {
|
|
1330
|
+
kind: "nl",
|
|
1331
|
+
task: text,
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1320
1334
|
const payload = String(match[3] || "").trim();
|
|
1321
1335
|
let args = {};
|
|
1322
1336
|
try {
|
|
@@ -1546,7 +1560,7 @@ async function runUcodeCoreAgent({
|
|
|
1546
1560
|
}
|
|
1547
1561
|
}
|
|
1548
1562
|
if (result.kind === "resume") {
|
|
1549
|
-
const resumed = resumeSessionState(state, result.sessionId, workspaceRoot);
|
|
1563
|
+
const resumed = resumeSessionState(state, result.sessionId, state.workspaceRoot || resolvedWorkspaceRoot);
|
|
1550
1564
|
if (!resumed.ok) {
|
|
1551
1565
|
stdout.write(`Error: ${resumed.error}\n`);
|
|
1552
1566
|
} else {
|
|
@@ -224,8 +224,10 @@ function readLastArgValue(args = [], flag = "") {
|
|
|
224
224
|
if (!item) continue;
|
|
225
225
|
if (item === flag) {
|
|
226
226
|
const next = String(args[i + 1] || "").trim();
|
|
227
|
-
if (next
|
|
228
|
-
|
|
227
|
+
if (next && !next.startsWith("--")) {
|
|
228
|
+
value = next;
|
|
229
|
+
i += 1;
|
|
230
|
+
}
|
|
229
231
|
continue;
|
|
230
232
|
}
|
|
231
233
|
if (item.startsWith(`${flag}=`)) {
|
|
@@ -342,7 +344,7 @@ function resolveUcodeLaunch({
|
|
|
342
344
|
const promptFile = String(
|
|
343
345
|
env.UFOO_UCODE_PROMPT_FILE
|
|
344
346
|
|| config.ucodePromptFile
|
|
345
|
-
||
|
|
347
|
+
|| ""
|
|
346
348
|
).trim();
|
|
347
349
|
const bootstrapFile = String(
|
|
348
350
|
env.UFOO_UCODE_BOOTSTRAP_FILE
|
|
@@ -3,7 +3,6 @@ const path = require("path");
|
|
|
3
3
|
const { loadConfig } = require("../../config");
|
|
4
4
|
const {
|
|
5
5
|
resolveNativeFallbackCommand,
|
|
6
|
-
defaultBundledPromptFile,
|
|
7
6
|
} = require("./ucode");
|
|
8
7
|
const { inspectUcodeBuildSetup } = require("./ucodeBuild");
|
|
9
8
|
const { inspectUcodeRuntimeConfig } = require("./ucodeRuntimeConfig");
|
|
@@ -30,7 +29,7 @@ function inspectUcodeSetup({
|
|
|
30
29
|
const promptFile = String(
|
|
31
30
|
env.UFOO_UCODE_PROMPT_FILE
|
|
32
31
|
|| config.ucodePromptFile
|
|
33
|
-
||
|
|
32
|
+
|| ""
|
|
34
33
|
).trim();
|
|
35
34
|
const bootstrapFile = String(
|
|
36
35
|
env.UFOO_UCODE_BOOTSTRAP_FILE
|
|
@@ -130,7 +129,7 @@ function formatUcodeDoctor(result = {}) {
|
|
|
130
129
|
if (result.configuredCommand) {
|
|
131
130
|
lines.push(`configured command override (ignored in native-only mode): ${result.configuredCommand}`);
|
|
132
131
|
}
|
|
133
|
-
lines.push(`prompt: ${result.promptFile || "(none)"}${result.promptExists ? "" : "
|
|
132
|
+
lines.push(`prompt: ${result.promptFile || "(none)"}${result.promptFile && !result.promptExists ? " (missing)" : ""}`);
|
|
134
133
|
lines.push(`bootstrap: ${result.bootstrapFile || "(none)"}`);
|
|
135
134
|
if (result.build && result.build.coreRoot) {
|
|
136
135
|
lines.push(`build: ${result.build.distCliExists ? "ready" : "missing dist"}`);
|
|
@@ -170,6 +169,10 @@ function prepareAndInspectUcode({
|
|
|
170
169
|
projectRoot: inspection.projectRoot,
|
|
171
170
|
promptFile: inspection.promptFile,
|
|
172
171
|
targetFile: inspection.bootstrapFile,
|
|
172
|
+
// The native core already injects the ufoo protocol via the modular
|
|
173
|
+
// prompt (src/agents/prompts/native/ufoo.js); inlining it into the
|
|
174
|
+
// bootstrap file would append the same protocol text a second time.
|
|
175
|
+
includeDefaultProtocol: false,
|
|
173
176
|
});
|
|
174
177
|
return {
|
|
175
178
|
...inspection,
|
package/src/code/nativeRunner.js
CHANGED
|
@@ -14,6 +14,11 @@ const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
|
|
|
14
14
|
// for non-trivial tasks while still catching runaway loops. Override via env.
|
|
15
15
|
const DEFAULT_MAX_NATIVE_TOOL_CALLS = 100;
|
|
16
16
|
const DEFAULT_MAX_NATIVE_TOOL_ERRORS = 5;
|
|
17
|
+
// Anthropic Messages rejects max_tokens above the model's real cap (64K on
|
|
18
|
+
// current models), so the transports use different defaults. Override either
|
|
19
|
+
// via UFOO_UCODE_MAX_TOKENS (positive integer).
|
|
20
|
+
const DEFAULT_OPENAI_MAX_TOKENS = 131072;
|
|
21
|
+
const DEFAULT_ANTHROPIC_MAX_TOKENS = 64000;
|
|
17
22
|
|
|
18
23
|
function nowMs() {
|
|
19
24
|
return Date.now();
|
|
@@ -38,6 +43,10 @@ function resolveNativeToolBudget(env = process.env) {
|
|
|
38
43
|
};
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
function resolveMaxTokens(fallback) {
|
|
47
|
+
return normalizePositiveInt(process.env.UFOO_UCODE_MAX_TOKENS, fallback);
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
function enforceNativeToolBudget({
|
|
42
51
|
toolCallsExecuted = 0,
|
|
43
52
|
toolErrors = 0,
|
|
@@ -46,7 +55,7 @@ function enforceNativeToolBudget({
|
|
|
46
55
|
lastTool = "",
|
|
47
56
|
lastError = "",
|
|
48
57
|
} = {}) {
|
|
49
|
-
if (toolCallsExecuted
|
|
58
|
+
if (toolCallsExecuted >= maxToolCalls) {
|
|
50
59
|
throw new Error(`tool call budget exceeded (${maxToolCalls})`);
|
|
51
60
|
}
|
|
52
61
|
if (toolErrors >= maxToolErrors) {
|
|
@@ -534,7 +543,7 @@ async function runOpenAiLikeTurn({
|
|
|
534
543
|
} = {}) {
|
|
535
544
|
const payload = {
|
|
536
545
|
model,
|
|
537
|
-
max_tokens:
|
|
546
|
+
max_tokens: resolveMaxTokens(DEFAULT_OPENAI_MAX_TOKENS),
|
|
538
547
|
messages,
|
|
539
548
|
tools: buildCoreToolSpecs(),
|
|
540
549
|
tool_choice: "auto",
|
|
@@ -588,6 +597,8 @@ async function runOpenAiLikeTurn({
|
|
|
588
597
|
let rawBuffer = "";
|
|
589
598
|
let responseText = "";
|
|
590
599
|
const announcedToolNames = new Set();
|
|
600
|
+
let nextSyntheticIndex = 0;
|
|
601
|
+
let lastSyntheticIndex = -1;
|
|
591
602
|
|
|
592
603
|
while (true) {
|
|
593
604
|
const { done, value } = await reader.read();
|
|
@@ -633,7 +644,23 @@ async function runOpenAiLikeTurn({
|
|
|
633
644
|
|
|
634
645
|
if (Array.isArray(delta.tool_calls)) {
|
|
635
646
|
for (const callPart of delta.tool_calls) {
|
|
636
|
-
|
|
647
|
+
let index;
|
|
648
|
+
if (Number.isFinite(callPart.index)) {
|
|
649
|
+
index = callPart.index;
|
|
650
|
+
} else if (typeof callPart.id === "string" && callPart.id) {
|
|
651
|
+
// Provider omitted index: a chunk carrying an id starts a new
|
|
652
|
+
// call, so give it its own synthetic index instead of
|
|
653
|
+
// collapsing every call into slot 0.
|
|
654
|
+
while (toolCallMap.has(nextSyntheticIndex)) nextSyntheticIndex += 1;
|
|
655
|
+
index = nextSyntheticIndex;
|
|
656
|
+
nextSyntheticIndex += 1;
|
|
657
|
+
lastSyntheticIndex = index;
|
|
658
|
+
} else if (lastSyntheticIndex >= 0) {
|
|
659
|
+
// No index and no id: continuation of the latest synthetic call.
|
|
660
|
+
index = lastSyntheticIndex;
|
|
661
|
+
} else {
|
|
662
|
+
index = 0;
|
|
663
|
+
}
|
|
637
664
|
const previous = toolCallMap.get(index) || {
|
|
638
665
|
id: "",
|
|
639
666
|
type: "function",
|
|
@@ -755,7 +782,7 @@ async function runAnthropicTurn({
|
|
|
755
782
|
} = {}) {
|
|
756
783
|
const payload = {
|
|
757
784
|
model,
|
|
758
|
-
max_tokens:
|
|
785
|
+
max_tokens: resolveMaxTokens(DEFAULT_ANTHROPIC_MAX_TOKENS),
|
|
759
786
|
messages,
|
|
760
787
|
tools: buildAnthropicToolSpecs(),
|
|
761
788
|
stream: true,
|
|
@@ -280,6 +280,15 @@ function compileSummary(results) {
|
|
|
280
280
|
return summaryParts.join("\n\n");
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Quote a value for safe inclusion in a shell command (single-quote style).
|
|
285
|
+
* Kept local to avoid a circular dependency with agent.js.
|
|
286
|
+
*/
|
|
287
|
+
function shellQuote(value = "") {
|
|
288
|
+
const text = String(value == null ? "" : value);
|
|
289
|
+
return `'${text.replace(/'/g, `'\"'\"'`)}'`;
|
|
290
|
+
}
|
|
291
|
+
|
|
283
292
|
/**
|
|
284
293
|
* Create a progress reporter that sends updates via bus
|
|
285
294
|
*/
|
|
@@ -297,10 +306,10 @@ function createBusProgressReporter(shell, publisher) {
|
|
|
297
306
|
|
|
298
307
|
if (progress.type === "step_start") {
|
|
299
308
|
const message = `⏳ ${progress.name} (${progress.current}/${progress.total})`;
|
|
300
|
-
shell(`ufoo bus send ${publisher} ${JSON.stringify(message)}`);
|
|
309
|
+
shell(`ufoo bus send ${shellQuote(publisher)} ${shellQuote(JSON.stringify(message))}`);
|
|
301
310
|
} else if (progress.type === "step_complete" && progress.success) {
|
|
302
311
|
const message = `✅ ${progress.name} completed`;
|
|
303
|
-
shell(`ufoo bus send ${publisher} ${JSON.stringify(message)}`);
|
|
312
|
+
shell(`ufoo bus send ${shellQuote(publisher)} ${shellQuote(JSON.stringify(message))}`);
|
|
304
313
|
}
|
|
305
314
|
};
|
|
306
315
|
}
|
package/src/code/tools/bash.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const { spawnSync } = require("child_process");
|
|
2
2
|
const { normalizeWorkspaceRoot } = require("./common");
|
|
3
3
|
|
|
4
|
+
const MAX_TIMEOUT_MS = 600000;
|
|
5
|
+
|
|
4
6
|
function runBashTool(input = {}, options = {}) {
|
|
5
7
|
try {
|
|
6
8
|
const command = String(input.command || "").trim();
|
|
@@ -11,7 +13,9 @@ function runBashTool(input = {}, options = {}) {
|
|
|
11
13
|
};
|
|
12
14
|
}
|
|
13
15
|
const workspaceRoot = normalizeWorkspaceRoot(options.workspaceRoot, options.cwd);
|
|
14
|
-
const timeoutMs = Number.isFinite(input.timeoutMs)
|
|
16
|
+
const timeoutMs = Number.isFinite(input.timeoutMs)
|
|
17
|
+
? Math.min(MAX_TIMEOUT_MS, Math.max(100, Math.floor(input.timeoutMs)))
|
|
18
|
+
: 60000;
|
|
15
19
|
const result = spawnSync(command, {
|
|
16
20
|
cwd: workspaceRoot,
|
|
17
21
|
shell: true,
|
|
@@ -25,13 +29,26 @@ function runBashTool(input = {}, options = {}) {
|
|
|
25
29
|
ok: false,
|
|
26
30
|
workspaceRoot,
|
|
27
31
|
code: typeof result.status === "number" ? result.status : -1,
|
|
32
|
+
signal: result.signal || "",
|
|
28
33
|
stdout: String(result.stdout || ""),
|
|
29
34
|
stderr: String(result.stderr || ""),
|
|
30
35
|
error: result.error.message || "bash failed",
|
|
31
36
|
};
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
|
|
39
|
+
if (typeof result.status !== "number") {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
workspaceRoot,
|
|
43
|
+
code: -1,
|
|
44
|
+
signal: result.signal || "",
|
|
45
|
+
stdout: String(result.stdout || ""),
|
|
46
|
+
stderr: String(result.stderr || ""),
|
|
47
|
+
error: `command killed by signal ${result.signal || "unknown"}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const code = result.status;
|
|
35
52
|
return {
|
|
36
53
|
ok: code === 0,
|
|
37
54
|
workspaceRoot,
|
package/src/code/tools/read.js
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const { resolveWorkspacePath } = require("./common");
|
|
3
3
|
|
|
4
|
+
const MAX_FULL_READ_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
function readFileBounded(resolved) {
|
|
7
|
+
const stat = fs.statSync(resolved);
|
|
8
|
+
if (stat.size <= MAX_FULL_READ_BYTES) {
|
|
9
|
+
return { raw: fs.readFileSync(resolved, "utf8"), partial: false };
|
|
10
|
+
}
|
|
11
|
+
const fd = fs.openSync(resolved, "r");
|
|
12
|
+
try {
|
|
13
|
+
const buffer = Buffer.alloc(MAX_FULL_READ_BYTES);
|
|
14
|
+
const bytesRead = fs.readSync(fd, buffer, 0, MAX_FULL_READ_BYTES, 0);
|
|
15
|
+
return { raw: buffer.slice(0, bytesRead).toString("utf8"), partial: true };
|
|
16
|
+
} finally {
|
|
17
|
+
fs.closeSync(fd);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
4
21
|
function runReadTool(input = {}, options = {}) {
|
|
5
22
|
try {
|
|
6
23
|
const filePath = String(input.path || input.file || "").trim();
|
|
@@ -9,13 +26,13 @@ function runReadTool(input = {}, options = {}) {
|
|
|
9
26
|
const endLine = Number.isFinite(input.endLine) ? Math.max(startLine, Math.floor(input.endLine)) : 0;
|
|
10
27
|
const maxBytes = Number.isFinite(input.maxBytes) ? Math.max(256, Math.floor(input.maxBytes)) : 200000;
|
|
11
28
|
|
|
12
|
-
const raw =
|
|
29
|
+
const { raw, partial } = readFileBounded(resolved);
|
|
13
30
|
const lines = raw.split(/\r?\n/);
|
|
14
31
|
const from = startLine - 1;
|
|
15
32
|
const to = endLine > 0 ? endLine : lines.length;
|
|
16
33
|
const selected = lines.slice(from, to);
|
|
17
34
|
let content = selected.join("\n");
|
|
18
|
-
let truncated =
|
|
35
|
+
let truncated = partial;
|
|
19
36
|
if (Buffer.byteLength(content, "utf8") > maxBytes) {
|
|
20
37
|
content = Buffer.from(content, "utf8").slice(0, maxBytes).toString("utf8");
|
|
21
38
|
truncated = true;
|
|
@@ -41,4 +58,5 @@ function runReadTool(input = {}, options = {}) {
|
|
|
41
58
|
|
|
42
59
|
module.exports = {
|
|
43
60
|
runReadTool,
|
|
61
|
+
MAX_FULL_READ_BYTES,
|
|
44
62
|
};
|
package/src/ui/ink/ChatApp.js
CHANGED
|
@@ -23,6 +23,14 @@ const fmt = require("../format");
|
|
|
23
23
|
const { createMultilineInput } = require("./MultilineInput");
|
|
24
24
|
const { createDashboardBar } = require("./DashboardBar");
|
|
25
25
|
const { reducer, createInitialState } = require("./chatReducer");
|
|
26
|
+
const {
|
|
27
|
+
stripBlessedTags,
|
|
28
|
+
compactDividerLabel,
|
|
29
|
+
classifyChatLogLine,
|
|
30
|
+
buildChatLogLineModel,
|
|
31
|
+
buildChatLogGroups,
|
|
32
|
+
chatLogEntryText,
|
|
33
|
+
} = require("./chatLogModel");
|
|
26
34
|
const { restartDaemonLifecycle } = require("../../runtime/daemon/restart");
|
|
27
35
|
|
|
28
36
|
function bootstrapEnvironment(projectRoot, options = {}) {
|
|
@@ -157,12 +165,20 @@ function loadChatHistory(projectRoot, cap = 200, options = {}) {
|
|
|
157
165
|
const raw = fs.readFileSync(file, "utf8");
|
|
158
166
|
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
159
167
|
const out = [];
|
|
168
|
+
const pushLine = (line = "") => {
|
|
169
|
+
const value = String(line || "");
|
|
170
|
+
if (!value.trim()) {
|
|
171
|
+
if (out.length > 0 && out[out.length - 1] !== "") out.push("");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
out.push(value);
|
|
175
|
+
};
|
|
160
176
|
for (const line of lines) {
|
|
161
177
|
try {
|
|
162
178
|
const entry = JSON.parse(line);
|
|
163
179
|
if (!entry) continue;
|
|
164
180
|
if (entry.type === "spacer") {
|
|
165
|
-
|
|
181
|
+
pushLine("");
|
|
166
182
|
continue;
|
|
167
183
|
}
|
|
168
184
|
const text = String(entry.text || "");
|
|
@@ -170,12 +186,18 @@ function loadChatHistory(projectRoot, cap = 200, options = {}) {
|
|
|
170
186
|
// Strip blessed-tag markup that the legacy log writer used; ink
|
|
171
187
|
// can't render those tags and we don't want them shown literally.
|
|
172
188
|
const stripped = text.replace(/\{[^{}]+\}/g, "");
|
|
173
|
-
|
|
189
|
+
for (const renderedLine of normalizeInkLogLines(stripped)) {
|
|
190
|
+
pushLine(renderedLine);
|
|
191
|
+
}
|
|
174
192
|
} catch {
|
|
175
193
|
// ignore malformed lines
|
|
176
194
|
}
|
|
177
195
|
}
|
|
178
|
-
|
|
196
|
+
while (out.length > 0 && out[0] === "") out.shift();
|
|
197
|
+
while (out.length > 0 && out[out.length - 1] === "") out.pop();
|
|
198
|
+
const capped = out.slice(-cap);
|
|
199
|
+
while (capped.length > 0 && capped[0] === "") capped.shift();
|
|
200
|
+
return capped;
|
|
179
201
|
} catch {
|
|
180
202
|
return [];
|
|
181
203
|
}
|
|
@@ -369,67 +391,11 @@ function buildPromptIpcRequest(text) {
|
|
|
369
391
|
};
|
|
370
392
|
}
|
|
371
393
|
|
|
372
|
-
function stripBlessedTags(text = "") {
|
|
373
|
-
return String(text || "")
|
|
374
|
-
.replace(/\{\/?[^{}\n]+\}/g, "")
|
|
375
|
-
.replace(/\r/g, "");
|
|
376
|
-
}
|
|
377
|
-
|
|
378
394
|
function normalizeInkLogLines(text = "") {
|
|
379
395
|
const clean = stripBlessedTags(text);
|
|
380
396
|
return clean.split(/\r?\n/);
|
|
381
397
|
}
|
|
382
398
|
|
|
383
|
-
function stripMarkdownDecorators(text = "") {
|
|
384
|
-
return String(text || "")
|
|
385
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
386
|
-
.replace(/`([^`]+)`/g, "$1");
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function classifyChatLogLine(text = "") {
|
|
390
|
-
const raw = stripBlessedTags(text).replace(/\r/g, "");
|
|
391
|
-
const clean = stripMarkdownDecorators(raw);
|
|
392
|
-
const trimmed = clean.trim();
|
|
393
|
-
if (!trimmed) return { kind: "spacer", marker: " ", speaker: "", body: " " };
|
|
394
|
-
if (/^[█▀▄ ]+(?:\s{2,}(?:Version|Mode|Dictionary):.*)?$/.test(trimmed) || /^ufoo chat/i.test(trimmed)) {
|
|
395
|
-
return { kind: "banner", marker: " ", speaker: "", body: clean };
|
|
396
|
-
}
|
|
397
|
-
if (/^───.*───$/.test(trimmed)) {
|
|
398
|
-
return { kind: "divider", marker: "─", speaker: "", body: clean };
|
|
399
|
-
}
|
|
400
|
-
if (/^(error:|✗|failed\b)/i.test(trimmed)) {
|
|
401
|
-
return { kind: "error", marker: "!", speaker: "error", body: clean.replace(/^(error:\s*)/i, "") };
|
|
402
|
-
}
|
|
403
|
-
if (/^(✓|✔|done\b|closed\b)/i.test(trimmed)) {
|
|
404
|
-
return { kind: "success", marker: "✓", speaker: "", body: clean.replace(/^[✓✔]\s*/, "") };
|
|
405
|
-
}
|
|
406
|
-
const dotMatch = clean.match(/^([^·:\n]{1,42})\s+·\s+(.*)$/);
|
|
407
|
-
if (dotMatch) {
|
|
408
|
-
const speaker = dotMatch[1].trim();
|
|
409
|
-
const lower = speaker.toLowerCase();
|
|
410
|
-
const kind = lower === "ufoo" ? "assistant" : "agent";
|
|
411
|
-
return { kind, marker: kind === "assistant" ? "◆" : "•", speaker, body: dotMatch[2] || " " };
|
|
412
|
-
}
|
|
413
|
-
const colonMatch = clean.match(/^([A-Za-z0-9_.:@/-]{1,42}):\s+(.*)$/);
|
|
414
|
-
if (colonMatch) {
|
|
415
|
-
return { kind: "agent", marker: "•", speaker: colonMatch[1], body: colonMatch[2] || " " };
|
|
416
|
-
}
|
|
417
|
-
if (/^(CHAT|UCODE)\s+·/i.test(trimmed)) {
|
|
418
|
-
return { kind: "meta", marker: "·", speaker: "", body: clean };
|
|
419
|
-
}
|
|
420
|
-
return { kind: "plain", marker: "│", speaker: "", body: clean };
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function buildChatLogLineModel(text = "") {
|
|
424
|
-
const row = classifyChatLogLine(text);
|
|
425
|
-
const hasSpeaker = Boolean(row.speaker);
|
|
426
|
-
return {
|
|
427
|
-
...row,
|
|
428
|
-
markerText: hasSpeaker ? `${row.marker || " "} ` : `${row.marker || " "} `,
|
|
429
|
-
bodyText: row.body || " ",
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
|
|
433
399
|
function createInkStreamState({
|
|
434
400
|
dispatch,
|
|
435
401
|
appendHistory,
|
|
@@ -1234,7 +1200,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
1234
1200
|
getChatLogLines: () => {
|
|
1235
1201
|
const current = stateRef.current || {};
|
|
1236
1202
|
return Array.isArray(current.logLines)
|
|
1237
|
-
? current.logLines.map((item) =>
|
|
1203
|
+
? current.logLines.map((item) => chatLogEntryText(item))
|
|
1238
1204
|
: [];
|
|
1239
1205
|
},
|
|
1240
1206
|
getStatusText: () => {
|
|
@@ -3262,9 +3228,9 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
3262
3228
|
return null;
|
|
3263
3229
|
}
|
|
3264
3230
|
|
|
3265
|
-
const
|
|
3266
|
-
const row =
|
|
3267
|
-
const key =
|
|
3231
|
+
const renderChatLogEntry = (entry, group) => {
|
|
3232
|
+
const row = entry && entry.row ? entry.row : buildChatLogLineModel("");
|
|
3233
|
+
const key = entry && entry.id ? entry.id : `log-${row.body}`;
|
|
3268
3234
|
if (row.kind === "spacer") {
|
|
3269
3235
|
return h(Text, { key, color: "gray" }, " ");
|
|
3270
3236
|
}
|
|
@@ -3281,7 +3247,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
3281
3247
|
const colors = palette[row.kind] || palette.plain;
|
|
3282
3248
|
if (row.kind === "divider") {
|
|
3283
3249
|
return h(Box, { key, marginBottom: 1 },
|
|
3284
|
-
h(Text, { color: colors.body, wrap: "truncate" }, row.body),
|
|
3250
|
+
h(Text, { color: colors.body, wrap: "truncate" }, ` ${compactDividerLabel(row.body)}`),
|
|
3285
3251
|
);
|
|
3286
3252
|
}
|
|
3287
3253
|
if (row.kind === "banner") {
|
|
@@ -3289,13 +3255,16 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
3289
3255
|
h(Text, { color: colors.body, bold: true, wrap: "truncate" }, row.body),
|
|
3290
3256
|
);
|
|
3291
3257
|
}
|
|
3292
|
-
|
|
3293
|
-
|
|
3258
|
+
const markerText = entry && entry.continuation
|
|
3259
|
+
? (group && (group.kind === "assistant" || group.kind === "agent") ? " " : " ")
|
|
3260
|
+
: row.markerText;
|
|
3261
|
+
return h(Box, { key, width: "100%" },
|
|
3262
|
+
h(Text, { color: colors.marker, bold: row.kind === "error" }, markerText),
|
|
3294
3263
|
h(Text, { color: colors.body, wrap: "wrap" },
|
|
3295
|
-
row.speaker
|
|
3264
|
+
row.speaker && !(entry && entry.continuation)
|
|
3296
3265
|
? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
|
|
3297
3266
|
: null,
|
|
3298
|
-
row.speaker
|
|
3267
|
+
row.speaker && !(entry && entry.continuation)
|
|
3299
3268
|
? h(Text, { color: "gray" }, " · ")
|
|
3300
3269
|
: null,
|
|
3301
3270
|
row.bodyText,
|
|
@@ -3303,6 +3272,27 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
3303
3272
|
);
|
|
3304
3273
|
};
|
|
3305
3274
|
|
|
3275
|
+
const renderChatLogGroup = (group) => {
|
|
3276
|
+
const entries = Array.isArray(group && group.entries) ? group.entries : [];
|
|
3277
|
+
if (entries.length === 0) return null;
|
|
3278
|
+
const first = entries[0] || {};
|
|
3279
|
+
const row = first.row || buildChatLogLineModel("");
|
|
3280
|
+
if (row.kind === "spacer" || row.kind === "banner" || row.kind === "divider") {
|
|
3281
|
+
return renderChatLogEntry(first, group);
|
|
3282
|
+
}
|
|
3283
|
+
return h(Box, {
|
|
3284
|
+
key: `group-${group.id}`,
|
|
3285
|
+
flexDirection: "column",
|
|
3286
|
+
width: "100%",
|
|
3287
|
+
marginBottom: 1,
|
|
3288
|
+
},
|
|
3289
|
+
...entries.map((entry) => renderChatLogEntry(entry, group)));
|
|
3290
|
+
};
|
|
3291
|
+
|
|
3292
|
+
const renderChatLogGroups = (items) => buildChatLogGroups(items)
|
|
3293
|
+
.map(renderChatLogGroup)
|
|
3294
|
+
.filter(Boolean);
|
|
3295
|
+
|
|
3306
3296
|
if (state.viewingAgentId) {
|
|
3307
3297
|
const maxWidth = Math.max(20, size.cols || 80);
|
|
3308
3298
|
const logRows = Math.max(1, (size.rows || 24) - 5);
|
|
@@ -3379,7 +3369,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
3379
3369
|
|
|
3380
3370
|
return h(Box, { flexDirection: "column", width: "100%" },
|
|
3381
3371
|
h(Box, { flexDirection: "column", width: "100%" },
|
|
3382
|
-
...state.logLines
|
|
3372
|
+
...renderChatLogGroups(state.logLines),
|
|
3383
3373
|
),
|
|
3384
3374
|
state.activeMerge ? h(Box, null,
|
|
3385
3375
|
h(Text, { color: state.activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
|
|
@@ -3391,10 +3381,10 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
3391
3381
|
const prefix = state.activeStream.publisher
|
|
3392
3382
|
? `${state.activeStream.publisher}: `
|
|
3393
3383
|
: "";
|
|
3394
|
-
return lines.map((line, idx) =>
|
|
3384
|
+
return renderChatLogGroups(lines.map((line, idx) => ({
|
|
3395
3385
|
id: `s-${idx}`,
|
|
3396
3386
|
text: idx === 0 ? `${prefix}${line}` : ` ${line}`,
|
|
3397
|
-
}));
|
|
3387
|
+
})));
|
|
3398
3388
|
})(),
|
|
3399
3389
|
) : null,
|
|
3400
3390
|
h(Box, { marginTop: 1, width: "100%" },
|
|
@@ -3680,6 +3670,7 @@ module.exports = {
|
|
|
3680
3670
|
chatHistoryOptionsForScope,
|
|
3681
3671
|
classifyChatLogLine,
|
|
3682
3672
|
buildChatLogLineModel,
|
|
3673
|
+
buildChatLogGroups,
|
|
3683
3674
|
createInkMultiWindowToggle,
|
|
3684
3675
|
resolveActiveAgentId,
|
|
3685
3676
|
resolveInjectSockPathForAgent,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function stripBlessedTags(text = "") {
|
|
4
|
+
return String(text || "")
|
|
5
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
6
|
+
.replace(/\{\/?[^{}\n]+\}/g, "")
|
|
7
|
+
.replace(/\r/g, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function stripMarkdownDecorators(text = "") {
|
|
11
|
+
return String(text || "")
|
|
12
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
13
|
+
.replace(/`([^`]+)`/g, "$1");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function compactContinuationIndent(text = "") {
|
|
17
|
+
return String(text || "").replace(/^\s{8,}(?=\S)/, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function compactDividerLabel(text = "") {
|
|
21
|
+
const label = String(text || "")
|
|
22
|
+
.trim()
|
|
23
|
+
.replace(/^─+\s*/, "")
|
|
24
|
+
.replace(/\s*─+$/, "")
|
|
25
|
+
.trim();
|
|
26
|
+
return label || String(text || "").trim() || "section";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function classifyChatLogLine(text = "") {
|
|
30
|
+
const raw = stripBlessedTags(text).replace(/\r/g, "");
|
|
31
|
+
const clean = stripMarkdownDecorators(raw);
|
|
32
|
+
const trimmed = clean.trim();
|
|
33
|
+
if (!trimmed) return { kind: "spacer", marker: " ", speaker: "", body: " " };
|
|
34
|
+
if (/^[█▀▄ ]+(?:\s{2,}(?:Version|Mode|Dictionary):.*)?$/.test(trimmed) || /^ufoo chat/i.test(trimmed)) {
|
|
35
|
+
return { kind: "banner", marker: " ", speaker: "", body: clean };
|
|
36
|
+
}
|
|
37
|
+
if (/^───.*───$/.test(trimmed)) {
|
|
38
|
+
return { kind: "divider", marker: "─", speaker: "", body: clean };
|
|
39
|
+
}
|
|
40
|
+
if (/^(CHAT|UCODE)\s+·/i.test(trimmed)) {
|
|
41
|
+
return { kind: "meta", marker: "·", speaker: "", body: clean };
|
|
42
|
+
}
|
|
43
|
+
if (/^(error:|✗|failed\b)/i.test(trimmed)) {
|
|
44
|
+
return { kind: "error", marker: "!", speaker: "error", body: clean.replace(/^(error:\s*)/i, "") };
|
|
45
|
+
}
|
|
46
|
+
if (/^(✓|✔|done\b|closed\b)/i.test(trimmed)) {
|
|
47
|
+
return { kind: "success", marker: "✓", speaker: "", body: clean.replace(/^[✓✔]\s*/, "") };
|
|
48
|
+
}
|
|
49
|
+
const dotMatch = clean.match(/^([^·\n]{1,64})\s+·\s+(.*)$/);
|
|
50
|
+
if (dotMatch) {
|
|
51
|
+
const speaker = dotMatch[1].trim();
|
|
52
|
+
const lower = speaker.toLowerCase();
|
|
53
|
+
const kind = lower === "ufoo" ? "assistant" : "agent";
|
|
54
|
+
return { kind, marker: kind === "assistant" ? "◆" : "•", speaker, body: dotMatch[2] || " " };
|
|
55
|
+
}
|
|
56
|
+
const colonMatch = clean.match(/^([A-Za-z0-9_.:@/-]{1,42}):\s+(.*)$/);
|
|
57
|
+
if (colonMatch) {
|
|
58
|
+
return { kind: "agent", marker: "•", speaker: colonMatch[1], body: colonMatch[2] || " " };
|
|
59
|
+
}
|
|
60
|
+
return { kind: "plain", marker: "", speaker: "", body: clean };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaultMarkerForKind(kind = "", speaker = "") {
|
|
64
|
+
if (kind === "assistant") return "◆";
|
|
65
|
+
if (kind === "agent") return "•";
|
|
66
|
+
if (kind === "error") return "!";
|
|
67
|
+
if (kind === "success") return "✓";
|
|
68
|
+
if (kind === "divider") return "─";
|
|
69
|
+
if (kind === "meta") return "·";
|
|
70
|
+
if (kind === "banner" || kind === "spacer") return " ";
|
|
71
|
+
return speaker ? "•" : "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildChatLogLineModel(input = "") {
|
|
75
|
+
if (input && typeof input === "object" && !input.kind) {
|
|
76
|
+
return buildChatLogLineModel(chatLogEntryText(input));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (input && typeof input === "object" && input.kind) {
|
|
80
|
+
const kind = String(input.kind || "plain");
|
|
81
|
+
const speaker = String(input.speaker || "");
|
|
82
|
+
const marker = input.marker != null ? String(input.marker) : defaultMarkerForKind(kind, speaker);
|
|
83
|
+
const rawBody = input.bodyText != null
|
|
84
|
+
? String(input.bodyText)
|
|
85
|
+
: String(input.body != null ? input.body : chatLogEntryText(input));
|
|
86
|
+
const body = kind === "plain" ? compactContinuationIndent(rawBody || " ") : (rawBody || " ");
|
|
87
|
+
return {
|
|
88
|
+
kind,
|
|
89
|
+
marker,
|
|
90
|
+
speaker,
|
|
91
|
+
body: input.body != null ? String(input.body) : rawBody,
|
|
92
|
+
markerText: input.markerText != null
|
|
93
|
+
? String(input.markerText)
|
|
94
|
+
: (speaker ? `${marker || " "} ` : `${marker || " "} `),
|
|
95
|
+
bodyText: body,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const row = classifyChatLogLine(input);
|
|
100
|
+
const hasSpeaker = Boolean(row.speaker);
|
|
101
|
+
const body = row.kind === "plain"
|
|
102
|
+
? compactContinuationIndent(row.body || " ")
|
|
103
|
+
: (row.body || " ");
|
|
104
|
+
return {
|
|
105
|
+
...row,
|
|
106
|
+
markerText: hasSpeaker ? `${row.marker || " "} ` : `${row.marker || " "} `,
|
|
107
|
+
bodyText: body,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeEntryInput(input) {
|
|
112
|
+
if (input && typeof input === "object" && !Array.isArray(input)) return input;
|
|
113
|
+
return { text: input };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createChatLogEntry(input = "", id = "") {
|
|
117
|
+
const source = normalizeEntryInput(input);
|
|
118
|
+
const text = String(source.text != null ? source.text : chatLogEntryText(source));
|
|
119
|
+
const row = source.kind
|
|
120
|
+
? buildChatLogLineModel({ ...source, text })
|
|
121
|
+
: buildChatLogLineModel(text);
|
|
122
|
+
const meta = source.meta && typeof source.meta === "object" && !Array.isArray(source.meta)
|
|
123
|
+
? { ...source.meta }
|
|
124
|
+
: {};
|
|
125
|
+
const entry = {
|
|
126
|
+
id: String(id || source.id || ""),
|
|
127
|
+
text,
|
|
128
|
+
kind: row.kind,
|
|
129
|
+
marker: row.marker,
|
|
130
|
+
speaker: row.speaker,
|
|
131
|
+
body: row.body,
|
|
132
|
+
markerText: row.markerText,
|
|
133
|
+
bodyText: row.bodyText,
|
|
134
|
+
sourceType: String(source.sourceType || source.type || ""),
|
|
135
|
+
meta,
|
|
136
|
+
};
|
|
137
|
+
return entry;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function chatLogEntryText(entry = "") {
|
|
141
|
+
if (typeof entry === "string") return entry;
|
|
142
|
+
if (!entry || typeof entry !== "object") return "";
|
|
143
|
+
if (entry.text != null) return String(entry.text);
|
|
144
|
+
if (entry.speaker) return `${entry.speaker} · ${entry.bodyText || entry.body || ""}`;
|
|
145
|
+
return String(entry.bodyText || entry.body || "");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function canAppendToChatLogGroup(group, row) {
|
|
149
|
+
if (!group || !row) return false;
|
|
150
|
+
if (row.kind !== "plain" && row.kind !== "spacer") return false;
|
|
151
|
+
return group.kind === "assistant"
|
|
152
|
+
|| group.kind === "agent"
|
|
153
|
+
|| group.kind === "success"
|
|
154
|
+
|| group.kind === "error"
|
|
155
|
+
|| group.kind === "meta"
|
|
156
|
+
|| group.kind === "plain";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildChatLogGroups(items = []) {
|
|
160
|
+
const source = Array.isArray(items) ? items : [];
|
|
161
|
+
const groups = [];
|
|
162
|
+
let current = null;
|
|
163
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
164
|
+
const item = source[index] || {};
|
|
165
|
+
const itemId = item && typeof item === "object" && item.id ? item.id : `log-${index}`;
|
|
166
|
+
const row = buildChatLogLineModel(item);
|
|
167
|
+
const entry = {
|
|
168
|
+
id: itemId,
|
|
169
|
+
text: chatLogEntryText(item),
|
|
170
|
+
row,
|
|
171
|
+
sourceType: item && typeof item === "object" ? String(item.sourceType || item.type || "") : "",
|
|
172
|
+
meta: item && typeof item === "object" && item.meta ? item.meta : {},
|
|
173
|
+
continuation: false,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
if (canAppendToChatLogGroup(current, row)) {
|
|
177
|
+
entry.continuation = true;
|
|
178
|
+
current.entries.push(entry);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
current = {
|
|
183
|
+
id: entry.id,
|
|
184
|
+
kind: row.kind,
|
|
185
|
+
entries: [entry],
|
|
186
|
+
};
|
|
187
|
+
groups.push(current);
|
|
188
|
+
}
|
|
189
|
+
return groups;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
stripBlessedTags,
|
|
194
|
+
stripMarkdownDecorators,
|
|
195
|
+
compactContinuationIndent,
|
|
196
|
+
compactDividerLabel,
|
|
197
|
+
classifyChatLogLine,
|
|
198
|
+
buildChatLogLineModel,
|
|
199
|
+
buildChatLogGroups,
|
|
200
|
+
createChatLogEntry,
|
|
201
|
+
chatLogEntryText,
|
|
202
|
+
};
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
const fmt = require("../format");
|
|
38
|
+
const { createChatLogEntry } = require("./chatLogModel");
|
|
38
39
|
|
|
39
40
|
const LOG_CAP = 1000;
|
|
40
41
|
const HISTORY_CAP = 200;
|
|
@@ -100,7 +101,10 @@ function createInitialState({ banner = [], globalMode = false, globalScope = "co
|
|
|
100
101
|
const initialAgentProvider = settings.agentProvider || "codex-cli";
|
|
101
102
|
const selectedProviderIndex = Math.max(0, DEFAULT_PROVIDER_OPTIONS.findIndex((opt) => opt.value === initialAgentProvider));
|
|
102
103
|
return {
|
|
103
|
-
logLines: banner.concat([""]).map((line, idx) => ({
|
|
104
|
+
logLines: banner.concat([""]).map((line, idx) => createChatLogEntry({
|
|
105
|
+
text: line,
|
|
106
|
+
sourceType: "banner",
|
|
107
|
+
}, `b-${idx}`)),
|
|
104
108
|
lineSeq: banner.length + 1,
|
|
105
109
|
draft: "",
|
|
106
110
|
focusMode: "input",
|
|
@@ -144,10 +148,10 @@ function createInitialState({ banner = [], globalMode = false, globalScope = "co
|
|
|
144
148
|
function appendLog(state, lines) {
|
|
145
149
|
const incoming = Array.isArray(lines) ? lines : [lines];
|
|
146
150
|
let seq = state.lineSeq;
|
|
147
|
-
const out = state.logLines.concat(incoming.map((
|
|
151
|
+
const out = state.logLines.concat(incoming.map((line) => {
|
|
148
152
|
const id = `l-${seq}`;
|
|
149
153
|
seq += 1;
|
|
150
|
-
return
|
|
154
|
+
return createChatLogEntry(line, id);
|
|
151
155
|
}));
|
|
152
156
|
return {
|
|
153
157
|
...state,
|