s-app-scoder-agent 0.6.0 → 0.7.0

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.
Files changed (49) hide show
  1. package/.scoderrc.example.json +97 -0
  2. package/LICENSE +21 -21
  3. package/README.md +599 -468
  4. package/dist/agent.js +825 -175
  5. package/dist/agents.js +70 -0
  6. package/dist/compaction.js +114 -0
  7. package/dist/config.js +178 -12
  8. package/dist/context.js +119 -14
  9. package/dist/cost.js +56 -6
  10. package/dist/guardrails.js +409 -0
  11. package/dist/index.js +121 -76
  12. package/dist/json-schema.js +58 -0
  13. package/dist/llm.js +123 -9
  14. package/dist/mcp.js +397 -70
  15. package/dist/model-catalog.js +175 -0
  16. package/dist/permissions.js +328 -35
  17. package/dist/prompt-builder.js +47 -0
  18. package/dist/prompt.js +97 -0
  19. package/dist/provider-anthropic.js +113 -35
  20. package/dist/provider-registry.js +116 -0
  21. package/dist/retry.js +3 -2
  22. package/dist/sandbox.js +96 -0
  23. package/dist/server.js +28 -1
  24. package/dist/session.js +282 -11
  25. package/dist/shell-scanner.js +112 -0
  26. package/dist/skills.js +122 -0
  27. package/dist/snapshot.js +170 -0
  28. package/dist/steering.js +35 -0
  29. package/dist/tools.js +513 -19
  30. package/dist/tui/app.js +498 -198
  31. package/dist/tui/at-complete.js +93 -0
  32. package/dist/tui/chat-views.js +145 -0
  33. package/dist/tui/chrome.js +96 -0
  34. package/dist/tui/command-registry.js +189 -0
  35. package/dist/tui/commands.js +200 -2
  36. package/dist/tui/image.js +149 -0
  37. package/dist/tui/input.js +214 -0
  38. package/dist/tui/markdown.js +229 -0
  39. package/dist/tui/mouse.js +20 -0
  40. package/dist/tui/permission-modal.js +34 -0
  41. package/dist/tui/review.js +64 -0
  42. package/dist/tui/session-picker.js +68 -0
  43. package/dist/tui/sidebar.js +101 -0
  44. package/dist/tui/state.js +182 -6
  45. package/dist/tui/theme.js +90 -0
  46. package/dist/tui/vim.js +60 -0
  47. package/dist/watch.js +52 -0
  48. package/package.json +16 -8
  49. package/dist/tui-direct.mjs +0 -4
@@ -0,0 +1,58 @@
1
+ /**
2
+ * M2: JSON-Schema subset validator — không dependency. Hỗ trợ: type,
3
+ * required, properties, enum, items, additionalProperties=false khi khai báo.
4
+ * Trả về mảng lỗi (rỗng = hợp lệ). Dùng để validate MCP tool args trước
5
+ * tools/call — lỗi local, không gọi server.
6
+ */
7
+ const TYPE_CHECKS = {
8
+ string: (v) => typeof v === "string",
9
+ number: (v) => typeof v === "number" && Number.isFinite(v),
10
+ integer: (v) => typeof v === "number" && Number.isInteger(v),
11
+ boolean: (v) => typeof v === "boolean",
12
+ object: (v) => typeof v === "object" && v !== null && !Array.isArray(v),
13
+ array: (v) => Array.isArray(v),
14
+ null: (v) => v === null,
15
+ };
16
+ /** Validate value theo schema subset. path dùng cho error message. */
17
+ export function validateSchema(value, schema, path = "args") {
18
+ if (!schema || typeof schema !== "object")
19
+ return [];
20
+ const errors = [];
21
+ const type = schema.type;
22
+ if (type !== undefined) {
23
+ const types = Array.isArray(type) ? type : [type];
24
+ const ok = types.some((t) => TYPE_CHECKS[t]?.(value));
25
+ if (!ok) {
26
+ errors.push(`${path}: expected ${types.join("|")}, got ${jsonType(value)}`);
27
+ return errors; // sai type — không kiểm tra sâu hơn
28
+ }
29
+ }
30
+ if (Array.isArray(schema.enum) && !schema.enum.some((e) => e === value)) {
31
+ errors.push(`${path}: must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(", ")}`);
32
+ }
33
+ if (TYPE_CHECKS.object(value)) {
34
+ const obj = value;
35
+ const props = (schema.properties ?? {});
36
+ for (const req of schema.required ?? []) {
37
+ if (!(req in obj))
38
+ errors.push(`${path}: missing required property "${req}"`);
39
+ }
40
+ for (const [k, v] of Object.entries(obj)) {
41
+ if (props[k])
42
+ errors.push(...validateSchema(v, props[k], `${path}.${k}`));
43
+ else if (schema.additionalProperties === false)
44
+ errors.push(`${path}: unexpected property "${k}"`);
45
+ }
46
+ }
47
+ if (Array.isArray(value) && schema.items) {
48
+ value.forEach((item, i) => errors.push(...validateSchema(item, schema.items, `${path}[${i}]`)));
49
+ }
50
+ return errors;
51
+ }
52
+ function jsonType(v) {
53
+ if (v === null)
54
+ return "null";
55
+ if (Array.isArray(v))
56
+ return "array";
57
+ return typeof v;
58
+ }
package/dist/llm.js CHANGED
@@ -3,6 +3,29 @@
3
3
  * and optional SSE streaming.
4
4
  */
5
5
  import { withRetry } from "./retry.js";
6
+ // ---------------------------------------------------------------------------
7
+ // Secrets redaction (H-13) — shared by LLM error bodies, MCP tool output,
8
+ // compaction summaries and snapshot logs. Never log raw credentials.
9
+ // ---------------------------------------------------------------------------
10
+ const REDACTION_PATTERNS = [
11
+ [/Bearer\s+[^\s,&\]]+/gi, "Bearer ***REDACTED***"],
12
+ [/sk-[A-Za-z0-9_-]{8,}/g, "sk-***REDACTED***"],
13
+ [/sk-ant-[^\s,&\]]+/gi, "sk-ant-***REDACTED***"],
14
+ [/ghp_[A-Za-z0-9]{20,}/g, "ghp_***REDACTED***"],
15
+ [/github_pat_[A-Za-z0-9_]{20,}/g, "github_pat_***REDACTED***"],
16
+ [/xox[bap]-[A-Za-z0-9-]{10,}/g, "xox***REDACTED***"],
17
+ [/AKIA[0-9A-Z]{16}/g, "AKIA***REDACTED***"],
18
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/g, "***PRIVATE-KEY***"],
19
+ [/\b(api[_-]?key|token|secret|password)["'\s:=]+[^\s"'&]{8,}/gi, "$1=***REDACTED***"],
20
+ ];
21
+ /** Redact known secret shapes from arbitrary tool/LLM/log output. */
22
+ export function redactSecrets(text) {
23
+ let out = text;
24
+ for (const [rx, replacement] of REDACTION_PATTERNS) {
25
+ out = out.replace(rx, replacement);
26
+ }
27
+ return out;
28
+ }
6
29
  export class LlmError extends Error {
7
30
  status;
8
31
  retryAfterMs;
@@ -19,8 +42,7 @@ export class LlmError extends Error {
19
42
  async function toLlmError(res, url) {
20
43
  let body = await res.text().catch(() => "");
21
44
  // Redact Bearer token and raw key patterns to avoid leaking secrets in logs/errors
22
- body = body.replace(/Bearer\s+[^\s,&\]]+/gi, "Bearer ***REDACTED***");
23
- body = body.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***REDACTED***");
45
+ body = redactSecrets(body);
24
46
  const err = new LlmError(`LLM API error ${res.status} at ${url}: ${body.slice(0, 500)}`, res.status);
25
47
  // Only honor a REAL Retry-After value: Number("") === 0 would zero the backoff
26
48
  const retryAfterHeader = res.headers.get("retry-after");
@@ -53,6 +75,8 @@ export function combinedSignal(external, timeoutMs) {
53
75
  }
54
76
  return {
55
77
  signal: ac.signal,
78
+ /** Clear only the request timer — keep the SIGINT listener alive. */
79
+ stopTimer: () => clearTimeout(timer),
56
80
  cleanup: () => {
57
81
  clearTimeout(timer);
58
82
  external?.removeEventListener("abort", onExternal);
@@ -102,6 +126,8 @@ function toWireMessages(messages) {
102
126
  export class LlmClient {
103
127
  opts;
104
128
  streamEnabled;
129
+ /** Last interrupted stream's accumulated tool calls (H-09 continuation). */
130
+ lastPartialToolCalls = [];
105
131
  constructor(opts) {
106
132
  this.opts = opts;
107
133
  this.streamEnabled = opts.stream !== false; // default true
@@ -109,10 +135,17 @@ export class LlmClient {
109
135
  get model() {
110
136
  return this.opts.model;
111
137
  }
138
+ /** T-04: runtime hot-swap — next request uses the new model id. */
139
+ setModel(model) {
140
+ this.opts = { ...this.opts, model };
141
+ }
142
+ get timeoutMs() {
143
+ return this.opts.requestTimeoutMs ?? LLM_REQUEST_TIMEOUT_MS;
144
+ }
112
145
  // ── Non-streaming ──────────────────────────────────────────────────────────
113
146
  async chat(messages, tools, opts = {}) {
114
147
  const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
115
- const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
148
+ const { signal, cleanup } = combinedSignal(opts.signal, this.timeoutMs);
116
149
  const body = JSON.stringify({
117
150
  model: this.opts.model,
118
151
  messages: toWireMessages(messages),
@@ -135,10 +168,21 @@ export class LlmClient {
135
168
  return r;
136
169
  }, {}, (attempt, delay, reason) => console.error(`⚠ LLM request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/3)...`));
137
170
  }
171
+ catch (err) {
172
+ cleanup(); // timer must not survive a failed request
173
+ throw err;
174
+ }
175
+ let data;
176
+ try {
177
+ // Keep the timeout alive through the body read — a hung body must also
178
+ // surface as a timeout, not an eternal await.
179
+ const pending = res.json();
180
+ pending.catch(() => { }); // no-op catch for the abort path; awaited below
181
+ data = (await pending);
182
+ }
138
183
  finally {
139
184
  cleanup();
140
185
  }
141
- const data = (await res.json());
142
186
  const choice = data.choices?.[0]?.message;
143
187
  if (!choice)
144
188
  throw new LlmError("LLM API returned no message in response");
@@ -157,6 +201,7 @@ export class LlmClient {
157
201
  role: "assistant",
158
202
  content: choice.content ?? null,
159
203
  stop_reason: choice.stop_reason ?? (choice.content ? "stop" : undefined),
204
+ reasoning: typeof choice.reasoning_content === "string" && choice.reasoning_content ? choice.reasoning_content : undefined,
160
205
  tool_calls: choice.tool_calls?.map((tc) => ({
161
206
  id: tc.id ?? "",
162
207
  name: tc.function?.name ?? "",
@@ -173,7 +218,7 @@ export class LlmClient {
173
218
  */
174
219
  async *streamChat(messages, tools, opts = {}) {
175
220
  const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
176
- const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
221
+ const { signal, cleanup, stopTimer } = combinedSignal(opts.signal, this.timeoutMs);
177
222
  const bodyStr = JSON.stringify({
178
223
  model: this.opts.model,
179
224
  messages: toWireMessages(messages),
@@ -181,6 +226,9 @@ export class LlmClient {
181
226
  temperature: this.opts.temperature,
182
227
  max_tokens: this.opts.maxTokens,
183
228
  stream: true,
229
+ // OpenAI-compatible providers only include usage in streaming responses
230
+ // when this flag is set — without it token accounting silently zeros out.
231
+ stream_options: { include_usage: true },
184
232
  });
185
233
  let res;
186
234
  try {
@@ -203,15 +251,47 @@ export class LlmClient {
203
251
  if (!res.body) {
204
252
  throw new LlmError("Response body is not readable (not a streaming response)");
205
253
  }
254
+ // Headers received — the WHOLE-request timeout has served its purpose.
255
+ // A long stream is normal (big models can stream for minutes), so from
256
+ // here on we enforce an IDLE timeout instead: reset on every received
257
+ // chunk; only a truly silent connection for idleTimeoutMs kills the read.
258
+ // (The old whole-request timer fired mid-stream on long generations —
259
+ // "LLM stream timed out while reading the response".) SIGINT still aborts
260
+ // through the same signal — stopTimer keeps the external listener alive.
261
+ stopTimer();
262
+ const idleTimeoutMs = opts.idleTimeoutMs ?? this.timeoutMs;
263
+ const readWithIdleTimeout = async () => {
264
+ const ac = new AbortController();
265
+ const t = setTimeout(() => ac.abort(new DOMException(`stream idle for ${idleTimeoutMs / 1000}s`, "TimeoutError")), idleTimeoutMs);
266
+ try {
267
+ return await Promise.race([
268
+ reader.read(),
269
+ new Promise((_, rej) => ac.signal.addEventListener("abort", () => rej(ac.signal.reason), { once: true })),
270
+ ]);
271
+ }
272
+ finally {
273
+ clearTimeout(t);
274
+ }
275
+ };
206
276
  // Accumulators for tool_calls across chunks
207
277
  const accumulatedToolCalls = [];
208
278
  let finalResult;
279
+ let finalUsage;
209
280
  const reader = res.body.getReader();
210
281
  const decoder = new TextDecoder();
211
282
  let buffer = "";
283
+ // Reset the partial-call record for this stream (H-09 continuation).
284
+ this.lastPartialToolCalls = [];
285
+ // I-05: accumulate reasoning deltas for the final message.
286
+ let reasoningAccum = "";
212
287
  try {
213
288
  while (true) {
214
- const { done, value } = await reader.read();
289
+ // Guard the pending read against a late abort (e.g. the idle timeout
290
+ // firing while the consumer paused at a yield): attaching a no-op
291
+ // catch prevents an unhandled rejection from crashing the process.
292
+ const pending = readWithIdleTimeout();
293
+ pending.catch(() => { });
294
+ const { done, value } = await pending;
215
295
  if (done)
216
296
  break;
217
297
  buffer += decoder.decode(value, { stream: true });
@@ -229,6 +309,10 @@ export class LlmClient {
229
309
  catch {
230
310
  continue; // skip malformed JSON lines
231
311
  }
312
+ // Usage-only final chunk: many providers send usage in a chunk with
313
+ // an empty choices array — must not be skipped.
314
+ if (chunkData.usage)
315
+ finalUsage = { ...chunkData.usage };
232
316
  const choice = chunkData.choices?.[0];
233
317
  if (!choice)
234
318
  continue;
@@ -237,6 +321,11 @@ export class LlmClient {
237
321
  if (delta?.content != null && delta.content.length > 0) {
238
322
  yield { contentDelta: delta.content, done: false };
239
323
  }
324
+ // I-05: reasoning_content delta (DeepSeek/OpenRouter style)
325
+ if (delta?.reasoning_content) {
326
+ reasoningAccum += delta.reasoning_content;
327
+ yield { reasoningDelta: delta.reasoning_content, done: false };
328
+ }
240
329
  // Tool call deltas
241
330
  if (delta?.tool_calls) {
242
331
  for (const tc of delta.tool_calls) {
@@ -278,6 +367,7 @@ export class LlmClient {
278
367
  content: null,
279
368
  stop_reason: choice.finish_reason,
280
369
  tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
370
+ reasoning: reasoningAccum || undefined,
281
371
  },
282
372
  usage: chunkData.usage,
283
373
  };
@@ -285,15 +375,39 @@ export class LlmClient {
285
375
  }
286
376
  }
287
377
  }
378
+ catch (err) {
379
+ // Normalize the abort timeout into LlmError so callers handle it as a
380
+ // normal provider failure instead of a stray DOMException.
381
+ const e = err;
382
+ if (e?.name === "TimeoutError" || /TimeoutError/.test(e?.message ?? "")) {
383
+ throw new LlmError(`LLM stream timed out (${(e?.message ?? "").includes("idle") ? `no data for ${e.message.replace(/^.*idle for /, "")}` : "while reading the response"})`);
384
+ }
385
+ throw err;
386
+ }
288
387
  finally {
289
- // Cancel the body (frees the socket) and clear the request timeout
388
+ // H-09: record complete tool calls accumulated so far, for continuation.
389
+ this.lastPartialToolCalls = accumulatedToolCalls
390
+ .filter((tc) => tc.name)
391
+ .map((tc) => ({ id: tc.id ?? "", name: tc.name, arguments: tc.arguments }));
392
+ // Cancel the body (frees the socket) and clear the request timeout.
393
+ // cancel() returns a promise — a rejection after abort must be swallowed
394
+ // or it becomes an unhandled rejection that kills the process.
290
395
  try {
291
- reader.cancel();
396
+ const c = reader.cancel();
397
+ if (c && typeof c.catch === "function")
398
+ c.catch(() => { });
292
399
  }
293
400
  catch { /* already released */ }
294
- reader.releaseLock();
401
+ try {
402
+ reader.releaseLock();
403
+ }
404
+ catch { /* lock already released */ }
295
405
  cleanup();
296
406
  }
407
+ // Attach usage collected from a usage-only chunk (choices: []) if the
408
+ // finish chunk itself didn't carry it.
409
+ if (finalResult && !finalResult.usage && finalUsage)
410
+ finalResult.usage = finalUsage;
297
411
  yield { done: true, final: finalResult };
298
412
  }
299
413
  }