s-app-scoder-agent 0.9.7 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -2,19 +2,21 @@ import { loadPermissionLists, loadPolicyRules, loadSavedApprovals } from "./conf
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";
5
- import { setMaxOutputChars } from "./tools/path-safety.js";
5
+ import { setMaxOutputChars, setMaxToolOutputChars } from "./tools/path-safety.js";
6
6
  import { detectBuildCommand } from "./workspace/build.js";
7
7
  import { expandMentions } from "./context/context.js";
8
8
  import { createGuardrailEngine } from "./agent/guardrails.js";
9
9
  import { PROMPT_VERSION } from "./context/prompt.js";
10
10
  import { PermissionChecker } from "./security/permissions.js";
11
11
  import { SessionStore, EventSessionStore } from "./session/session.js";
12
- import { DEFAULT_COMPACTION, estimateTokens, findCutIndex, extractSummaryInput, COMPACTION_INSTRUCTION, applyCompaction } from "./context/compaction.js";
12
+ import { DEFAULT_COMPACTION, estimateTokens, estimateSchemaTokens, findCutIndex, extractSummaryInput, COMPACTION_INSTRUCTION, applyCompaction } from "./context/compaction.js";
13
13
  import { SnapshotManager } from "./workspace/snapshot.js";
14
14
  import { contextWindowFor } from "./llm/model-catalog.js";
15
+ import { probeContextWindow } from "./llm/context-probe.js";
16
+ import { promptMetrics } from "./llm/prompt-metrics.js";
15
17
  import { JsonlLogger } from "./session/logger.js";
16
18
  import { createRuntime, parseCustomProviders, resolveProvider, splitProviderModel } from "./llm/provider-registry.js";
17
- import { loadRcFile } from "./config/config.js";
19
+ import { loadRcFile, isLocalBaseUrl } from "./config/config.js";
18
20
  import { watchWorkdir } from "./workspace/watch.js";
19
21
  import { discoverAgents, findAgent } from "./context/agents.js";
20
22
  import { emptyWorkingMemory, noteFileRead, noteFileChanged, noteFailure, notePlan, } from "./context/memory.js";
@@ -22,7 +24,7 @@ import { SteeringInbox } from "./session/steering.js";
22
24
  import { randomUUID } from "node:crypto";
23
25
  import { consoleReporter } from "./io/console-reporter.js";
24
26
  import { resolveVerify as resolveVerificationCommands, verifyAll as runVerification, failureSignature as getFailureSignature, collectRunDiff as collectVerificationDiff, selfReviewGate as runSelfReviewGate, } from "./agent/verification.js";
25
- import { callMcpTool as callMcpToolBridge, confirmMcpTool as confirmMcpToolBridge, connectMcp as connectMcpBridge, invokeMcpTool as invokeMcpToolBridge, mcpStatus as mcpStatusBridge, rebuildToolList as rebuildMcpToolList, shutdownMcp as shutdownMcpBridge, } from "./agent/mcp-bridge.js";
27
+ import { callMcpTool as callMcpToolBridge, confirmMcpTool as confirmMcpToolBridge, connectMcp as connectMcpBridge, governedMcpSpecs, invokeMcpTool as invokeMcpToolBridge, mcpStatus as mcpStatusBridge, rebuildToolList as rebuildMcpToolList, shutdownMcp as shutdownMcpBridge, } from "./agent/mcp-bridge.js";
26
28
  import { InteractionBroker } from "./agent/interaction.js";
27
29
  import { ToolExecutor } from "./agent/tool-executor.js";
28
30
  import { PromptContext, buildRunMessages, memoryBlock } from "./agent/prompt-context.js";
@@ -55,6 +57,8 @@ export class Agent {
55
57
  sessionCreatedAt;
56
58
  currentTask = "";
57
59
  lastBuildResult = null;
60
+ /** P1-2 (RC-5): kết quả verify thành công gần nhất theo (lệnh, fingerprint). */
61
+ verifyCacheStore = new Map();
58
62
  /** Accumulated token usage across the current run (session state). */
59
63
  usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
60
64
  mcp = null;
@@ -174,6 +178,13 @@ export class Agent {
174
178
  onToolOutput: (stream, delta) => this.handleToolOutput("build", stream, delta),
175
179
  endToolStream: () => this.endToolStream(),
176
180
  setLastBuildResult: (result) => { this.lastBuildResult = result; },
181
+ // P1-2: verify cache sống trong run (key = chuỗi lệnh verify).
182
+ verifyCache: {
183
+ read: (key) => this.verifyCacheStore.get(key),
184
+ write: (key, entry) => {
185
+ this.verifyCacheStore.set(key, entry);
186
+ },
187
+ },
177
188
  };
178
189
  return runVerification(runtime, buildCmd);
179
190
  }
@@ -269,6 +280,10 @@ export class Agent {
269
280
  // G7: thresholds từ rc limits.
270
281
  if (config.limits?.maxOutputChars)
271
282
  setMaxOutputChars(config.limits.maxOutputChars);
283
+ // P0-2: ngân sách ký tự của một tool result trong history (read_file cắt
284
+ // theo dòng theo giá trị này).
285
+ if (config.limits?.toolOutputChars)
286
+ setMaxToolOutputChars(config.limits.toolOutputChars);
272
287
  this.session = new SessionStore(config.workdir, config.noSave !== true);
273
288
  this.events = new EventSessionStore(config.workdir, config.noSave !== true, this.session.id);
274
289
  this.snapshots = new SnapshotManager(config.workdir, config.snapshots !== false);
@@ -563,7 +578,9 @@ export class Agent {
563
578
  emitContextEvent(compacted = false) {
564
579
  try {
565
580
  const window = this.compactionSettings().contextWindow;
566
- const tokens = estimateTokens(this.messages);
581
+ // P1-1: token áp lực context phải tính cả tool schema (chi phí cố định
582
+ // mỗi lượt) — UI/`/compact` mới hiện đúng mức "còn thở" thật.
583
+ const tokens = estimateTokens(this.messages, this.fixedPromptTokens());
567
584
  const pct = window > 0 ? Math.min(100, Math.round((tokens / window) * 100)) : 0;
568
585
  this.emit({ type: "context", tokens, window, pct, ...(compacted ? { compacted: true } : {}) });
569
586
  }
@@ -577,6 +594,19 @@ export class Agent {
577
594
  const input = usage.prompt_tokens ?? 0;
578
595
  const output = usage.completion_tokens ?? 0;
579
596
  const cached = usage.cached_tokens ?? 0;
597
+ // P1-3: metrics của lượt vừa rồi (consumed one-shot — lượt summarizer
598
+ // của compaction đi đường khác nên không có perf).
599
+ const perf = this.lastPerf;
600
+ this.lastPerf = null;
601
+ // P1-1 calibration: engine vừa báo đúng số token nó thấy. Lấy số đó trừ
602
+ // đi phần messages mình ước lượng = chi phí cố định THẬT (tool schema +
603
+ // chat template + sai số bộ đếm). bộ đếm cl100k đo phần schema rất chuẩn
604
+ // (lệch ~1% trên Qwen3.6-35B) nhưng không biết token đóng gói của template.
605
+ if (perf && input > 0 && perf.messageTokens > 0) {
606
+ const fixed = input - perf.messageTokens;
607
+ if (fixed > 0)
608
+ this.measuredFixedTokens = fixed;
609
+ }
580
610
  this.usageTotals.calls += 1;
581
611
  this.usageTotals.inputTokens += input;
582
612
  this.usageTotals.outputTokens += output;
@@ -595,8 +625,38 @@ export class Agent {
595
625
  output_tokens: output,
596
626
  cached_tokens: cached,
597
627
  },
628
+ ...(perf
629
+ ? {
630
+ perf: {
631
+ ttftMs: perf.ttftMs,
632
+ callMs: perf.callMs,
633
+ /** Chi phí cố định/lượt mà agent đang dùng để tính ngân sách. */
634
+ fixedTokens: this.fixedPromptTokens(),
635
+ fixedSource: this.fixedPromptSource(),
636
+ /** token/s vùng prefill — TTFT ≈ prefill trên máy local. */
637
+ prefillTokPerSec: perf.ttftMs > 0 && input > 0 ? Math.round((input / perf.ttftMs) * 1000) : undefined,
638
+ ...(perf.prompt
639
+ ? {
640
+ promptChars: perf.prompt.promptChars,
641
+ /** phần chat (system+repo map+history+task) vs phần tool defs */
642
+ messageChars: perf.prompt.messageChars,
643
+ schemaChars: perf.prompt.schemaChars,
644
+ promptMessages: perf.prompt.messages,
645
+ promptTools: perf.prompt.tools,
646
+ promptHash: perf.prompt.promptHash,
647
+ historyHash: perf.prompt.historyHash,
648
+ /** RC-1: history lượt trước có còn là tiền tố của lượt này? */
649
+ prefixReused: perf.prompt.prefixReused,
650
+ commonPrefixMessages: perf.prompt.commonPrefixMessages,
651
+ }
652
+ : {}),
653
+ },
654
+ }
655
+ : {}),
598
656
  });
599
657
  }
658
+ /** prefixHash của lượt trước — phát hiện invalidation KV-cache (P1-3). */
659
+ lastPromptRef = null;
600
660
  /** Reset per-run usage accumulation (called when a fresh run starts). */
601
661
  resetUsageTotals() {
602
662
  this.usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
@@ -610,7 +670,72 @@ export class Agent {
610
670
  * result (flagged `incomplete`) instead of discarding it — the run loop
611
671
  * executes the tool calls and continues with a synthetic instruction.
612
672
  */
673
+ /**
674
+ * P1-3: một lượt gọi LLM được đo: thời gian tới token đầu (TTFT ≈ prefill
675
+ * trên máy local), tổng thời gian, và hash prompt/prefix. `prefixHash` đổi
676
+ * giữa hai lượt = có gì đó sửa history ⇒ mất KV-cache (RC-1).
677
+ * Consumed one-shot bởi recordLlmUsage (lượt summarizer không có perf).
678
+ */
679
+ pendingPerf = null;
680
+ lastPerf = null;
681
+ /**
682
+ * P1-1 (calibration): chi phí cố định của một lượt gọi ĐO TỪ CHÍNH ENGINE.
683
+ * = prompt_tokens thật − số token mình ước lượng cho messages. Phần chênh
684
+ * gồm tool schema + token đóng gói của chat template (ChatML) + sai số bộ
685
+ * đếm. Đo trên Qwen3.6-35B: schema ước lượng đúng ~1%, nhưng prompt thật
686
+ * lớn hơn ước lượng tổng ~19% vì chat template. null = chưa có phép đo.
687
+ */
688
+ measuredFixedTokens = null;
689
+ /**
690
+ * Chi phí cố định mỗi lượt (không nằm trong history) để trừ khỏi window khi
691
+ * tính ngân sách trim/compaction. Lấy max giữa ước lượng tool schema và
692
+ * phép đo thật từ engine — phép đo chỉ có thể SIẾT ngân sách, không bao giờ
693
+ * mở rộng hơn mức ước lượng (an toàn với RC-4).
694
+ */
695
+ fixedPromptTokens() {
696
+ return Math.max(estimateSchemaTokens(this.tools), this.measuredFixedTokens ?? 0);
697
+ }
698
+ /** Nguồn của con số trên — ghi vào log để giải thích ngân sách. */
699
+ fixedPromptSource() {
700
+ return this.measuredFixedTokens !== null && this.measuredFixedTokens >= estimateSchemaTokens(this.tools)
701
+ ? "measured"
702
+ : "estimate";
703
+ }
704
+ /** One LLM turn (wrapper) — see chatWithStreamInner for the streaming logic. */
613
705
  async chatWithStream() {
706
+ const t0 = Date.now();
707
+ let prompt = null;
708
+ // Số token mình ước lượng cho phần messages — dùng để suy ra chi phí cố
709
+ // định thật của engine khi usage quay về (P1-1 calibration).
710
+ let messageTokens = 0;
711
+ try {
712
+ messageTokens = estimateTokens(this.messages);
713
+ prompt = promptMetrics(this.messages, this.tools);
714
+ }
715
+ catch {
716
+ // metrics không được phép làm hỏng lượt gọi
717
+ }
718
+ this.pendingPerf = { t0, ttftMs: 0, messageTokens, prompt };
719
+ // Keep the per-message hashes for the NEXT call: `prefixReused` in the log
720
+ // is the direct answer to "KV-cache còn dùng được không?" (RC-1).
721
+ this.lastPromptRef = prompt ? { messageHashes: prompt.messageHashes } : null;
722
+ try {
723
+ return await this.chatWithStreamInner();
724
+ }
725
+ finally {
726
+ const p = this.pendingPerf;
727
+ this.pendingPerf = null;
728
+ if (p)
729
+ this.lastPerf = { ttftMs: p.ttftMs, callMs: Date.now() - t0, messageTokens: p.messageTokens, prompt: p.prompt };
730
+ }
731
+ }
732
+ /** Đánh dấu token đầu tiên tới (best-effort, cho TTFT). */
733
+ markFirstToken() {
734
+ const p = this.pendingPerf;
735
+ if (p && p.ttftMs === 0)
736
+ p.ttftMs = Date.now() - p.t0;
737
+ }
738
+ async chatWithStreamInner() {
614
739
  const signal = this.runSignal.signal;
615
740
  if (!this.config.stream) {
616
741
  return this.client.chat(this.messages, this.tools, { signal });
@@ -621,6 +746,10 @@ export class Agent {
621
746
  let sawToolCallDelta = false;
622
747
  try {
623
748
  for await (const chunk of this.client.streamChat(this.messages, this.tools, { signal })) {
749
+ // P1-3: TTFT tính từ lúc request bắt đầu tới delta đầu tiên (bất kể
750
+ // loại — content/reasoning/tool_call đều nghĩa là prefill đã xong).
751
+ if (chunk.contentDelta || chunk.reasoningDelta || chunk.toolCallDelta)
752
+ this.markFirstToken();
624
753
  if (chunk.reasoningDelta) {
625
754
  // I-05: reasoning hiển thị dim qua UI stream; không vào llm_delta.
626
755
  this.emit({ type: "llm_reasoning", delta: chunk.reasoningDelta });
@@ -699,7 +828,11 @@ export class Agent {
699
828
  pendingAllowedTools: this.promptContext.pendingTools(),
700
829
  getMcp: () => this.mcp,
701
830
  setMcp: (manager) => { this.mcp = manager; },
702
- setTools: (tools) => { this.tools = tools; },
831
+ setTools: (tools) => {
832
+ this.tools = tools;
833
+ // Tool list đổi ⇒ chi phí cố định đo trước không còn đúng nữa.
834
+ this.measuredFixedTokens = null;
835
+ },
703
836
  emit: (event) => this.emit(event),
704
837
  confirmMcpTool: (name) => this.confirmMcpTool(name),
705
838
  };
@@ -708,6 +841,41 @@ export class Agent {
708
841
  async connectMcp() {
709
842
  await connectMcpBridge(this.mcpBridgeRuntime());
710
843
  }
844
+ /**
845
+ * P1-1 (RC-4): hỏi server lấy context window THẬT khi user chưa đặt tường
846
+ * minh (rc `contextWindow` / provider def). Fallback 128k của catalog khiến
847
+ * compaction và trim kích hoạt muộn hơn server thật hàng chục nghìn token —
848
+ * server tự cắt history, và mỗi lần nó cắt là một lần mất KV-cache.
849
+ */
850
+ async applyContextProbe() {
851
+ if (this.config.contextWindow)
852
+ return;
853
+ let probe = null;
854
+ try {
855
+ // Server local vẫn có thể bật --api-key: /props trả 401 nếu không gửi
856
+ // Authorization, và probe im lặng => agent tưởng còn 128k (RC-4 cũ).
857
+ probe = await probeContextWindow(this.config.baseUrl, this.client.model, 1_500, {
858
+ apiKey: this.config.apiKey,
859
+ });
860
+ }
861
+ catch {
862
+ // probe là best-effort — không được làm hỏng run
863
+ }
864
+ if (!probe) {
865
+ if (isLocalBaseUrl(this.config.baseUrl)) {
866
+ const catalog = contextWindowFor(this.config.provider, this.config.model);
867
+ this.reporter.warn(`không probe được context window của server cục bộ — đang dùng fallback ${catalog} token. ` +
868
+ `Nếu nhỏ hơn (vd. llama.cpp -c 16384 / Ollama num_ctx), đặt "contextWindow" trong rc để trim/compaction tính đúng.`);
869
+ }
870
+ return;
871
+ }
872
+ this.config.contextWindow = probe.tokens;
873
+ this.logger.log({ type: "context_window", tokens: probe.tokens, source: probe.source, detail: probe.detail, model: this.client.model });
874
+ const catalog = contextWindowFor(this.config.provider, this.config.model);
875
+ if (probe.tokens !== catalog) {
876
+ this.reporter.info(`context window: ${probe.tokens} token (${probe.detail}) — thay fallback ${catalog} token, compaction/trim tính theo số này`);
877
+ }
878
+ }
711
879
  /** Rebuild built-in and connected MCP tool specs for the current skill scope. */
712
880
  rebuildToolList() {
713
881
  rebuildMcpToolList(this.mcpBridgeRuntime());
@@ -727,6 +895,37 @@ export class Agent {
727
895
  this.watcher = null;
728
896
  this.shutdownMcp();
729
897
  }
898
+ /**
899
+ * Dấu mốc "một lượt run bắt đầu": stamp `run.started` vào event log VÀ emit
900
+ * `run_start` cho UI.
901
+ *
902
+ * Trước đây chỉ `run()` làm việc này; `runTurn()` (câu hỏi thứ 2+ trong
903
+ * TUI/REPL) và `resume()` bỏ qua, mà reducer TUI lại reset mọi anchor
904
+ * per-run trong nhánh `run_start` (`startedAt`, `endedAt = null`, `usage`,
905
+ * `context`, `phases`, `lastLlmTokPerSec`...). Hệ quả đo được: từ câu hỏi
906
+ * thứ hai, `endedAt` vẫn đóng của lượt trước ⇒ thanh elapsed đứng im, áp lực
907
+ * ctx và tok/s không nhảy nữa — dù text vẫn stream bình thường.
908
+ *
909
+ * `taskForUi` là NGUYÊN VĂN user gõ (chưa expand mentions) để dedupe trong
910
+ * reducer khớp đúng item user mà TUI/REPL đã tự append. `loggedTask` (đã
911
+ * expand) ghi vào event log để resume/phân tích giữ đúng nội dung model thấy.
912
+ * `quiet` = UI đã tự hiển thị task (resume) ⇒ reducer không append thêm.
913
+ */
914
+ emitRunBoundary(taskForUi, buildCmd, loggedTask, quiet = false) {
915
+ this.events.append({ type: "run.started", task: loggedTask ?? taskForUi });
916
+ this.emit({
917
+ type: "run_start",
918
+ task: taskForUi,
919
+ model: this.client.model,
920
+ provider: this.config.provider,
921
+ workdir: this.config.workdir,
922
+ buildCmd,
923
+ permission: this.config.permissionMode,
924
+ schemaVersion: EVENT_SCHEMA_VERSION,
925
+ agent: this.config.agent ?? "build",
926
+ ...(quiet ? { quiet: true } : {}),
927
+ });
928
+ }
730
929
  /**
731
930
  * Autonomous closed loop:
732
931
  * task -> LLM plans -> tool calls -> build -> on failure: read error, patch, retry.
@@ -736,6 +935,7 @@ export class Agent {
736
935
  throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
737
936
  }
738
937
  const buildCmd = this.buildCmd;
938
+ const rawTask = task;
739
939
  // @file mention expansion — task trước khi build messages.
740
940
  task = expandMentions(task, this.config.workdir);
741
941
  this.reporter.banner("scoder", [
@@ -746,7 +946,6 @@ export class Agent {
746
946
  ["steps", `max ${this.config.maxSteps} · permission ${this.config.permissionMode}`],
747
947
  ]);
748
948
  this.currentTask = task;
749
- this.events.append({ type: "run.started", task });
750
949
  this.resetRunSignal();
751
950
  this.resetUsageTotals();
752
951
  this.filesModified = false;
@@ -764,7 +963,10 @@ export class Agent {
764
963
  const allow = new Set(pendingAllowedTools.map((t) => t.toLowerCase()));
765
964
  baseTools = baseTools.filter((s) => allow.has(s.function.name.toLowerCase()));
766
965
  }
767
- const mcpSpecs = this.mcp ? [...this.mcp.toToolSpecs(), ...this.mcp.resourceToolSpecs()] : [];
966
+ // P0-3: MCP specs đi qua đúng một cửa (allowlist "mcpTools" + trần
967
+ // schema). Trước đây chỗ này tự ghép raw specs → run nối tiếp trên cùng
968
+ // Agent (MCP đã connect, connectMcp return sớm) gửi lại đủ 53 tool.
969
+ const mcpSpecs = governedMcpSpecs(this.mcpBridgeRuntime());
768
970
  if (pendingAllowedTools?.length) {
769
971
  const allow = new Set(pendingAllowedTools.map((t) => t.toLowerCase()));
770
972
  this.tools = [...baseTools, ...mcpSpecs.filter((s) => allow.has(s.function.name.toLowerCase()))];
@@ -783,17 +985,8 @@ export class Agent {
783
985
  // Phiên bản system prompt được ghi vào log — giúp so sánh chất lượng
784
986
  // giữa các phiên bản prompt khi phân tích JSONL.
785
987
  this.logger.log({ type: "prompt_version", version: PROMPT_VERSION });
786
- this.emit({
787
- type: "run_start",
788
- task,
789
- model: this.client.model,
790
- provider: this.config.provider,
791
- workdir: this.config.workdir,
792
- buildCmd,
793
- permission: this.config.permissionMode,
794
- schemaVersion: EVENT_SCHEMA_VERSION,
795
- agent: this.config.agent ?? "build",
796
- });
988
+ this.emitRunBoundary(rawTask, buildCmd, task);
989
+ await this.applyContextProbe();
797
990
  await this.connectMcp();
798
991
  this.promptContext.clearPendingTools();
799
992
  return this.runLoop(0, buildCmd, null);
@@ -833,6 +1026,7 @@ export class Agent {
833
1026
  return this.run(task);
834
1027
  }
835
1028
  const buildCmd = this.buildCmd;
1029
+ const rawTask = task;
836
1030
  task = expandMentions(task, this.config.workdir);
837
1031
  this.currentTask = `${this.currentTask}\n${task}`.trim();
838
1032
  this.resetRunSignal();
@@ -842,7 +1036,10 @@ export class Agent {
842
1036
  for (const content of this.promptContext.takePendingContext())
843
1037
  this.messages.push({ role: "user", content });
844
1038
  this.messages.push({ role: "user", content: task });
1039
+ // Cùng một dấu mốc run_start mà run() phát — UI reset anchor per-run ở đó.
1040
+ this.emitRunBoundary(rawTask, buildCmd, task);
845
1041
  this.rebuildToolList();
1042
+ await this.applyContextProbe();
846
1043
  await this.connectMcp();
847
1044
  this.promptContext.clearPendingTools();
848
1045
  return this.runLoop(0, buildCmd, null);
@@ -888,8 +1085,11 @@ export class Agent {
888
1085
  });
889
1086
  }
890
1087
  const lastBuild = restored.lastBuild;
891
- // v2: opening a resumed run stamps a fresh run.started (event log continuity).
892
- this.events.append({ type: "run.started", task: this.currentTask });
1088
+ // v2: opening a resumed run stamps a fresh run.started + run_start (event log
1089
+ // continuity VÀ UI anchor reset). quiet: transcript đã được restore, đừng
1090
+ // append lại task thành một dòng user mới.
1091
+ this.emitRunBoundary(this.currentTask, buildCmd, undefined, true);
1092
+ await this.applyContextProbe();
893
1093
  await this.connectMcp();
894
1094
  return this.runLoop(session.steps ?? 0, buildCmd, lastBuild);
895
1095
  }
@@ -947,6 +1147,9 @@ export class Agent {
947
1147
  steerCounts: () => this.steerInbox.counts(),
948
1148
  chatWithStream: () => this.chatWithStream(),
949
1149
  recordLlmUsage: (usage) => this.recordLlmUsage(usage),
1150
+ tools: () => this.tools,
1151
+ fixedPromptTokens: () => this.fixedPromptTokens(),
1152
+ fixedPromptSource: () => this.fixedPromptSource(),
950
1153
  emit: (event) => this.emit(event),
951
1154
  emitContextEvent: (force) => this.emitContextEvent(force),
952
1155
  toolExecutor: () => this.toolExecutor(),
@@ -10,24 +10,109 @@ const ANTHROPIC_DEFAULT_MODEL = "claude-sonnet-4-5";
10
10
  * G7: parse + validate rc "limits" — số nguyên dương; giá trị sai bỏ qua
11
11
  * (dùng default). Defaults là nguồn sự thật duy nhất (resolveLimits).
12
12
  */
13
- export const LIMIT_DEFAULTS = { requestTimeoutMs: 120_000, maxOutputChars: 20_000, toolConcurrency: 4, renderCap: 200 };
13
+ export const LIMIT_DEFAULTS = {
14
+ requestTimeoutMs: 120_000,
15
+ maxOutputChars: 20_000,
16
+ toolConcurrency: 4,
17
+ renderCap: 200,
18
+ toolOutputChars: 8_000,
19
+ mcpSchemaTokens: 0,
20
+ buildTimeoutSecs: 600,
21
+ };
14
22
  function parseLimits(raw) {
15
23
  if (!raw || typeof raw !== "object")
16
24
  return undefined;
17
25
  const r = raw;
18
26
  const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined;
19
27
  const out = {};
20
- for (const k of ["requestTimeoutMs", "maxOutputChars", "toolConcurrency", "renderCap"]) {
28
+ for (const k of [
29
+ "requestTimeoutMs",
30
+ "maxOutputChars",
31
+ "toolConcurrency",
32
+ "renderCap",
33
+ "toolOutputChars",
34
+ "buildTimeoutSecs",
35
+ ]) {
21
36
  const v = num(r[k]);
22
37
  if (v !== undefined)
23
38
  out[k] = v;
24
39
  }
40
+ // mcpSchemaTokens: 0 có nghĩa "tắt" — nhận cả 0, chỉ bỏ số âm/NaN.
41
+ const mcpCap = r.mcpSchemaTokens;
42
+ if (typeof mcpCap === "number" && Number.isFinite(mcpCap) && mcpCap >= 0)
43
+ out.mcpSchemaTokens = Math.floor(mcpCap);
25
44
  return Object.keys(out).length > 0 ? out : undefined;
26
45
  }
27
- /** G7: limits đã resolve (defaults + rc override) — các module đọc từ đây. */
28
46
  export function resolveLimits(limits) {
29
47
  return { ...LIMIT_DEFAULTS, ...limits };
30
48
  }
49
+ // ── P2 (plan/performance-issue.md): profile cho model chạy cục bộ ───────────
50
+ /**
51
+ * Model chạy trên máy của user (Ollama / LM Studio / vLLM / llama.cpp server)?
52
+ * Nhận diện theo host của baseUrl — loopback là tín hiệu đáng tin duy nhất mà
53
+ * không cần gọi network.
54
+ */
55
+ export function isLocalBaseUrl(baseUrl) {
56
+ try {
57
+ const u = new URL(baseUrl);
58
+ const h = u.hostname.replace(/^\[|\]$/g, "");
59
+ return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0" || h.endsWith(".local");
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ /** Mặc định của profile local — chỉ áp dụng khi user KHÔNG set tường minh. */
66
+ export const LOCAL_PROFILE_DEFAULTS = {
67
+ /** P2-1: local model chậm, 40 bước là nhiều giờ; bao việc hơn trong 24 bước. */
68
+ maxSteps: 24,
69
+ /** P2-3: prefill 20–60k token trên máy cá nhân có thể vượt 120s → timeout + retry. */
70
+ requestTimeoutMs: 600_000,
71
+ /** P0-2: history của MỘT tool result nhỏ hơn mặc định cloud (8k). */
72
+ toolOutputChars: 6_000,
73
+ /** P0-3: trần token cho MCP schema; 46 tool GitHub MCP ≈ 12k token mỗi lượt. */
74
+ mcpSchemaTokens: 6_000,
75
+ };
76
+ /**
77
+ * Áp mặc định phù hợp cho local engine. Trả về danh sách thay đổi đã áp (để
78
+ * log/kiểm thử); rỗng khi không áp dụng (cloud, hoặc localProfile:false).
79
+ *
80
+ * Lý do KHÔNG tắt repoMap/snapshots trong profile:
81
+ * - repoMap: nằm trong system prompt → prefix ổn định, không phá KV-cache, và
82
+ * giảm số bước khám phá (mỗi bước mới = một lượt prefill).
83
+ * - snapshots: git ops cục bộ, không tốn token.
84
+ * - selfReview: thêm MỘT lượt LLM với prompt gần đầy → tắt thật.
85
+ */
86
+ export function applyLocalProfile(cfg, explicit = {}) {
87
+ if (cfg.localProfile === false)
88
+ return [];
89
+ if (!isLocalBaseUrl(cfg.baseUrl))
90
+ return [];
91
+ const applied = [];
92
+ const limits = { ...cfg.limits };
93
+ if (!explicit.maxSteps && cfg.maxSteps > LOCAL_PROFILE_DEFAULTS.maxSteps) {
94
+ cfg.maxSteps = LOCAL_PROFILE_DEFAULTS.maxSteps;
95
+ applied.push(`maxSteps=${cfg.maxSteps}`);
96
+ }
97
+ if (limits.requestTimeoutMs === undefined) {
98
+ limits.requestTimeoutMs = LOCAL_PROFILE_DEFAULTS.requestTimeoutMs;
99
+ applied.push(`requestTimeoutMs=${LOCAL_PROFILE_DEFAULTS.requestTimeoutMs}`);
100
+ }
101
+ if (limits.toolOutputChars === undefined) {
102
+ limits.toolOutputChars = LOCAL_PROFILE_DEFAULTS.toolOutputChars;
103
+ applied.push(`toolOutputChars=${LOCAL_PROFILE_DEFAULTS.toolOutputChars}`);
104
+ }
105
+ if (limits.mcpSchemaTokens === undefined) {
106
+ limits.mcpSchemaTokens = LOCAL_PROFILE_DEFAULTS.mcpSchemaTokens;
107
+ applied.push(`mcpSchemaTokens=${LOCAL_PROFILE_DEFAULTS.mcpSchemaTokens}`);
108
+ }
109
+ cfg.limits = limits;
110
+ if (cfg.selfReview === undefined) {
111
+ cfg.selfReview = false;
112
+ applied.push("selfReview=false");
113
+ }
114
+ return applied;
115
+ }
31
116
  function readRcFile(path, label) {
32
117
  if (!existsSync(path))
33
118
  return {};
@@ -170,6 +255,9 @@ export function loadConfig(overrides = {}) {
170
255
  // (steps < NaN is always false → the loop never runs).
171
256
  const rawMaxSteps = Number(process.env.SCODER_MAX_STEPS ?? rc.maxSteps ?? 40);
172
257
  const validatedMaxSteps = Number.isFinite(rawMaxSteps) && rawMaxSteps >= 1 ? Math.floor(rawMaxSteps) : 40;
258
+ // P2-1: profile local CHỈ hạ maxSteps khi user không đặt tường minh ở
259
+ // flag/env/rc — tôn trọng cấu hình rõ ràng của user.
260
+ const maxStepsExplicit = overrides.maxSteps !== undefined || process.env.SCODER_MAX_STEPS !== undefined || rc.maxSteps !== undefined;
173
261
  const cfg = {
174
262
  provider,
175
263
  baseUrl: overrides.baseUrl ?? process.env.SCODER_BASE_URL ?? process.env.OPENAI_BASE_URL ?? rc.baseUrl ?? (isAnthropic ? ANTHROPIC_BASE_URL : DEFAULT_BASE_URL),
@@ -182,7 +270,7 @@ export function loadConfig(overrides = {}) {
182
270
  "",
183
271
  model: modelFromSlash || (overrides.model ?? process.env.SCODER_MODEL ?? process.env.OPENAI_MODEL ?? rc.model ?? (isAnthropic ? ANTHROPIC_DEFAULT_MODEL : DEFAULT_MODEL)),
184
272
  providerDef,
185
- contextWindow: providerDef?.contextWindow,
273
+ contextWindow: rc.contextWindow ?? providerDef?.contextWindow,
186
274
  workdir,
187
275
  buildCmd: overrides.buildCmd ?? process.env.SCODER_BUILD_CMD ?? rc.buildCmd ?? null,
188
276
  maxSteps: overrides.maxSteps ?? validatedMaxSteps,
@@ -198,6 +286,7 @@ export function loadConfig(overrides = {}) {
198
286
  ? true
199
287
  : process.env.SCODER_LOG_FILE || undefined),
200
288
  mcpServers: rc.mcpServers,
289
+ mcpTools: rc.mcpTools,
201
290
  guardrails: rc.guardrails,
202
291
  stream: overrides.stream ?? (process.env.SCODER_STREAM !== "0" ? true : false),
203
292
  reasoning: overrides.reasoning ??
@@ -218,8 +307,17 @@ export function loadConfig(overrides = {}) {
218
307
  promptVersion: rc.promptVersion,
219
308
  verifyCmds: rc.verifyCmds,
220
309
  selfReview: rc.selfReview,
310
+ verifyCache: rc.verifyCache,
311
+ localProfile: rc.localProfile,
221
312
  modelPricing: parsePricing(rc.modelPricing),
222
313
  };
314
+ // P2: local engine → mặc định rẻ hơn (maxSteps, timeout, output cap, MCP
315
+ // schema budget, self-review). Danh sách thay đổi được log qua reporter để
316
+ // `scoder cost`/JSONL giải thích được vì sao run này khác cloud.
317
+ const localChanges = applyLocalProfile(cfg, { maxSteps: maxStepsExplicit });
318
+ if (localChanges.length > 0) {
319
+ overrides.reporter?.info(`local model profile (plan/performance-issue.md P2): ${localChanges.join(", ")} — ghi đè bằng rc "localProfile": false hoặc đặt từng key trong "limits"`);
320
+ }
223
321
  // Secrets hygiene: warn if apiKey is set in the rc file (risk of accidental commit)
224
322
  if (rc.apiKey && !process.env.SCODER_NO_APIKEY_WARNING) {
225
323
  console.error(`\n⚠ WARNING: API key found in .scoderrc.json — this file may be committed to git.\n` +
@@ -5,11 +5,45 @@ export const DEFAULT_COMPACTION = {
5
5
  buffer: 0.15,
6
6
  contextWindow: 128_000,
7
7
  };
8
+ /**
9
+ * P1-1 (RC-4): tool schema KHÔNG nằm trong messages nhưng chiếm phần lớn
10
+ * prompt thật. Session baseline: 25.799 input token cho một câu "chào", trong
11
+ * đó ~20.300 token là schema (46 tool GitHub MCP + prompt-gate + built-ins).
12
+ * Counting only messages made compaction trigger ~20k token quá muộn.
13
+ */
14
+ const schemaTokenCache = new WeakMap();
15
+ export function estimateSchemaTokens(tools) {
16
+ if (!tools || tools.length === 0)
17
+ return 0;
18
+ const key = tools;
19
+ const hit = schemaTokenCache.get(key);
20
+ if (hit !== undefined)
21
+ return hit;
22
+ let text = "";
23
+ for (const t of tools) {
24
+ text += t.function.name + "\n" + (t.function.description ?? "") + "\n" + JSON.stringify(t.function.parameters ?? {}) + "\n";
25
+ }
26
+ let n;
27
+ try {
28
+ n = encode(text).length;
29
+ }
30
+ catch {
31
+ n = Math.ceil(text.length / 4);
32
+ }
33
+ try {
34
+ schemaTokenCache.set(key, n);
35
+ }
36
+ catch {
37
+ /* readonly array from a frozen literal — cache bỏ qua */
38
+ }
39
+ return n;
40
+ }
8
41
  /**
9
42
  * Token-accurate count (I-10): gpt-tokenizer (offline, MIT) thay heuristic
10
43
  * ~4 chars/token. Fallback char/4 nếu encode lỗi (never throw).
44
+ * `extraTokens` (P1-1): phần chi phí cố định ngoài messages — tool schemas.
11
45
  */
12
- export function estimateTokens(messages) {
46
+ export function estimateTokens(messages, extraTokens = 0) {
13
47
  let chars = 0;
14
48
  const texts = [];
15
49
  for (const m of messages) {
@@ -21,18 +55,18 @@ export function estimateTokens(messages) {
21
55
  }
22
56
  }
23
57
  try {
24
- return encode(texts.join("\n")).length + texts.length;
58
+ return encode(texts.join("\n")).length + texts.length + extraTokens;
25
59
  }
26
60
  catch {
27
61
  for (const t of texts)
28
62
  chars += t.length;
29
- return Math.ceil(chars / 4);
63
+ return Math.ceil(chars / 4) + extraTokens;
30
64
  }
31
65
  }
32
66
  /** True when the history should be compacted now. */
33
- export function shouldCompact(messages, cfg) {
67
+ export function shouldCompact(messages, cfg, extraTokens = 0) {
34
68
  const budget = cfg.contextWindow * cfg.trigger;
35
- return estimateTokens(messages) >= budget;
69
+ return estimateTokens(messages, extraTokens) >= budget;
36
70
  }
37
71
  /** Index where the verbatim tail begins (never inside a tool_calls/tool pair). */
38
72
  export function findCutIndex(messages, cfg) {