s-app-scoder-agent 0.9.10 → 0.9.14
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 +2 -0
- package/README.md +1 -1
- package/dist/agent/interaction.js +15 -8
- package/dist/agent/prompt-context.js +5 -0
- package/dist/agent.js +226 -3
- package/dist/cli/repl.js +6 -0
- package/dist/config/config.js +23 -6
- package/dist/context/prompt.js +4 -0
- package/dist/llm/llm.js +3 -1
- package/dist/llm/provider-anthropic.js +5 -3
- package/dist/llm/retry.js +4 -0
- package/dist/mcp/manager.js +2 -2
- package/dist/security/permissions.js +32 -12
- package/dist/tools/read-file.js +4 -2
- package/dist/tools/run-command.js +42 -24
- package/dist/tools/search.js +7 -0
- package/dist/tui/app.js +16 -3
- package/dist/tui/chrome.js +9 -6
- package/dist/tui/hooks/use-keybinds.js +24 -1
- package/dist/tui/hooks/use-scroll.js +12 -3
- package/dist/workspace/file-index.js +6 -5
- package/package.json +1 -1
package/.scoderrc.example.json
CHANGED
package/README.md
CHANGED
|
@@ -395,7 +395,7 @@ Features:
|
|
|
395
395
|
- **Slash commands** — `/help`, `/clear`, `/compact`, `/cost`, `/model`, `/skill`, `/mcp`, `/permission` (identical to the REPL; `/theme` is TUI-only)
|
|
396
396
|
- **Multi-turn** — keep typing follow-ups after a task finishes; the conversation context carries over (same as the REPL)
|
|
397
397
|
- **Scrollback** — `PgUp`/`PgDn` page in 0.8-viewport steps, `Home`/`End` jump to top/bottom, `Esc` returns to the live tail (press again to abort); rendering is capped at the last 200 items for smooth scrolling on long runs
|
|
398
|
-
- **Keybinds** — `Enter` send · `PgUp`/`PgDn` scroll · `Esc` reset scroll / abort · `Ctrl+C` quit
|
|
398
|
+
- **Keybinds** — `Enter` send · `PgUp`/`PgDn` scroll · `Esc` reset scroll / abort · `Ctrl+O` toggle select mode (releases mouse tracking so you can drag-highlight chat text and copy it with `⌘C`/`Ctrl+C`) · `Ctrl+C` quit
|
|
399
399
|
- Non-TTY environments (CI, pipes) automatically fall back to the classic REPL
|
|
400
400
|
- `permission: "ask"` is not supported in the TUI (no readline prompts on the alt screen) — it falls back to `auto`
|
|
401
401
|
|
|
@@ -21,15 +21,22 @@ export class InteractionBroker {
|
|
|
21
21
|
async awaitQuestion(runtime, request, id, handler) {
|
|
22
22
|
try {
|
|
23
23
|
const timeoutMs = (request.timeoutSecs ?? 120) * 1000;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
let timer;
|
|
25
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
26
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
const answer = await Promise.race([handler({ ...request, id }), timeoutPromise]);
|
|
30
|
+
if (answer == null || (typeof answer === "string" && !answer.trim())) {
|
|
31
|
+
return { ok: false, output: "ERROR: ask_user timed out or was cancelled." };
|
|
32
|
+
}
|
|
33
|
+
runtime.logger.log({ type: "ask_user", question: request.question, answer: String(answer).slice(0, 500) });
|
|
34
|
+
return { ok: true, output: `User answered: ${answer}` };
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
if (timer)
|
|
38
|
+
clearTimeout(timer);
|
|
30
39
|
}
|
|
31
|
-
runtime.logger.log({ type: "ask_user", question: request.question, answer: String(answer).slice(0, 500) });
|
|
32
|
-
return { ok: true, output: `User answered: ${answer}` };
|
|
33
40
|
}
|
|
34
41
|
catch (err) {
|
|
35
42
|
return { ok: false, output: `ERROR: ask_user failed: ${err.message.slice(0, 200)}` };
|
|
@@ -88,6 +88,11 @@ export class PromptContext {
|
|
|
88
88
|
clearPendingTools() {
|
|
89
89
|
this.pendingAllowedTools = null;
|
|
90
90
|
}
|
|
91
|
+
/** Non-destructive read — warm-up dựng đúng messages như run() nhưng KHÔNG
|
|
92
|
+
* tiêu thụ pending context (nó thuộc về run thật, take là mất). */
|
|
93
|
+
peekPendingContext() {
|
|
94
|
+
return [...this.pendingContext];
|
|
95
|
+
}
|
|
91
96
|
takePendingContext() {
|
|
92
97
|
const pending = this.pendingContext;
|
|
93
98
|
this.pendingContext = [];
|
package/dist/agent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadPermissionLists, loadPolicyRules, loadSavedApprovals } from "./config/config.js";
|
|
1
|
+
import { loadPermissionLists, loadPolicyRules, loadSavedApprovals, adaptiveToolOutputChars } from "./config/config.js";
|
|
2
2
|
import { LlmClient, LlmError, redactSecrets } from "./llm/llm.js";
|
|
3
3
|
import { AnthropicClient } from "./llm/provider-anthropic.js";
|
|
4
4
|
import { allToolSpecs } from "./tools/registry.js";
|
|
@@ -33,6 +33,12 @@ import { runAgentLoop } from "./agent/run-loop.js";
|
|
|
33
33
|
// CHAT_DONE_MARKER import từ guardrails.js (marker chat thuần, rule 9).
|
|
34
34
|
import { EVENT_SCHEMA_VERSION, applyToolFilter, resolvePrimaryConfig, } from "./agent/types.js";
|
|
35
35
|
export { EVENT_SCHEMA_VERSION, resolvePrimaryConfig } from "./agent/types.js";
|
|
36
|
+
/**
|
|
37
|
+
* FILE-READ-PERF P3: nhịp heartbeat re-warm khi idle — 4' < TTL cache ~5'
|
|
38
|
+
* (Anthropic ephemeral / Ollama keep_alive mặc định), để prefix không bao giờ
|
|
39
|
+
* nguội trong lúc session còn mở.
|
|
40
|
+
*/
|
|
41
|
+
export const WARMUP_IDLE_REFRESH_MS = 240_000;
|
|
36
42
|
export class Agent {
|
|
37
43
|
/** v2 event schema: stable id for the run — links agent_start/agent_end. */
|
|
38
44
|
id = randomUUID();
|
|
@@ -89,6 +95,25 @@ export class Agent {
|
|
|
89
95
|
/** v2: build no-progress tracking — same failure signature twice → escalate. */
|
|
90
96
|
lastVerifySignature = "";
|
|
91
97
|
verifyStrikes = 0;
|
|
98
|
+
/**
|
|
99
|
+
* WARMUP (plan/WARMUP-PLAN.md): request prefill nền — dựng đúng prefix
|
|
100
|
+
* system+tools mà lượt thật sẽ gửi, max_tokens=1, vứt response. Null khi
|
|
101
|
+
* chưa trigger; `settled` sau khi LLM request kết thúc (thành công/abort/lỗi).
|
|
102
|
+
*/
|
|
103
|
+
warmupRun = null;
|
|
104
|
+
/** True after dispose() — warm-up không được để MCP server con mọc lại. */
|
|
105
|
+
disposed = false;
|
|
106
|
+
/**
|
|
107
|
+
* FILE-READ-PERF P3 (plan/FILE-READ-PERF-PLAN.md): heartbeat re-warm khi idle —
|
|
108
|
+
* engine local có keep_alive ~5', model unload thì lượt kế trả 35–56s cold
|
|
109
|
+
* prefill (đo thật). Timer chạy CHỈ lúc nhàn rỗi (settleWarmup tắt, finish()
|
|
110
|
+
* bật lại), unref để không giữ process. CHỈ cho baseUrl loopback.
|
|
111
|
+
*/
|
|
112
|
+
warmupIdleTimer = null;
|
|
113
|
+
/** True nếu frontend này THỰC sự dùng warm-up (REPL/TUI) — serve/run-once không heartbeat. */
|
|
114
|
+
warmupUsed = false;
|
|
115
|
+
/** Test knob: thu ngắn nhịp heartbeat (default WARMUP_IDLE_REFRESH_MS). */
|
|
116
|
+
warmupIdleRefreshMs = WARMUP_IDLE_REFRESH_MS;
|
|
92
117
|
/** v2: ask_user plumbing (frontends register handlers; serve → not-interactive). */
|
|
93
118
|
/** Guardrail Engine — khởi tạo trong constructor (cần this.config). */
|
|
94
119
|
guardrails;
|
|
@@ -411,7 +436,7 @@ export class Agent {
|
|
|
411
436
|
const servers = rc.mcpServers ?? {};
|
|
412
437
|
this.config.mcpServers = servers;
|
|
413
438
|
if (this.mcp) {
|
|
414
|
-
void this.mcp.applyConfig(servers);
|
|
439
|
+
void this.mcp.applyConfig(servers).catch(() => { });
|
|
415
440
|
}
|
|
416
441
|
}
|
|
417
442
|
});
|
|
@@ -424,6 +449,8 @@ export class Agent {
|
|
|
424
449
|
abort() {
|
|
425
450
|
this.aborted = true;
|
|
426
451
|
this.runSignal.abort(new DOMException("interrupted by user", "AbortError"));
|
|
452
|
+
// WARMUP: SIGINT giết luôn request prefill nếu còn chạy nền.
|
|
453
|
+
this.warmupRun?.abort.abort(new DOMException("interrupted by user", "AbortError"));
|
|
427
454
|
}
|
|
428
455
|
/**
|
|
429
456
|
* Steer: user input delivered at the NEXT safe step boundary (pattern from
|
|
@@ -848,8 +875,10 @@ export class Agent {
|
|
|
848
875
|
* server tự cắt history, và mỗi lần nó cắt là một lần mất KV-cache.
|
|
849
876
|
*/
|
|
850
877
|
async applyContextProbe() {
|
|
851
|
-
if (this.config.contextWindow)
|
|
878
|
+
if (this.config.contextWindow) {
|
|
879
|
+
this.applyAdaptiveReadBudget();
|
|
852
880
|
return;
|
|
881
|
+
}
|
|
853
882
|
let probe = null;
|
|
854
883
|
try {
|
|
855
884
|
// Server local vẫn có thể bật --api-key: /props trả 401 nếu không gửi
|
|
@@ -875,11 +904,184 @@ export class Agent {
|
|
|
875
904
|
if (probe.tokens !== catalog) {
|
|
876
905
|
this.reporter.info(`context window: ${probe.tokens} token (${probe.detail}) — thay fallback ${catalog} token, compaction/trim tính theo số này`);
|
|
877
906
|
}
|
|
907
|
+
this.applyAdaptiveReadBudget();
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* FILE-READ-PERF (plan/FILE-READ-PERF-PLAN.md) P1: nới ngân sách một lần đọc
|
|
911
|
+
* theo window THẬT khi local + user không đặt tường minh. Chạy một lần; giá
|
|
912
|
+
* trị ghi cả vào config.limits (safety-net ở tool-executor) lẫn module budget
|
|
913
|
+
* mà read-file.ts cắt theo. Probe thất bại (không có window) → giữ floor cũ.
|
|
914
|
+
*/
|
|
915
|
+
readBudgetAdapted = false;
|
|
916
|
+
applyAdaptiveReadBudget() {
|
|
917
|
+
if (this.readBudgetAdapted)
|
|
918
|
+
return;
|
|
919
|
+
const window = this.config.contextWindow;
|
|
920
|
+
if (!window || !isLocalBaseUrl(this.config.baseUrl) || this.config.localProfile === false)
|
|
921
|
+
return;
|
|
922
|
+
if (this.config.limits?.toolOutputChars !== undefined)
|
|
923
|
+
return; // user thắng, không đụng
|
|
924
|
+
const budget = adaptiveToolOutputChars(window);
|
|
925
|
+
this.config.limits = { ...this.config.limits, toolOutputChars: budget };
|
|
926
|
+
setMaxToolOutputChars(budget);
|
|
927
|
+
this.readBudgetAdapted = true;
|
|
928
|
+
this.logger.log({ type: "read_budget", chars: budget, window });
|
|
878
929
|
}
|
|
879
930
|
/** Rebuild built-in and connected MCP tool specs for the current skill scope. */
|
|
880
931
|
rebuildToolList() {
|
|
881
932
|
rebuildMcpToolList(this.mcpBridgeRuntime());
|
|
882
933
|
}
|
|
934
|
+
/**
|
|
935
|
+
* WARMUP (plan/WARMUP-PLAN.md): prefill prefix system+tools NỀN ngay khi mở
|
|
936
|
+
* session, để lượt chat đầu không phải trả cold-load + 20–60k token prefill
|
|
937
|
+
* trên đường tới token đầu (đo: 30–120s trên máy cá nhân, xem P2-3). One-shot,
|
|
938
|
+
* idempotent, fire-and-forget — lỗi im lặng (best-effort).
|
|
939
|
+
* `rewarm` (FILE-READ-PERF P3): bản heartbeat — bỏ guard "chưa có messages",
|
|
940
|
+
* tiền đề là entry trước đã settle.
|
|
941
|
+
*/
|
|
942
|
+
warmup(opts = {}) {
|
|
943
|
+
const w = this.warmupRun;
|
|
944
|
+
if (w && (!w.settled || !opts.rewarm))
|
|
945
|
+
return w.promise;
|
|
946
|
+
if (this.config.warmup === false)
|
|
947
|
+
return Promise.resolve();
|
|
948
|
+
// aborted chỉ chặn warm KHỞI ĐẦU; heartbeat sống lại sau lượt kế (resetRunSignal).
|
|
949
|
+
if (this.disposed || (this.aborted && !opts.rewarm))
|
|
950
|
+
return Promise.resolve();
|
|
951
|
+
if (!opts.rewarm && this.messages.length > 0)
|
|
952
|
+
return Promise.resolve();
|
|
953
|
+
if (!this.config.apiKey)
|
|
954
|
+
return Promise.resolve();
|
|
955
|
+
// I-12: harness eval thay client bằng scripted transcript — warm-up sẽ ăn
|
|
956
|
+
// mất một lượt kịch bản, làm hỏng mọi assertion đếm request.
|
|
957
|
+
if (this.config.clientFactory)
|
|
958
|
+
return Promise.resolve();
|
|
959
|
+
const abort = new AbortController();
|
|
960
|
+
const entry = { abort, settled: false, promise: Promise.resolve() };
|
|
961
|
+
this.warmupUsed = true;
|
|
962
|
+
entry.promise = this.runWarmup(abort.signal, () => {
|
|
963
|
+
entry.settled = true;
|
|
964
|
+
}, opts.rewarm === true);
|
|
965
|
+
this.warmupRun = entry;
|
|
966
|
+
return entry.promise;
|
|
967
|
+
}
|
|
968
|
+
async runWarmup(signal, settle, rewarm = false) {
|
|
969
|
+
const t0 = Date.now();
|
|
970
|
+
try {
|
|
971
|
+
// Dời hai việc network ra khỏi đường tới token đầu. run() vẫn gọi lại
|
|
972
|
+
// nguyên như cũ — cả hai đều return sớm khi đã hoàn tất ở đây.
|
|
973
|
+
await Promise.allSettled([this.applyContextProbe(), this.connectMcp()]);
|
|
974
|
+
if (signal.aborted || this.disposed)
|
|
975
|
+
return;
|
|
976
|
+
// FILE-READ-PERF P3: heartbeat sau khi đã chạy ≥1 lượt thật gửi lại
|
|
977
|
+
// HISTORY THẬT — đó mới là prefix của lượt kế tiếp (placeholder sẽ phân
|
|
978
|
+
// nhánh ngay sau system). Trước lượt đầu: dựng đúng messages như run().
|
|
979
|
+
const hasHistory = this.messages.length > 0;
|
|
980
|
+
// Tool list + messages dựng ĐÚNG như run() — cache-key (Anthropic
|
|
981
|
+
// breakpoint, prefix KV của llama.cpp) là từng byte JSON của schema.
|
|
982
|
+
const pendingAllowedTools = this.promptContext.pendingTools();
|
|
983
|
+
const allow = pendingAllowedTools?.length
|
|
984
|
+
? new Set(pendingAllowedTools.map((t) => t.toLowerCase()))
|
|
985
|
+
: null;
|
|
986
|
+
let baseTools = applyToolFilter(allToolSpecs(), this.primaryDef, this.reporter);
|
|
987
|
+
if (allow)
|
|
988
|
+
baseTools = baseTools.filter((s) => allow.has(s.function.name.toLowerCase()));
|
|
989
|
+
const mcpSpecs = governedMcpSpecs(this.mcpBridgeRuntime());
|
|
990
|
+
const tools = allow
|
|
991
|
+
? [...baseTools, ...mcpSpecs.filter((s) => allow.has(s.function.name.toLowerCase()))]
|
|
992
|
+
: [...baseTools, ...mcpSpecs];
|
|
993
|
+
const messages = hasHistory
|
|
994
|
+
? this.messages
|
|
995
|
+
: buildRunMessages({
|
|
996
|
+
config: this.config,
|
|
997
|
+
primaryDef: this.primaryDef,
|
|
998
|
+
skills: this.promptContext.getSkills(),
|
|
999
|
+
buildCmd: this.buildCmd,
|
|
1000
|
+
// Placeholder KHÔNG mention file nào: system prompt trùng byte với
|
|
1001
|
+
// request thật ở mọi task không có focus-file guide (xem plan §7).
|
|
1002
|
+
task: "[warmup]",
|
|
1003
|
+
pendingContext: this.promptContext.peekPendingContext(),
|
|
1004
|
+
});
|
|
1005
|
+
const res = await this.client.chat(messages, tools, { signal, maxTokens: 1 });
|
|
1006
|
+
this.logger.log({
|
|
1007
|
+
type: "warmup",
|
|
1008
|
+
ok: true,
|
|
1009
|
+
rewarm,
|
|
1010
|
+
ms: Date.now() - t0,
|
|
1011
|
+
model: this.client.model,
|
|
1012
|
+
promptTokens: res.usage?.prompt_tokens,
|
|
1013
|
+
cachedTokens: res.usage?.cached_tokens,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
catch (err) {
|
|
1017
|
+
// Warm-up không bao giờ được làm hỏng run thật — kể cả SIGINT abort.
|
|
1018
|
+
this.logger.log({
|
|
1019
|
+
type: "warmup",
|
|
1020
|
+
ok: false,
|
|
1021
|
+
ms: Date.now() - t0,
|
|
1022
|
+
error: (err?.message ?? String(err)).slice(0, 200),
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
finally {
|
|
1026
|
+
settle();
|
|
1027
|
+
// dispose() chạy giữa chừng: kill lại MCP servers mà connectMcp của
|
|
1028
|
+
// warm-up vừa spawn (killAll của dispose đã chạy TRƯỚC khi chúng mọc).
|
|
1029
|
+
if (this.disposed)
|
|
1030
|
+
this.shutdownMcp();
|
|
1031
|
+
// FILE-READ-PERF P3: cycle warm kết thúc TỰ NHIÊN (không bị supersede/
|
|
1032
|
+
// abort) → arm nhịp heartbeat tiếp theo. Bị abort nghĩa là run thật đang
|
|
1033
|
+
// chạy — finish() sẽ arm lại khi xong.
|
|
1034
|
+
else if (!signal.aborted)
|
|
1035
|
+
this.scheduleWarmupRefresh();
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Đầu mọi run thật (run/runTurn/resume): lượt thật thắng warm-up — abort
|
|
1040
|
+
* request prefill nếu còn treo, rồi await cho settle để connectMcp/probe
|
|
1041
|
+
* không double-spawn server con song song với chính nó.
|
|
1042
|
+
*/
|
|
1043
|
+
async settleWarmup() {
|
|
1044
|
+
// Run thật bắt đầu → heartbeat phải im lặng (timer re-arm ở finish()).
|
|
1045
|
+
this.clearWarmupTimer();
|
|
1046
|
+
const w = this.warmupRun;
|
|
1047
|
+
if (!w)
|
|
1048
|
+
return;
|
|
1049
|
+
if (!w.settled)
|
|
1050
|
+
w.abort.abort(new DOMException("real run supersedes warmup", "AbortError"));
|
|
1051
|
+
await w.promise.catch(() => { });
|
|
1052
|
+
}
|
|
1053
|
+
/** Tắt heartbeat idle (run thật vào / dispose). */
|
|
1054
|
+
clearWarmupTimer() {
|
|
1055
|
+
if (this.warmupIdleTimer) {
|
|
1056
|
+
clearTimeout(this.warmupIdleTimer);
|
|
1057
|
+
this.warmupIdleTimer = null;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* FILE-READ-PERF P3: arm lại nhịp re-warm — chỉ khi engine local và bộ máy
|
|
1062
|
+
* warmup còn bật (đúng các guard của warmup()). unref(): timer không được
|
|
1063
|
+
* giữ process sống (run-once/test thoát sạch).
|
|
1064
|
+
*/
|
|
1065
|
+
scheduleWarmupRefresh() {
|
|
1066
|
+
if (this.disposed || !this.warmupUsed)
|
|
1067
|
+
return;
|
|
1068
|
+
if (this.config.warmup === false || !this.config.apiKey || this.config.clientFactory) {
|
|
1069
|
+
this.clearWarmupTimer();
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
// KHÔNG phải local (kể cả /model đổi từ local → cloud giữa session): tắt
|
|
1073
|
+
// heartbeat đang arm — không đốt tiền API để giữ cache.
|
|
1074
|
+
if (!isLocalBaseUrl(this.config.baseUrl)) {
|
|
1075
|
+
this.clearWarmupTimer();
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
this.clearWarmupTimer();
|
|
1079
|
+
this.warmupIdleTimer = setTimeout(() => {
|
|
1080
|
+
this.warmupIdleTimer = null;
|
|
1081
|
+
void this.warmup({ rewarm: true }).catch(() => { });
|
|
1082
|
+
}, this.warmupIdleRefreshMs);
|
|
1083
|
+
this.warmupIdleTimer.unref();
|
|
1084
|
+
}
|
|
883
1085
|
/**
|
|
884
1086
|
* Disconnect MCP servers when the agent is disposed — connections are kept
|
|
885
1087
|
* alive for the whole agent/session lifetime (one "connected" line, no
|
|
@@ -891,8 +1093,13 @@ export class Agent {
|
|
|
891
1093
|
}
|
|
892
1094
|
/** M4: đóng hot-reload watcher (khi agent bị hủy vĩnh viễn). */
|
|
893
1095
|
dispose() {
|
|
1096
|
+
this.disposed = true;
|
|
1097
|
+
this.clearWarmupTimer();
|
|
894
1098
|
this.watcher?.close();
|
|
895
1099
|
this.watcher = null;
|
|
1100
|
+
// WARMUP: hủy request prefill đang chạy (chặn MCP servers connect thêm
|
|
1101
|
+
// sau khi kill — runWarmup tự shutdownMcp lần cuối trong finally).
|
|
1102
|
+
this.warmupRun?.abort.abort(new DOMException("agent disposed", "AbortError"));
|
|
896
1103
|
this.shutdownMcp();
|
|
897
1104
|
}
|
|
898
1105
|
/**
|
|
@@ -934,6 +1141,8 @@ export class Agent {
|
|
|
934
1141
|
if (!this.config.apiKey) {
|
|
935
1142
|
throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
|
|
936
1143
|
}
|
|
1144
|
+
// WARMUP: lượt thật thắng request prefill đang chạy nền (xem warmup()).
|
|
1145
|
+
await this.settleWarmup();
|
|
937
1146
|
const buildCmd = this.buildCmd;
|
|
938
1147
|
const rawTask = task;
|
|
939
1148
|
// @file mention expansion — task trước khi build messages.
|
|
@@ -974,6 +1183,8 @@ export class Agent {
|
|
|
974
1183
|
else {
|
|
975
1184
|
this.tools = [...baseTools, ...mcpSpecs];
|
|
976
1185
|
}
|
|
1186
|
+
// Tool list (re)built for this run — previous calibration no longer valid.
|
|
1187
|
+
this.measuredFixedTokens = null;
|
|
977
1188
|
this.messages = buildRunMessages({
|
|
978
1189
|
config: this.config,
|
|
979
1190
|
primaryDef: this.primaryDef,
|
|
@@ -1025,6 +1236,9 @@ export class Agent {
|
|
|
1025
1236
|
if (this.messages.length === 0) {
|
|
1026
1237
|
return this.run(task);
|
|
1027
1238
|
}
|
|
1239
|
+
// WARMUP: thường đã settle ở run() đầu; await lại cho chắc (race /clear+
|
|
1240
|
+
// warm-up + runTurn ngay lập tức).
|
|
1241
|
+
await this.settleWarmup();
|
|
1028
1242
|
const buildCmd = this.buildCmd;
|
|
1029
1243
|
const rawTask = task;
|
|
1030
1244
|
task = expandMentions(task, this.config.workdir);
|
|
@@ -1033,6 +1247,10 @@ export class Agent {
|
|
|
1033
1247
|
this.resetUsageTotals();
|
|
1034
1248
|
this.filesModified = false;
|
|
1035
1249
|
this.plan = [];
|
|
1250
|
+
this.runDiffs = [];
|
|
1251
|
+
this.selfReviewDone = false;
|
|
1252
|
+
this.verifyStrikes = 0;
|
|
1253
|
+
this.lastVerifySignature = "";
|
|
1036
1254
|
for (const content of this.promptContext.takePendingContext())
|
|
1037
1255
|
this.messages.push({ role: "user", content });
|
|
1038
1256
|
this.messages.push({ role: "user", content: task });
|
|
@@ -1049,6 +1267,9 @@ export class Agent {
|
|
|
1049
1267
|
* An optional follow-up task is appended as a new user message.
|
|
1050
1268
|
*/
|
|
1051
1269
|
async resume(session, followUp) {
|
|
1270
|
+
// WARMUP: session restore mang system prompt RIÊNG (từ session cũ) — hủy
|
|
1271
|
+
// request prefill của prompt mới để không nhân đôi prefill nền.
|
|
1272
|
+
await this.settleWarmup();
|
|
1052
1273
|
const buildCmd = this.buildCmd;
|
|
1053
1274
|
this.sessionCreatedAt = session.createdAt;
|
|
1054
1275
|
this.currentTask = session.task;
|
|
@@ -1325,6 +1546,8 @@ export class Agent {
|
|
|
1325
1546
|
: { ok: null, detail: "no verification command ran" };
|
|
1326
1547
|
this.emit({ type: "finish", success, steps, summary: summary.slice(0, 2000), usage: this.getUsage(), verification });
|
|
1327
1548
|
this.logger.log({ type: "finish", success, steps });
|
|
1549
|
+
// FILE-READ-PERF P3: run kết thúc → session quay về nhàn rỗi, arm heartbeat.
|
|
1550
|
+
this.scheduleWarmupRefresh();
|
|
1328
1551
|
return { success, steps, buildCmd, summary: summary.slice(0, 2000) };
|
|
1329
1552
|
}
|
|
1330
1553
|
}
|
package/dist/cli/repl.js
CHANGED
|
@@ -12,6 +12,9 @@ export async function repl(cfg) {
|
|
|
12
12
|
// every input is a follow-up turn, context carries over, saved continuously.
|
|
13
13
|
// /clear swaps in a fresh Agent (the /clear branch reassigns this).
|
|
14
14
|
let agent = new Agent(cfg);
|
|
15
|
+
// WARMUP (plan/WARMUP-PLAN.md): dựng MCP+probe+prefill system+tools nền
|
|
16
|
+
// trong lúc user còn đọc prompt — lệnh đầu không trả 30–120s cold prefill.
|
|
17
|
+
void agent.warmup();
|
|
15
18
|
process.on("SIGINT", () => {
|
|
16
19
|
ui.warn("\nSIGINT — aborting current task...");
|
|
17
20
|
agent.abort();
|
|
@@ -37,6 +40,9 @@ export async function repl(cfg) {
|
|
|
37
40
|
agent.dispose();
|
|
38
41
|
agent = next ?? new Agent(cfg);
|
|
39
42
|
first = true;
|
|
43
|
+
// Agent mới = prefix mới (session id, repo state) — warm-up lại từ đầu.
|
|
44
|
+
// Với `next` đã có messages (resume) warmup() tự no-op.
|
|
45
|
+
void agent.warmup();
|
|
40
46
|
},
|
|
41
47
|
};
|
|
42
48
|
const { handled, outcome } = await dispatchCommand(input, host, (text, level) => {
|
package/dist/config/config.js
CHANGED
|
@@ -68,11 +68,29 @@ export const LOCAL_PROFILE_DEFAULTS = {
|
|
|
68
68
|
maxSteps: 24,
|
|
69
69
|
/** P2-3: prefill 20–60k token trên máy cá nhân có thể vượt 120s → timeout + retry. */
|
|
70
70
|
requestTimeoutMs: 600_000,
|
|
71
|
-
/**
|
|
72
|
-
|
|
71
|
+
/**
|
|
72
|
+
* FILE-READ-PERF P1: profile KHÔNG pin toolOutputChars nữa (trước là 6000) —
|
|
73
|
+
* ngân sách đọc giờ suy từ context window THẬT qua adaptiveToolOutputChars,
|
|
74
|
+
* apply ở cuối applyContextProbe (agent.ts). Window nhỏ vẫn ra 6000 như cũ.
|
|
75
|
+
*/
|
|
73
76
|
/** P0-3: trần token cho MCP schema; 46 tool GitHub MCP ≈ 12k token mỗi lượt. */
|
|
74
77
|
mcpSchemaTokens: 6_000,
|
|
75
78
|
};
|
|
79
|
+
/**
|
|
80
|
+
* FILE-READ-PERF (plan/FILE-READ-PERF-PLAN.md) N1: một lần đọc file bị cắt
|
|
81
|
+
* thành 5–6 MẢNH vì budget 6000 ký tự, mà mỗi mảnh = một lượt LLM tròn
|
|
82
|
+
* (đo: 8–11s/mảnh với README 28KB). Nới budget theo window đã probe:
|
|
83
|
+
* 0.17 ký tự mỗi token window ≈ 4% history cho một tool result
|
|
84
|
+
* (4 chars ≈ 1 token) — window 16k giữ nguyên 6000, 96k lên trần 16000.
|
|
85
|
+
*/
|
|
86
|
+
export const TOOL_OUTPUT_WINDOW_SHARE = 0.17;
|
|
87
|
+
export const TOOL_OUTPUT_CHARS_FLOOR = 6_000;
|
|
88
|
+
export const TOOL_OUTPUT_CHARS_CEIL = 16_000;
|
|
89
|
+
export function adaptiveToolOutputChars(windowTokens) {
|
|
90
|
+
if (!Number.isFinite(windowTokens) || windowTokens <= 0)
|
|
91
|
+
return TOOL_OUTPUT_CHARS_FLOOR;
|
|
92
|
+
return Math.min(TOOL_OUTPUT_CHARS_CEIL, Math.max(TOOL_OUTPUT_CHARS_FLOOR, Math.round(windowTokens * TOOL_OUTPUT_WINDOW_SHARE)));
|
|
93
|
+
}
|
|
76
94
|
/**
|
|
77
95
|
* Áp mặc định phù hợp cho local engine. Trả về danh sách thay đổi đã áp (để
|
|
78
96
|
* log/kiểm thử); rỗng khi không áp dụng (cloud, hoặc localProfile:false).
|
|
@@ -98,10 +116,8 @@ export function applyLocalProfile(cfg, explicit = {}) {
|
|
|
98
116
|
limits.requestTimeoutMs = LOCAL_PROFILE_DEFAULTS.requestTimeoutMs;
|
|
99
117
|
applied.push(`requestTimeoutMs=${LOCAL_PROFILE_DEFAULTS.requestTimeoutMs}`);
|
|
100
118
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
applied.push(`toolOutputChars=${LOCAL_PROFILE_DEFAULTS.toolOutputChars}`);
|
|
104
|
-
}
|
|
119
|
+
// FILE-READ-PERF P1: toolOutputChars KHÔNG pin ở đây nữa — để undefined để
|
|
120
|
+
// applyAdaptiveReadBudget nhận diện "user không đặt" và suy từ window.
|
|
105
121
|
if (limits.mcpSchemaTokens === undefined) {
|
|
106
122
|
limits.mcpSchemaTokens = LOCAL_PROFILE_DEFAULTS.mcpSchemaTokens;
|
|
107
123
|
applied.push(`mcpSchemaTokens=${LOCAL_PROFILE_DEFAULTS.mcpSchemaTokens}`);
|
|
@@ -304,6 +320,7 @@ export function loadConfig(overrides = {}) {
|
|
|
304
320
|
agent: overrides.agent ?? process.env.SCODER_AGENT ?? rc.agent,
|
|
305
321
|
routing: rc.routing,
|
|
306
322
|
repoMap: rc.repoMap,
|
|
323
|
+
warmup: rc.warmup,
|
|
307
324
|
promptVersion: rc.promptVersion,
|
|
308
325
|
verifyCmds: rc.verifyCmds,
|
|
309
326
|
selfReview: rc.selfReview,
|
package/dist/context/prompt.js
CHANGED
|
@@ -259,6 +259,10 @@ export const PROMPT_SECTIONS = {
|
|
|
259
259
|
tools: `## Tool policy
|
|
260
260
|
- Orient with list_files / grep / glob / repo_map — prefer them over shell
|
|
261
261
|
greps. read_file before every edit_file.
|
|
262
|
+
- read_file: when a result ends with "N more line(s)", read the REST in ONE
|
|
263
|
+
step — issue several read_file calls with successive offset/limit covering
|
|
264
|
+
every remaining chunk at once (they run in parallel). Never re-read lines
|
|
265
|
+
you already have.
|
|
262
266
|
- edit_file: old_string MUST appear exactly once — otherwise re-read the file
|
|
263
267
|
and add more context.
|
|
264
268
|
- run_command IS the build command: run it only when you changed files; a
|
package/dist/llm/llm.js
CHANGED
|
@@ -169,7 +169,9 @@ export class LlmClient {
|
|
|
169
169
|
messages: toWireMessages(messages),
|
|
170
170
|
tools: tools.length > 0 ? tools : undefined,
|
|
171
171
|
temperature: this.opts.temperature,
|
|
172
|
-
|
|
172
|
+
// WARMUP: override max_tokens cho request prefill (1 token là đủ để
|
|
173
|
+
// engine tính xong KV-cache của prefix) — lượt thật không truyền key này.
|
|
174
|
+
max_tokens: opts.maxTokens ?? this.opts.maxTokens,
|
|
173
175
|
stream: false,
|
|
174
176
|
});
|
|
175
177
|
let res;
|
|
@@ -153,12 +153,12 @@ export class AnthropicClient {
|
|
|
153
153
|
this.opts = { ...this.opts, model };
|
|
154
154
|
}
|
|
155
155
|
/** I-05: request body builder — shared by chat/streamChat. */
|
|
156
|
-
buildBody(system, messages, tools, stream) {
|
|
156
|
+
buildBody(system, messages, tools, stream, maxTokensOverride) {
|
|
157
157
|
// Extended thinking: temperature must be 1 và thinking block format.
|
|
158
158
|
const thinkingOn = (this.opts.thinkingBudgetTokens ?? 0) > 0;
|
|
159
159
|
return JSON.stringify({
|
|
160
160
|
model: this.opts.model,
|
|
161
|
-
max_tokens: this.opts.maxTokens,
|
|
161
|
+
max_tokens: maxTokensOverride ?? this.opts.maxTokens,
|
|
162
162
|
temperature: thinkingOn ? 1 : this.opts.temperature,
|
|
163
163
|
...(thinkingOn ? { thinking: { type: "enabled", budget_tokens: this.opts.thinkingBudgetTokens } } : {}),
|
|
164
164
|
system,
|
|
@@ -172,7 +172,9 @@ export class AnthropicClient {
|
|
|
172
172
|
const { system, messages: translated } = toAnthropicMessages(messages);
|
|
173
173
|
const { signal, cleanup } = combinedSignal(opts.signal, this.timeoutMs);
|
|
174
174
|
const cached = this.applyCacheControl(system, translated, tools);
|
|
175
|
-
|
|
175
|
+
// WARMUP: override max_tokens cho request prefill — các breakpoint
|
|
176
|
+
// cache_control (tools + system) đã áp dụng y hệt lượt thật.
|
|
177
|
+
const body = this.buildBody(cached.system, cached.messages, cached.tools, false, opts.maxTokens);
|
|
176
178
|
let res;
|
|
177
179
|
try {
|
|
178
180
|
res = await withRetry(async () => {
|
package/dist/llm/retry.js
CHANGED
|
@@ -32,6 +32,10 @@ export async function withRetry(fn, opts = {}, onRetry) {
|
|
|
32
32
|
catch (err) {
|
|
33
33
|
lastErr = err;
|
|
34
34
|
const status = err.status;
|
|
35
|
+
// WARMUP: abort của CHÍNH caller (warm-up bị lượt thật supersede, SIGINT)
|
|
36
|
+
// không bao giờ được retry — message có thể là bất kỳ chuỗi nào.
|
|
37
|
+
if (err?.name === "AbortError")
|
|
38
|
+
throw err;
|
|
35
39
|
const retryable = (typeof status === "number" && isRetryableStatus(status)) ||
|
|
36
40
|
(!(err instanceof NonRetryableError) && isRetryableNetworkError(err));
|
|
37
41
|
// Client errors (except 429) — fail immediately
|
package/dist/mcp/manager.js
CHANGED
|
@@ -89,8 +89,8 @@ export class McpManager {
|
|
|
89
89
|
.listTools()
|
|
90
90
|
.then((defs) => this.reregisterTools(name, conn, defs))
|
|
91
91
|
.catch(() => { });
|
|
92
|
-
void this.refreshResources(name, conn);
|
|
93
|
-
void this.refreshPrompts(name, conn);
|
|
92
|
+
void this.refreshResources(name, conn).catch(() => { });
|
|
93
|
+
void this.refreshPrompts(name, conn).catch(() => { });
|
|
94
94
|
for (const uri of this.subscriptions.get(name) ?? []) {
|
|
95
95
|
void conn.subscribeResource(uri).catch(() => { });
|
|
96
96
|
}
|
|
@@ -272,22 +272,29 @@ export class PermissionChecker {
|
|
|
272
272
|
// T3: timeout enforced HERE (checker-level) — a hung frontend handler
|
|
273
273
|
// (TUI modal never answered, server client disconnected) must not leak
|
|
274
274
|
// the pending permission promise forever.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
let timer;
|
|
276
|
+
const timeoutPromise = new Promise((_, rej) => {
|
|
277
|
+
timer = setTimeout(() => {
|
|
278
278
|
const e = new Error("permission handler timed out");
|
|
279
279
|
e.name = "AbortError";
|
|
280
280
|
rej(e);
|
|
281
|
-
}, req.timeoutSecs * 1000)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
this.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
281
|
+
}, req.timeoutSecs * 1000);
|
|
282
|
+
});
|
|
283
|
+
try {
|
|
284
|
+
const answer = await Promise.race([this.promptHandler(req), timeoutPromise]);
|
|
285
|
+
if (answer === "quit")
|
|
286
|
+
this.abortRequested = true;
|
|
287
|
+
if (answer === "always" && req.kind === "edit")
|
|
288
|
+
this.allowAllEdits = true;
|
|
289
|
+
if (answer === "always" && req.kind === "shell" && req.alwaysAllowPrefix) {
|
|
290
|
+
this.sessionAllow.addPrefix(req.alwaysAllowPrefix);
|
|
291
|
+
}
|
|
292
|
+
return answer === "yes" || answer === "always";
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
if (timer)
|
|
296
|
+
clearTimeout(timer);
|
|
289
297
|
}
|
|
290
|
-
return answer === "yes" || answer === "always";
|
|
291
298
|
}
|
|
292
299
|
catch {
|
|
293
300
|
return false; // handler error/timeout — deny
|
|
@@ -461,6 +468,19 @@ export class PermissionChecker {
|
|
|
461
468
|
};
|
|
462
469
|
}
|
|
463
470
|
}
|
|
471
|
+
// Command substitution $(...) / backticks can hide arbitrary commands inside
|
|
472
|
+
// an otherwise-allowed prefix (e.g. `echo $(rm -rf /)`). Treat as ask
|
|
473
|
+
// unless an explicit rule allows the whole command line.
|
|
474
|
+
if (scan?.ok && scan.hasCommandSubstitution) {
|
|
475
|
+
const e = matchRule(this.activeRules(), "shell", command);
|
|
476
|
+
if (e !== "allow") {
|
|
477
|
+
return {
|
|
478
|
+
decision: "ask",
|
|
479
|
+
reason: "command substitution ($() / backticks) — approval required",
|
|
480
|
+
alwaysAllowPrefix: extractFirstWord(command) + " ",
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
464
484
|
return this.classify(command);
|
|
465
485
|
}
|
|
466
486
|
/**
|
package/dist/tools/read-file.js
CHANGED
|
@@ -74,8 +74,10 @@ export function toolReadFile(workdir, args) {
|
|
|
74
74
|
const header = offset > 1 || end < total ? `[lines ${offset}-${end} of ${total} — ${args.path}]\n` : "";
|
|
75
75
|
const footer = dropped > 0
|
|
76
76
|
? `\n... [${dropped} more line(s): ${end + 1}-${offset + slice.length - 1} of ${total}. ` +
|
|
77
|
-
`Read the
|
|
78
|
-
`
|
|
77
|
+
`Read the REST in ONE step — parallel read_file calls at ` +
|
|
78
|
+
`{"path":"${args.path}","offset":${end + 1}}, ` +
|
|
79
|
+
`{"path":"${args.path}","offset":${end + 1 + kept.length}}, ... ` +
|
|
80
|
+
`until the file is done; do NOT re-read from the start.]`
|
|
79
81
|
: "";
|
|
80
82
|
// 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
83
|
const numbered = clamp(kept.join("\n"), budget);
|
|
@@ -116,23 +116,34 @@ export async function toolRunCommand(workdir, args, signal, onOutput, sandbox, r
|
|
|
116
116
|
cwd: workdir,
|
|
117
117
|
stdio: ["ignore", "pipe", "pipe"],
|
|
118
118
|
env: buildChildEnv(workdir), // G3: allowlist — không kế thừa full process.env
|
|
119
|
+
// Own process group so killTree(-pid) can reap grandchildren (see killTree).
|
|
120
|
+
detached: process.platform !== "win32",
|
|
119
121
|
});
|
|
122
|
+
let exited = false;
|
|
120
123
|
let stdout = "";
|
|
121
124
|
let stderr = "";
|
|
122
125
|
let killedReason = null;
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
// Linux/macOS: sh fork command con (sleep, make, npm…) rồi chết trước —
|
|
127
|
+
// kill mỗi `child` để chúng mồ côi, còn giữ PIPE => 'close' không bao giờ
|
|
128
|
+
// tới, tool treo, run không finish (fail CI serve-abort reproducibly).
|
|
129
|
+
// detached:true cho child một process group riêng -> kill cả nhóm qua -pid.
|
|
130
|
+
const killTree = (sig) => {
|
|
125
131
|
try {
|
|
126
|
-
|
|
132
|
+
if (process.platform !== "win32" && child.pid)
|
|
133
|
+
process.kill(-child.pid, sig);
|
|
134
|
+
else
|
|
135
|
+
child.kill(sig);
|
|
127
136
|
}
|
|
128
|
-
catch { /* already dead */ }
|
|
129
|
-
|
|
137
|
+
catch { /* already dead / group gone */ }
|
|
138
|
+
};
|
|
139
|
+
const killChild = (reason) => {
|
|
140
|
+
killedReason = reason;
|
|
141
|
+
killTree("SIGTERM");
|
|
142
|
+
// SIGKILL escalation — sh chết nhưng GRANDCHILD ignore TERM thì group
|
|
143
|
+
// vẫn sống; exitCode của bản thân sh không phản ánh điều đó, dùng `exited`.
|
|
130
144
|
setTimeout(() => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
child.kill("SIGKILL");
|
|
134
|
-
}
|
|
135
|
-
catch { /* already dead */ }
|
|
145
|
+
if (!exited)
|
|
146
|
+
killTree("SIGKILL");
|
|
136
147
|
}, 5_000).unref();
|
|
137
148
|
};
|
|
138
149
|
const timer = setTimeout(() => killChild("timeout"), timeoutMs);
|
|
@@ -156,34 +167,41 @@ export async function toolRunCommand(workdir, args, signal, onOutput, sandbox, r
|
|
|
156
167
|
child.on("error", (err) => {
|
|
157
168
|
finish({ ok: false, output: clamp(`${echoLine}\n(spawn error) ${err.message}`) });
|
|
158
169
|
});
|
|
159
|
-
|
|
170
|
+
const buildResult = (code) => {
|
|
160
171
|
if (killedReason === "timeout") {
|
|
161
|
-
|
|
172
|
+
return {
|
|
162
173
|
ok: false,
|
|
163
174
|
output: clamp(`${echoLine}\n(command timed out after ${Math.round(timeoutMs / 1000)}s and was killed)\n--- stdout ---\n${redactSecrets(stdout)}\n--- stderr ---\n${redactSecrets(stderr)}`),
|
|
164
|
-
}
|
|
165
|
-
return;
|
|
175
|
+
};
|
|
166
176
|
}
|
|
167
177
|
if (killedReason === "aborted") {
|
|
168
|
-
|
|
178
|
+
return {
|
|
169
179
|
ok: false,
|
|
170
180
|
output: clamp(`${echoLine}\n(interrupted by user)\n--- stdout ---\n${redactSecrets(stdout)}\n--- stderr ---\n${redactSecrets(stderr)}`),
|
|
171
|
-
}
|
|
172
|
-
return;
|
|
181
|
+
};
|
|
173
182
|
}
|
|
174
183
|
const status = code ?? -1;
|
|
175
184
|
// G3: redact trước khi output vào conversation/log/TUI.
|
|
176
185
|
const redacted = redactSecrets(stdout);
|
|
177
186
|
const redactedErr = redactSecrets(stderr);
|
|
178
187
|
if (status === 0) {
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
finish({
|
|
183
|
-
ok: false,
|
|
184
|
-
output: clamp(`${echoLine}\n(exit ${status})\n--- stdout ---\n${redacted}\n--- stderr ---\n${redactedErr}`),
|
|
185
|
-
});
|
|
188
|
+
return { ok: true, output: clamp(`${echoLine}\n(exit 0)\n${redacted}`) };
|
|
186
189
|
}
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
output: clamp(`${echoLine}\n(exit ${status})\n--- stdout ---\n${redacted}\n--- stderr ---\n${redactedErr}`),
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
// 'close' chờ EOF của pipe; grandchild lì (khác process group — hiếm, hoặc
|
|
196
|
+
// spawn của chính command) có thể giữ nó mở vĩnh viễn. Đã kill mà child
|
|
197
|
+
// exit rồi thì resolve ngay trên 'exit', đừng đợi close.
|
|
198
|
+
child.on("exit", (code) => {
|
|
199
|
+
exited = true;
|
|
200
|
+
if (killedReason)
|
|
201
|
+
finish(buildResult(code));
|
|
202
|
+
});
|
|
203
|
+
child.on("close", (code) => {
|
|
204
|
+
finish(buildResult(code));
|
|
187
205
|
});
|
|
188
206
|
});
|
|
189
207
|
}
|
package/dist/tools/search.js
CHANGED
|
@@ -91,7 +91,14 @@ export function toolGrep(workdir, args) {
|
|
|
91
91
|
// status >= 2 → rg error; fall through to grep fallback.
|
|
92
92
|
}
|
|
93
93
|
// Fallback: grep -rn (portable) — output already path:line:text.
|
|
94
|
+
// CI runners (ubuntu-latest) không có rg → đường này là mainstream, phải
|
|
95
|
+
// hỗ trợ include + skip noise dirs như rg; grep BSD không hiểu các cờ này
|
|
96
|
+
// sẽ exit ≥2 → rơi tiếp xuống jsGrepFallback (xử lý đúng, không mất sạch).
|
|
94
97
|
const gArgs = ["-rn", "--binary-files=without-match", "-I", "-m", "5", "-e", pattern];
|
|
98
|
+
for (const d of SEARCH_SKIP)
|
|
99
|
+
gArgs.push(`--exclude-dir=${d}`);
|
|
100
|
+
if (args.include)
|
|
101
|
+
gArgs.push("--include", args.include);
|
|
95
102
|
if (ctxBefore > 0)
|
|
96
103
|
gArgs.push("-B", String(ctxBefore));
|
|
97
104
|
if (ctxAfter > 0)
|
package/dist/tui/app.js
CHANGED
|
@@ -73,6 +73,8 @@ export function TuiApp({ config, initialTask }) {
|
|
|
73
73
|
const [input, setInput] = useState(initialTask ?? "");
|
|
74
74
|
/** Rows scrolled up from the bottom; 0 = follow the newest content. */
|
|
75
75
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
76
|
+
/** Ctrl+O: native terminal text selection (mouse tracking released). */
|
|
77
|
+
const [selectMode, setSelectMode] = useState(false);
|
|
76
78
|
const [permMode, setPermMode] = useState(cfg.permissionMode);
|
|
77
79
|
// T-03: active permission prompt (modal) — the run blocks until answered.
|
|
78
80
|
const [permPrompt, setPermPrompt] = useState(null);
|
|
@@ -102,7 +104,7 @@ export function TuiApp({ config, initialTask }) {
|
|
|
102
104
|
// Scroll offset khi đọc lại dùng rows ước lượng; khi BÁM ĐÁY dùng chiều
|
|
103
105
|
// cao THẬT của cửa sổ đã mount (đo rẻ vì cửa sổ ~viewport+overscan) —
|
|
104
106
|
// 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);
|
|
107
|
+
const { viewportRef, viewport, contentRef, content, viewportRowsRef } = useScrollMetrics(setScrollOffset, !selectMode);
|
|
106
108
|
const viewportRows = viewport.clientHeight || 24;
|
|
107
109
|
const win = useMemo(() => {
|
|
108
110
|
const total = estimateTotalRows(state.items);
|
|
@@ -154,8 +156,19 @@ export function TuiApp({ config, initialTask }) {
|
|
|
154
156
|
useTuiKeybinds({
|
|
155
157
|
scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen,
|
|
156
158
|
setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef,
|
|
157
|
-
setScrollOffset, exit,
|
|
159
|
+
setScrollOffset, selectMode, setSelectMode, exit,
|
|
158
160
|
});
|
|
161
|
+
// WARMUP (plan/WARMUP-PLAN.md): tạo agent + prefill prefix system+tools nền
|
|
162
|
+
// NGAY khi mở TUI — TTFT của câu chat đầu không còn phải trả cold-load model
|
|
163
|
+
// và 20–60k token prefill. Bỏ qua khi có initialTask (submit trả prefill ngay
|
|
164
|
+
// sau mount, warm-up chỉ nhân đôi việc — settleWarmup sẽ abort nó).
|
|
165
|
+
const warmupRef = useRef(false);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (initialTask || warmupRef.current)
|
|
168
|
+
return;
|
|
169
|
+
warmupRef.current = true;
|
|
170
|
+
void createAgent().warmup();
|
|
171
|
+
}, [initialTask]);
|
|
159
172
|
// Submit an initial task passed on the command line once, after mount.
|
|
160
173
|
const bootRef = useRef(false);
|
|
161
174
|
useEffect(() => {
|
|
@@ -190,7 +203,7 @@ export function TuiApp({ config, initialTask }) {
|
|
|
190
203
|
_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 ? (
|
|
191
204
|
// Welcome: căn giữa cả 2 chiều — height = chiều cao đo được của
|
|
192
205
|
// viewport (flexGrow không giãn được vì content box flexShrink=0).
|
|
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 })] }));
|
|
206
|
+
_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" })] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "ctrl+o" }), _jsx(Text, { dimColor: true, children: " select/copy (t\u1EAFt mouse tracking \u0111\u1EC3 t\u00F4 \u0111en k\u00E9o chu\u1ED9t)" })] })] })) : (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, selectMode: selectMode })] }));
|
|
194
207
|
}
|
|
195
208
|
/** Mount the TUI (called from the `scoder tui` subcommand). */
|
|
196
209
|
export async function runTui(config, initialTask) {
|
package/dist/tui/chrome.js
CHANGED
|
@@ -67,9 +67,10 @@ function PhaseBreadcrumb({ phases }) {
|
|
|
67
67
|
/** Metrics cho narrow-terminal — không còn dùng (sidebar đã thay thế), giữ để tham khảo. */
|
|
68
68
|
/**
|
|
69
69
|
* Status bar — bottom row. Left: spinner · status · elapsed · model.
|
|
70
|
-
* Right: scroll position OR keyboard hints.
|
|
70
|
+
* Right: scroll position OR keyboard hints. In select mode (Ctrl+O) the hints
|
|
71
|
+
* swap to the copy reminder — the mouse belongs to the terminal there.
|
|
71
72
|
*/
|
|
72
|
-
export function StatusBar({ state, scroll }) {
|
|
73
|
+
export function StatusBar({ state, scroll, selectMode = false }) {
|
|
73
74
|
const { columns } = useWindowSize();
|
|
74
75
|
const width = Math.max(40, (columns ?? 100) - 6);
|
|
75
76
|
const statusColor = state.status === "running" ? theme.accent :
|
|
@@ -86,10 +87,12 @@ export function StatusBar({ state, scroll }) {
|
|
|
86
87
|
state.status === "aborted" ? "⚠" :
|
|
87
88
|
state.status === "idle" ? "○" : null;
|
|
88
89
|
const model = state.model || "—";
|
|
89
|
-
// Right side: scroll position OR
|
|
90
|
-
const rightText =
|
|
91
|
-
?
|
|
92
|
-
:
|
|
90
|
+
// Right side: select-mode reminder OR scroll position OR keybind hints
|
|
91
|
+
const rightText = selectMode
|
|
92
|
+
? "⊝ select · drag to highlight · ⌘C/ctrl+c copy · ctrl+o back"
|
|
93
|
+
: scroll
|
|
94
|
+
? `scroll ${scroll} · esc→tail`
|
|
95
|
+
: `⏎ send · ctrl+s sessions · ctrl+t ${state.thinkingVisible ? "◉" : "○"} · esc abort · ctrl+c quit`;
|
|
93
96
|
const leftBudget = Math.max(24, width - rightText.length - 3);
|
|
94
97
|
let left = statusLabel;
|
|
95
98
|
if (model && model !== "—") {
|
|
@@ -3,13 +3,18 @@ import { useInput } from "ink";
|
|
|
3
3
|
import { useEffect, useRef } from "react";
|
|
4
4
|
import { markAborted } from "../state.js";
|
|
5
5
|
export function useTuiKeybinds(deps) {
|
|
6
|
-
const { scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen, setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef, setScrollOffset, exit } = deps;
|
|
6
|
+
const { scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen, setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef, setScrollOffset, selectMode, setSelectMode, exit } = deps;
|
|
7
7
|
// Esc aborts the running task (like SIGINT in the CLI); when scrolled up,
|
|
8
8
|
// the first Esc returns to the bottom instead.
|
|
9
9
|
const scrollOffsetRef = useRef(0);
|
|
10
10
|
useEffect(() => {
|
|
11
11
|
scrollOffsetRef.current = scrollOffset;
|
|
12
12
|
}, [scrollOffset]);
|
|
13
|
+
// useInput closures go stale between renders (same reason as scrollOffsetRef).
|
|
14
|
+
const selectModeRef = useRef(false);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
selectModeRef.current = selectMode;
|
|
17
|
+
}, [selectMode]);
|
|
13
18
|
// T-03: permission modal keybinds — intercepts y/a/n/q while open.
|
|
14
19
|
useInput((input, key) => {
|
|
15
20
|
const p = permPromptRef.current;
|
|
@@ -47,6 +52,24 @@ export function useTuiKeybinds(deps) {
|
|
|
47
52
|
update((s) => ({ ...s, thinkingVisible: !s.thinkingVisible }));
|
|
48
53
|
return;
|
|
49
54
|
}
|
|
55
|
+
// Ctrl+O: toggle select mode — mouse tracking off (native drag-select +
|
|
56
|
+
// Cmd/Ctrl+C copy) / on (wheel scroll). Scroll with PgUp/PgDn while off.
|
|
57
|
+
if (key.ctrl && _input === "o") {
|
|
58
|
+
const next = !selectModeRef.current;
|
|
59
|
+
selectModeRef.current = next;
|
|
60
|
+
setSelectMode(next);
|
|
61
|
+
update((s) => ({
|
|
62
|
+
...s,
|
|
63
|
+
items: [...s.items, {
|
|
64
|
+
kind: "notice",
|
|
65
|
+
text: next
|
|
66
|
+
? "select mode ON — drag to highlight, copy with ⌘C/Ctrl+C · scroll via PgUp/PgDn · Ctrl+O back to mouse wheel"
|
|
67
|
+
: "select mode OFF — mouse wheel scrolling restored",
|
|
68
|
+
level: "info",
|
|
69
|
+
}],
|
|
70
|
+
}));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
50
73
|
if (key.pageUp || key.pageDown || key.home || key.end) {
|
|
51
74
|
const page = Math.max(1, Math.floor((viewportRowsRef.current ?? 20) * 0.8));
|
|
52
75
|
if (key.pageUp)
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/** Scroll viewport metrics + mouse wheel (SGR 1006) wiring for the TUI.
|
|
2
2
|
* P1-8: contentRef giờ đo CỬA SỔ item đã mount (không phải cả transcript) —
|
|
3
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.
|
|
4
|
+
* xác, ước lượng (transcriptWindow) chỉ dùng khi scroll lên trên.
|
|
5
|
+
* mouseEnabled=false (select mode, Ctrl+O): nhả mouse tracking để terminal
|
|
6
|
+
* cho tô đen kéo chuột + copy thường — wheel scroll tắt, dùng PgUp/PgDn. */
|
|
5
7
|
import { useCallback, useEffect, useRef } from "react";
|
|
6
8
|
import { useBoxMetrics, useStdin } from "ink";
|
|
7
9
|
import { parseWheel, MOUSE_ENABLE, MOUSE_DISABLE } from "../mouse.js";
|
|
8
|
-
export function useScrollMetrics(setScrollOffset) {
|
|
10
|
+
export function useScrollMetrics(setScrollOffset, mouseEnabled = true) {
|
|
9
11
|
const viewportRef = useRef(null);
|
|
10
12
|
const contentRef = useRef(null);
|
|
11
13
|
const viewport = useBoxMetrics(viewportRef);
|
|
@@ -17,6 +19,8 @@ export function useScrollMetrics(setScrollOffset) {
|
|
|
17
19
|
// T5: mouse wheel scroll (SGR 1006) — raw stdin listener; Ink's useInput
|
|
18
20
|
// drops unrepresentable CSI sequences, so wheel events must be read here.
|
|
19
21
|
// Only in alt-screen TTY (mouse tracking sequences corrupt plain pipes).
|
|
22
|
+
// Select mode (mouseEnabled=false): MOUSE_DISABLE → native terminal drag
|
|
23
|
+
// selection/copy returns; setRawMode calls stay balanced across toggles.
|
|
20
24
|
const { stdin, setRawMode, isRawModeSupported } = useStdin();
|
|
21
25
|
const wheelScroll = useCallback((dir) => {
|
|
22
26
|
setScrollOffset((o) => Math.max(0, o + (dir === "up" ? 3 : -3)));
|
|
@@ -24,6 +28,10 @@ export function useScrollMetrics(setScrollOffset) {
|
|
|
24
28
|
useEffect(() => {
|
|
25
29
|
if (process.stdout.isTTY !== true || !isRawModeSupported)
|
|
26
30
|
return;
|
|
31
|
+
if (!mouseEnabled) {
|
|
32
|
+
process.stdout.write(MOUSE_DISABLE);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
27
35
|
process.stdout.write(MOUSE_ENABLE);
|
|
28
36
|
setRawMode(true);
|
|
29
37
|
const onData = (chunk) => {
|
|
@@ -35,7 +43,8 @@ export function useScrollMetrics(setScrollOffset) {
|
|
|
35
43
|
return () => {
|
|
36
44
|
stdin.off("data", onData);
|
|
37
45
|
process.stdout.write(MOUSE_DISABLE);
|
|
46
|
+
setRawMode(false);
|
|
38
47
|
};
|
|
39
|
-
}, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
|
|
48
|
+
}, [stdin, setRawMode, isRawModeSupported, wheelScroll, mouseEnabled]);
|
|
40
49
|
return { viewportRef, viewport, contentRef, content, viewportRowsRef };
|
|
41
50
|
}
|
|
@@ -8,7 +8,7 @@ import { readdirSync } from "node:fs";
|
|
|
8
8
|
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
|
+
const cache = new Map();
|
|
12
12
|
/**
|
|
13
13
|
* File index for `workdir` (cached, TTL 5s; invalidateFileIndex on tool writes).
|
|
14
14
|
* `force` (P1-2) bỏ qua cache — dùng cho fingerprint, nơi một file mới được tạo
|
|
@@ -16,8 +16,9 @@ let cache = null;
|
|
|
16
16
|
* sẽ được tái sử dụng sai.
|
|
17
17
|
*/
|
|
18
18
|
export function fileIndex(workdir, force = false) {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const hit = cache.get(workdir);
|
|
20
|
+
if (!force && hit && Date.now() - hit.built < 5_000)
|
|
21
|
+
return hit.files;
|
|
21
22
|
const files = [];
|
|
22
23
|
// Fast path: ripgrep --files (gitignore-aware). Bare-name globs match at any
|
|
23
24
|
// depth; `!**/x/**` does NOT match absolute paths (I-07 lesson).
|
|
@@ -36,12 +37,12 @@ export function fileIndex(workdir, force = false) {
|
|
|
36
37
|
if (files.length === 0)
|
|
37
38
|
walkFiles(workdir, "", files, 0);
|
|
38
39
|
files.sort();
|
|
39
|
-
cache
|
|
40
|
+
cache.set(workdir, { files, built: Date.now() });
|
|
40
41
|
return files;
|
|
41
42
|
}
|
|
42
43
|
/** Cache invalidate — call after write/edit tool_result (scope: whole index). */
|
|
43
44
|
export function invalidateFileIndex() {
|
|
44
|
-
cache
|
|
45
|
+
cache.clear();
|
|
45
46
|
}
|
|
46
47
|
function walkFiles(root, prefix, out, depth) {
|
|
47
48
|
if (depth > 6)
|
package/package.json
CHANGED