s-app-scoder-agent 0.9.8 → 0.9.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/.scoderrc.example.json +12 -1
- package/dist/agent/guardrails.js +36 -5
- package/dist/agent/mcp-bridge.js +101 -5
- package/dist/agent/prompt-context.js +15 -2
- package/dist/agent/run-loop.js +72 -10
- package/dist/agent/tool-executor.js +11 -5
- package/dist/agent/verification.js +28 -2
- package/dist/agent.js +224 -21
- package/dist/config/config.js +102 -4
- package/dist/context/compaction.js +39 -5
- package/dist/context/context.js +131 -28
- package/dist/context/repo-map.js +8 -6
- package/dist/index.js +12 -1
- package/dist/llm/context-probe.js +151 -0
- package/dist/llm/llm.js +21 -10
- package/dist/llm/prompt-metrics.js +78 -0
- package/dist/mcp/manager.js +3 -19
- package/dist/mcp/tool-filter.js +34 -0
- package/dist/tools/path-safety.js +22 -2
- package/dist/tools/read-file.js +24 -4
- package/dist/tui/app.js +26 -12
- package/dist/tui/hooks/use-scroll.js +5 -2
- package/dist/tui/hooks/use-submit.js +3 -3
- package/dist/tui/selectors.js +61 -6
- package/dist/tui/state.js +4 -3
- package/dist/workspace/build.js +5 -2
- package/dist/workspace/file-index.js +8 -3
- package/dist/workspace/fingerprint.js +44 -0
- package/package.json +1 -1
package/dist/tools/read-file.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, statSync, realpathSync } from "node:fs";
|
|
2
2
|
import { relative } from "node:path";
|
|
3
3
|
import { detectImageBytes } from "../media/image.js";
|
|
4
|
-
import { clamp, MAX_FILE_SIZE_BYTES, safePath, toPosInt, toResult } from "./path-safety.js";
|
|
4
|
+
import { clamp, MAX_FILE_SIZE_BYTES, MAX_OUTPUT_CHARS, MAX_TOOL_OUTPUT_CHARS, safePath, toPosInt, toResult } from "./path-safety.js";
|
|
5
5
|
import { isProtectedEnvFile } from "./path-safety.js";
|
|
6
6
|
export function toolReadFile(workdir, args) {
|
|
7
7
|
return toResult(() => {
|
|
@@ -56,9 +56,29 @@ export function toolReadFile(workdir, args) {
|
|
|
56
56
|
const offset = Math.min(toPosInt(args.offset, 1), total);
|
|
57
57
|
const limit = Math.min(toPosInt(args.limit, total - offset + 1), 2000);
|
|
58
58
|
const slice = lines.slice(offset - 1, offset - 1 + limit);
|
|
59
|
-
const
|
|
60
|
-
|
|
59
|
+
const numberedAll = slice.map((l, i) => `${offset + i}: ${l}`);
|
|
60
|
+
// P0-2 (RC-3): cắt theo DÒNG ngay tại ngân sách history (limits.toolOutputChars)
|
|
61
|
+
// và nói rõ phần còn lại nằm ở dòng nào + lấy tiếp bằng tham số nào. Trunc mù
|
|
62
|
+
// kiểu "... more chars" từng khiến agent đọc lại cả file bằng "cách khác".
|
|
63
|
+
const budget = Math.max(200, Math.min(MAX_TOOL_OUTPUT_CHARS, MAX_OUTPUT_CHARS));
|
|
64
|
+
const kept = [];
|
|
65
|
+
let used = 0;
|
|
66
|
+
for (const line of numberedAll) {
|
|
67
|
+
if (used + line.length + 1 > budget && kept.length > 0)
|
|
68
|
+
break;
|
|
69
|
+
kept.push(line);
|
|
70
|
+
used += line.length + 1;
|
|
71
|
+
}
|
|
72
|
+
const dropped = numberedAll.length - kept.length;
|
|
73
|
+
const end = offset + kept.length - 1;
|
|
61
74
|
const header = offset > 1 || end < total ? `[lines ${offset}-${end} of ${total} — ${args.path}]\n` : "";
|
|
62
|
-
|
|
75
|
+
const footer = dropped > 0
|
|
76
|
+
? `\n... [${dropped} more line(s): ${end + 1}-${offset + slice.length - 1} of ${total}. ` +
|
|
77
|
+
`Read the next chunk with read_file({"path":"${args.path}","offset":${end + 1}}) — ` +
|
|
78
|
+
`do NOT re-read from the start.]`
|
|
79
|
+
: "";
|
|
80
|
+
// clamp cho dòng kếch xù (một dòng 40k ký tự vẫn phải nằm trong ngân sách).
|
|
81
|
+
const numbered = clamp(kept.join("\n"), budget);
|
|
82
|
+
return { ok: true, output: header + numbered + footer };
|
|
63
83
|
});
|
|
64
84
|
}
|
package/dist/tui/app.js
CHANGED
|
@@ -19,6 +19,7 @@ import { setQuiet } from "../io/terminal.js";
|
|
|
19
19
|
import { discoverSkills } from "../context/skills.js";
|
|
20
20
|
import { discoverAgents } from "../context/agents.js";
|
|
21
21
|
import { initialState } from "./state.js";
|
|
22
|
+
import { transcriptWindow, estimateTotalRows } from "./selectors.js";
|
|
22
23
|
import { SLASH_COMMANDS, formatSlashHelp } from "../commands/definitions.js";
|
|
23
24
|
import { ChatItemView } from "./chat-views.js"; // T2
|
|
24
25
|
import { BuildRail, StatusBar, SIDEBAR_MIN_COLS, SIDEBAR_WIDTH } from "./chrome.js"; // T2
|
|
@@ -95,13 +96,25 @@ export function TuiApp({ config, initialTask }) {
|
|
|
95
96
|
agentRef.current?.dispose();
|
|
96
97
|
agentRef.current = null;
|
|
97
98
|
}, []);
|
|
98
|
-
// ── Scrollable chat viewport (Ink 8 alt-screen) ──
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
const
|
|
99
|
+
// ── Scrollable chat viewport (Ink 8 alt-screen) — P1-8 virtualized ──
|
|
100
|
+
// Chi phí layout/wrap mỗi frame tỷ lệ với số item MOUNT, không phải số
|
|
101
|
+
// item transcript ⇒ chỉ mount cửa sổ quanh viewport (transcriptWindow).
|
|
102
|
+
// Scroll offset khi đọc lại dùng rows ước lượng; khi BÁM ĐÁY dùng chiều
|
|
103
|
+
// cao THẬT của cửa sổ đã mount (đo rẻ vì cửa sổ ~viewport+overscan) —
|
|
104
|
+
// estimate sai lệch bao nhiêu thì dòng cuối vẫn luôn thấy.
|
|
105
|
+
const { viewportRef, viewport, contentRef, content, viewportRowsRef } = useScrollMetrics(setScrollOffset);
|
|
106
|
+
const viewportRows = viewport.clientHeight || 24;
|
|
107
|
+
const win = useMemo(() => {
|
|
108
|
+
const total = estimateTotalRows(state.items);
|
|
109
|
+
const max = Math.max(0, total - viewportRows);
|
|
110
|
+
const top = Math.max(0, max - scrollOffset);
|
|
111
|
+
return { ...transcriptWindow(state.items, top, viewportRows), maxScroll: max };
|
|
112
|
+
}, [state.items, scrollOffset, viewportRows]);
|
|
113
|
+
const maxScroll = win.maxScroll;
|
|
114
|
+
// Follow-bottom (scrollOffset = 0): offset chính xác theo số đo thật.
|
|
115
|
+
const contentOffset = scrollOffset === 0 && content.hasMeasured
|
|
116
|
+
? Math.max(0, content.height - viewport.clientHeight)
|
|
117
|
+
: win.offset;
|
|
105
118
|
const skills = useMemo(() => discoverSkills(cfg.workdir), [cfg.workdir]);
|
|
106
119
|
const skillNames = useMemo(() => skills.map((s) => s.name), [skills]);
|
|
107
120
|
// T-02: completion sources (agents + files) computed once per mount.
|
|
@@ -164,19 +177,20 @@ export function TuiApp({ config, initialTask }) {
|
|
|
164
177
|
setScrollOffset(0);
|
|
165
178
|
prevStatusRef.current = state.status;
|
|
166
179
|
}, [state.status]);
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
180
|
+
// P1-8: viewport mount CỬA SỔ item quanh scrollTop (transcriptWindow), không
|
|
181
|
+
// mount cả transcript — chi phí layout/wrap mỗi frame hết tỷ lệ với tổng text.
|
|
182
|
+
// Ink clip bằng overflow="hidden"; contentOffsetY = số dòng phải ẩn ở đầu
|
|
183
|
+
// cửa sổ (scrollTop - startRow[item_start]).
|
|
170
184
|
const { rows, columns } = useWindowSize();
|
|
171
185
|
const showSidebar = (columns ?? 100) >= SIDEBAR_MIN_COLS;
|
|
172
186
|
return (
|
|
173
187
|
// flexShrink={0}: pinned chrome must never be squeezed when the chat
|
|
174
188
|
// content overflows — Ink defaults every box to flexShrink:1, which
|
|
175
189
|
// shrank the input bar to zero rows once the log outgrew the terminal.
|
|
176
|
-
_jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: cfg }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, minHeight: 0, children: [_jsx(Box, { ref: viewportRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", contentOffsetY:
|
|
190
|
+
_jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: cfg }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, minHeight: 0, children: [_jsx(Box, { ref: viewportRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", contentOffsetY: contentOffset, children: _jsxs(Box, { ref: contentRef, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [state.items.length === 0 ? (
|
|
177
191
|
// Welcome: căn giữa cả 2 chiều — height = chiều cao đo được của
|
|
178
192
|
// viewport (flexGrow không giãn được vì content box flexShrink=0).
|
|
179
|
-
_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: viewport.clientHeight || undefined, children: [(columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) >= 54 ? (_jsx(Box, { flexDirection: "column", alignItems: "center", children: (isAsciiMode() ? BANNER_ASCII : BANNER).map((line, i) => (_jsx(Text, { bold: true, color: theme.brand, children: line }, i))) })) : (_jsx(Text, { bold: true, color: theme.brand, children: "\u2691 SCoder" })), _jsx(Text, { dimColor: true, children: `autonomous coding agent · v${getVersion()}` }), _jsx(Text, {}), _jsx(Text, { dimColor: true, children: "type a task to start \u2014 the agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), _jsx(Text, { dimColor: true, children: " send " }), _jsx(Text, { bold: true, color: theme.accent, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: " sessions " }), _jsx(Text, { bold: true, color: theme.accent, children: "tab" }), _jsx(Text, { dimColor: true, children: " complete " }), _jsx(Text, { bold: true, color: theme.accent, children: "/help" }), _jsx(Text, { dimColor: true, children: " commands" })] })] })) : (state.items.map((item, i) => _jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) }, i))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: theme.success, bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: theme.error, bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg, rows: viewport.clientHeight || undefined })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.accent, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "Command palette (Ctrl+P to close)" }), SLASH_COMMANDS.slice(0, 40).map((c) => (_jsxs(Text, { children: [c.cmd.padEnd(12), " ", c.desc] }, c.cmd)))] })), helpOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.dim, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "Help (?)" }), _jsx(Text, { children: formatSlashHelp("scoder tui") })] })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, paddingTop: 0, paddingBottom: 0, flexShrink: 0, alignSelf: "stretch", children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: cfg, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null })] }));
|
|
193
|
+
_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: viewport.clientHeight || undefined, children: [(columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) >= 54 ? (_jsx(Box, { flexDirection: "column", alignItems: "center", children: (isAsciiMode() ? BANNER_ASCII : BANNER).map((line, i) => (_jsx(Text, { bold: true, color: theme.brand, children: line }, i))) })) : (_jsx(Text, { bold: true, color: theme.brand, children: "\u2691 SCoder" })), _jsx(Text, { dimColor: true, children: `autonomous coding agent · v${getVersion()}` }), _jsx(Text, {}), _jsx(Text, { dimColor: true, children: "type a task to start \u2014 the agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), _jsx(Text, { dimColor: true, children: " send " }), _jsx(Text, { bold: true, color: theme.accent, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: " sessions " }), _jsx(Text, { bold: true, color: theme.accent, children: "tab" }), _jsx(Text, { dimColor: true, children: " complete " }), _jsx(Text, { bold: true, color: theme.accent, children: "/help" }), _jsx(Text, { dimColor: true, children: " commands" })] })] })) : (state.items.slice(win.start, win.end).map((item, i) => (_jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) }, win.start + i)))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: theme.success, bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: theme.error, bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg, rows: viewport.clientHeight || undefined })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.accent, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "Command palette (Ctrl+P to close)" }), SLASH_COMMANDS.slice(0, 40).map((c) => (_jsxs(Text, { children: [c.cmd.padEnd(12), " ", c.desc] }, c.cmd)))] })), helpOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.dim, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "Help (?)" }), _jsx(Text, { children: formatSlashHelp("scoder tui") })] })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, paddingTop: 0, paddingBottom: 0, flexShrink: 0, alignSelf: "stretch", children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: cfg, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null })] }));
|
|
180
194
|
}
|
|
181
195
|
/** Mount the TUI (called from the `scoder tui` subcommand). */
|
|
182
196
|
export async function runTui(config, initialTask) {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
/** Scroll viewport metrics + mouse wheel (SGR 1006) wiring for the TUI.
|
|
1
|
+
/** Scroll viewport metrics + mouse wheel (SGR 1006) wiring for the TUI.
|
|
2
|
+
* P1-8: contentRef giờ đo CỬA SỔ item đã mount (không phải cả transcript) —
|
|
3
|
+
* chi phí đo O(window) nên vẫn rẻ; số đo thật dùng cho follow-bottom chính
|
|
4
|
+
* xác, ước lượng (transcriptWindow) chỉ dùng khi scroll lên trên. */
|
|
2
5
|
import { useCallback, useEffect, useRef } from "react";
|
|
3
6
|
import { useBoxMetrics, useStdin } from "ink";
|
|
4
7
|
import { parseWheel, MOUSE_ENABLE, MOUSE_DISABLE } from "../mouse.js";
|
|
@@ -34,5 +37,5 @@ export function useScrollMetrics(setScrollOffset) {
|
|
|
34
37
|
process.stdout.write(MOUSE_DISABLE);
|
|
35
38
|
};
|
|
36
39
|
}, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
|
|
37
|
-
return { viewportRef,
|
|
40
|
+
return { viewportRef, viewport, contentRef, content, viewportRowsRef };
|
|
38
41
|
}
|
|
@@ -83,9 +83,9 @@ export function useSubmit(deps) {
|
|
|
83
83
|
setScrollOffset(0);
|
|
84
84
|
// isFirst must be computed BEFORE pushing the user item below.
|
|
85
85
|
const isFirst = stateRef.current.items.every((i) => i.kind !== "user");
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// runTurn cũng
|
|
86
|
+
// run_start (cả run() lẫn runTurn() đều phát) dedupe với dòng user mà TUI
|
|
87
|
+
// vừa push ở dòng trên, nên câu hỏi thứ 2+ không hiển thị hai lần.
|
|
88
|
+
// runTurn cũng reset plan — cùng semantics với run_start.
|
|
89
89
|
update((s) => ({ ...s, plan: null, items: [...s.items, { kind: "user", text: task }], status: "running", summary: null }));
|
|
90
90
|
const agent = createAgent();
|
|
91
91
|
try {
|
package/dist/tui/selectors.js
CHANGED
|
@@ -70,11 +70,13 @@ export function estimateMarkdownRows(text, width = EST_WIDTH) {
|
|
|
70
70
|
export function estimateItemRows(item) {
|
|
71
71
|
switch (item.kind) {
|
|
72
72
|
case "user":
|
|
73
|
-
|
|
73
|
+
// Render: marginTop 1 + border-left box paddingX 1, wrap theo chat width
|
|
74
|
+
// (~60–96 cột). 60 là giả định hẹp an toàn (over-estimate nhẹ).
|
|
75
|
+
return 2 + wrapEstimate(item.text ?? "", 60);
|
|
74
76
|
case "assistant":
|
|
75
77
|
// +1 marginTop; streaming raw text dùng wrap đơn giản (chưa qua markdown).
|
|
76
78
|
return item.streaming
|
|
77
|
-
? wrapEstimate(item.text ?? "",
|
|
79
|
+
? (item.text ? 1 : 0) + wrapEstimate(item.text ?? "", 76)
|
|
78
80
|
: 1 + estimateMarkdownRows(item.text ?? "", EST_WIDTH);
|
|
79
81
|
case "thinking":
|
|
80
82
|
// Render: collapse to last 4 lines.
|
|
@@ -82,7 +84,7 @@ export function estimateItemRows(item) {
|
|
|
82
84
|
case "tool":
|
|
83
85
|
return 1 + 2; // title + preview 2 lines
|
|
84
86
|
case "compaction":
|
|
85
|
-
return
|
|
87
|
+
return 2; // marginTop + divider
|
|
86
88
|
case "diff":
|
|
87
89
|
return 3 + Math.min(24, (item.text?.split("\n").length ?? 0)); // header + ≤24 lines + border
|
|
88
90
|
case "todo":
|
|
@@ -90,15 +92,19 @@ export function estimateItemRows(item) {
|
|
|
90
92
|
// chiếm 0 dòng thực tế trong transcript.
|
|
91
93
|
return 0;
|
|
92
94
|
case "build":
|
|
93
|
-
return
|
|
95
|
+
return 3; // marginTop + label + command
|
|
94
96
|
case "notice":
|
|
95
|
-
|
|
97
|
+
// Render raw text (cap 2000 chars) với wrap thật theo width — /help là
|
|
98
|
+
// notice 40 dòng; estimate cũ (1 + len/2000) under-estimate khiến
|
|
99
|
+
// follow-bottom clip dòng kế tiếp ( regression caught by tui.test E2E).
|
|
100
|
+
return 1 + wrapEstimate(item.text.length > 2000 ? item.text.slice(0, 2000) + "…" : item.text, 76);
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
/**
|
|
99
104
|
* T4: chọn window item để render: lấy từ cuối lên tới khi tổng rows ước
|
|
100
105
|
* lượng ≥ viewportRows + margin (render dư phía trên để scroll mượt), nhưng
|
|
101
|
-
* giữ toàn bộ khi tổng nhỏ. Trả về slice của items (không
|
|
106
|
+
* giữ toàn bộ khi tổng nhỏ. Trả về slice của items (không rẻ — dùng
|
|
107
|
+
* transcriptWindow cho scroll đúng offset, hàm này chỉ còn cho callers cũ).
|
|
102
108
|
*/
|
|
103
109
|
export function visibleWindow(items, viewportRows, margin = 50) {
|
|
104
110
|
const budget = viewportRows + margin;
|
|
@@ -110,6 +116,55 @@ export function visibleWindow(items, viewportRows, margin = 50) {
|
|
|
110
116
|
}
|
|
111
117
|
return start === 0 ? items : items.slice(start);
|
|
112
118
|
}
|
|
119
|
+
// P1-8: cache số dòng ước lượng theo IDENTITY của item. state.ts patch item
|
|
120
|
+
// kiểu immutable (object mới khi nội dung đổi) ⇒ item finalized không bao giờ
|
|
121
|
+
// tính lại; chỉ item đang stream (mỗi flush đổi reference) mới estimate lại.
|
|
122
|
+
const itemRowsCache = new WeakMap();
|
|
123
|
+
function itemRowsCached(item) {
|
|
124
|
+
const hit = itemRowsCache.get(item);
|
|
125
|
+
if (hit !== undefined)
|
|
126
|
+
return hit;
|
|
127
|
+
const v = estimateItemRows(item);
|
|
128
|
+
itemRowsCache.set(item, v);
|
|
129
|
+
return v;
|
|
130
|
+
}
|
|
131
|
+
/** Tổng chiều cao ước lượng của cả transcript — O(n) phép cộng số nhỏ. */
|
|
132
|
+
export function estimateTotalRows(items) {
|
|
133
|
+
let acc = 0;
|
|
134
|
+
for (const it of items)
|
|
135
|
+
acc += itemRowsCached(it);
|
|
136
|
+
return acc;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* P1-8 — virtualization cho transcript viewport. Mount duy các item phủ
|
|
140
|
+
* [scrollTop - overscan, scrollTop + viewportRows + overscan] thay vì cả
|
|
141
|
+
* transcript: chi phí React diff + Yoga layout + wrap mỗi frame hết tỷ lệ
|
|
142
|
+
* với tổng số text (đo: 42–53 ms/frame ở 300–400 item → ~7 ms khi window).
|
|
143
|
+
* Scroll dùng ước lượng rows (estimateItemRows, cache theo identity item),
|
|
144
|
+
* nên khi estimate lệch thực tế thì offset chỉ sai vài dòng — cùng trade-off
|
|
145
|
+
* mọi TUI virtualized dùng.
|
|
146
|
+
*/
|
|
147
|
+
export function transcriptWindow(items, scrollTop, viewportRows, overscan = 24) {
|
|
148
|
+
const n = items.length;
|
|
149
|
+
// Cumulative start rows một lượt O(n) nhưng chỉ là phép cộng số nhỏ.
|
|
150
|
+
const startRow = new Array(n + 1);
|
|
151
|
+
startRow[0] = 0;
|
|
152
|
+
for (let i = 0; i < n; i++)
|
|
153
|
+
startRow[i + 1] = startRow[i] + itemRowsCached(items[i]);
|
|
154
|
+
const totalRows = startRow[n];
|
|
155
|
+
const top = Math.max(0, Math.min(scrollTop, totalRows));
|
|
156
|
+
const target = Math.max(0, top - overscan);
|
|
157
|
+
let start = 0;
|
|
158
|
+
while (start + 1 < n && startRow[start + 1] <= target)
|
|
159
|
+
start++;
|
|
160
|
+
const bottomTarget = top + viewportRows + overscan;
|
|
161
|
+
let end = start;
|
|
162
|
+
while (end < n && startRow[end] < bottomTarget)
|
|
163
|
+
end++;
|
|
164
|
+
if (end === start && start < n)
|
|
165
|
+
end = start + 1; // item khổng lồ vẫn phải hiện
|
|
166
|
+
return { start, end, offset: top - startRow[start], totalRows };
|
|
167
|
+
}
|
|
113
168
|
/**
|
|
114
169
|
* Plan mới nhất cho sidebar — todo items được append mỗi lần update nhưng
|
|
115
170
|
* sidebar chỉ cần snapshot hiện tại (last-wins), null khi chưa có plan.
|
package/dist/tui/state.js
CHANGED
|
@@ -79,7 +79,7 @@ function applyGuardrail(state, ev) {
|
|
|
79
79
|
}
|
|
80
80
|
// Pure selectors live in ./selectors.js (Phase 5.4) — re-exported so existing
|
|
81
81
|
// import sites (app.tsx, hooks, tests) keep working unchanged.
|
|
82
|
-
export { estimateItemRows, visibleWindow, latestPlan, toolPhase } from "./selectors.js";
|
|
82
|
+
export { estimateItemRows, visibleWindow, transcriptWindow, estimateTotalRows, latestPlan, toolPhase } from "./selectors.js";
|
|
83
83
|
/**
|
|
84
84
|
* Fold one AgentEvent into the state (mutates `state`, returns it for
|
|
85
85
|
* reducer-style chaining). Unknown events are ignored.
|
|
@@ -118,9 +118,10 @@ export function applyEvent(state, ev) {
|
|
|
118
118
|
state.llmCallStartedAt = null;
|
|
119
119
|
state.lastLlmTokPerSec = null;
|
|
120
120
|
// The TUI pushes the user's message itself when submitting; avoid a
|
|
121
|
-
// duplicate when run_start carries the same task.
|
|
121
|
+
// duplicate when run_start carries the same task. `quiet` (resume) =
|
|
122
|
+
// transcript đã hiển thị rồi, không append nữa.
|
|
122
123
|
const last = lastItem(state);
|
|
123
|
-
if (!(last && last.kind === "user" && last.text === ev.task)) {
|
|
124
|
+
if (!ev.quiet && !(last && last.kind === "user" && last.text === ev.task)) {
|
|
124
125
|
appendItem(state, { kind: "user", text: ev.task });
|
|
125
126
|
}
|
|
126
127
|
// Plan của task trước không được lọt sang task mới.
|
package/dist/workspace/build.js
CHANGED
|
@@ -31,9 +31,12 @@ export function detectBuildCommand(workdir) {
|
|
|
31
31
|
* Run the build command. This is the agent's stop condition:
|
|
32
32
|
* exit code 0 == task complete.
|
|
33
33
|
* `onOutput` (optional) receives stdout/stderr chunks live while the build runs.
|
|
34
|
+
* `timeoutSecs` (P1-2) tới từ `limits.buildTimeoutSecs` — trước đây hardcode
|
|
35
|
+
* 600s, không điều chỉnh được khi verify trên máy chậm.
|
|
34
36
|
*/
|
|
35
|
-
export async function runBuild(workdir, command, onOutput) {
|
|
36
|
-
const
|
|
37
|
+
export async function runBuild(workdir, command, onOutput, timeoutSecs) {
|
|
38
|
+
const timeout = Number.isFinite(timeoutSecs ?? NaN) && (timeoutSecs ?? 0) > 0 ? Math.floor(timeoutSecs) : 600;
|
|
39
|
+
const res = await toolRunCommand(workdir, { command, timeout_secs: timeout }, undefined, onOutput);
|
|
37
40
|
return {
|
|
38
41
|
success: res.ok,
|
|
39
42
|
command,
|
|
@@ -9,9 +9,14 @@ import { join, relative } from "node:path";
|
|
|
9
9
|
import fuzzysort from "fuzzysort";
|
|
10
10
|
const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
11
11
|
let cache = null;
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/**
|
|
13
|
+
* File index for `workdir` (cached, TTL 5s; invalidateFileIndex on tool writes).
|
|
14
|
+
* `force` (P1-2) bỏ qua cache — dùng cho fingerprint, nơi một file mới được tạo
|
|
15
|
+
* bởi `run_command`/trình sửa ngoài PHẢI đổi hash, nếu không kết quả verify cũ
|
|
16
|
+
* sẽ được tái sử dụng sai.
|
|
17
|
+
*/
|
|
18
|
+
export function fileIndex(workdir, force = false) {
|
|
19
|
+
if (!force && cache && Date.now() - cache.built < 5_000)
|
|
15
20
|
return cache.files;
|
|
16
21
|
const files = [];
|
|
17
22
|
// Fast path: ripgrep --files (gitignore-aware). Bare-name globs match at any
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1-2 (RC-5): fingerprint trạng thái workspace — dùng để KHÔNG chạy lại
|
|
3
|
+
* build/test/typecheck/lint khi không có gì thay đổi.
|
|
4
|
+
*
|
|
5
|
+
* Chi phí: 1 lần `stat` mỗi file (danh sách file lấy từ file-index —
|
|
6
|
+
* gitignore-aware, TTL 5s, đã invalidate mỗi lần tool write/edit). Không đọc
|
|
7
|
+
* nội dung file: size + mtime đổi khi agent sửa code; độ chính xác đủ cho một
|
|
8
|
+
* CACHE (kết quả cũ chỉ được tái sử dụng khi lần trước PASS), và có tắt bằng
|
|
9
|
+
* rc `"verifyCache": false`.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { statSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { fileIndex } from "./file-index.js";
|
|
15
|
+
/** Số file tối đa đưa vào fingerprint (file-index cap thực tế ~vài nghìn). */
|
|
16
|
+
const MAX_FINGERPRINT_FILES = 5_000;
|
|
17
|
+
/**
|
|
18
|
+
* Fingerprint ổn định cho một trạng thái workspace. Trả về chuỗi dài ~40 ký
|
|
19
|
+
* tự; khác 1 byte khi 1 file thêm/bỏ/đổi size hoặc mtime.
|
|
20
|
+
*
|
|
21
|
+
* `fileIndex(workdir, force)` — BỎ QUA cache TTL 5s (và cache one-slot theo
|
|
22
|
+
* workdir khác): file mới tạo bởi `run_command`/trình sửa ngoài PHẢI đổi hash,
|
|
23
|
+
* nếu không kết quả verify cũ sẽ được tái sử dụng sai. Chi phí một lần
|
|
24
|
+
* `rg --files` + stat — nhỏ hơn rất nhiều so với một lệnh build chạy thừa.
|
|
25
|
+
*/
|
|
26
|
+
export function workspaceFingerprint(workdir, cap = MAX_FINGERPRINT_FILES) {
|
|
27
|
+
const files = fileIndex(workdir, true);
|
|
28
|
+
const hash = createHash("sha1");
|
|
29
|
+
const truncated = files.length > cap;
|
|
30
|
+
const list = truncated ? files.slice(0, cap) : files;
|
|
31
|
+
hash.update(`files=${files.length}${truncated ? " (capped)" : ""}\n`);
|
|
32
|
+
for (const rel of list) {
|
|
33
|
+
let st;
|
|
34
|
+
try {
|
|
35
|
+
st = statSync(join(workdir, rel));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
hash.update(`${rel}:gone\n`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
hash.update(`${rel}:${st.size}:${Math.round(st.mtimeMs)}\n`);
|
|
42
|
+
}
|
|
43
|
+
return hash.digest("hex");
|
|
44
|
+
}
|
package/package.json
CHANGED