@zhongqian97-code/ecode 0.0.7 → 0.1.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 (34) hide show
  1. package/dist/index.js +375 -103
  2. package/package.json +2 -1
  3. package/skills/caveman/SKILL.md +49 -0
  4. package/skills/diagnose/SKILL.md +117 -0
  5. package/skills/grill-me/SKILL.md +10 -0
  6. package/skills/grill-with-docs/ADR-FORMAT.md +47 -0
  7. package/skills/grill-with-docs/CONTEXT-FORMAT.md +77 -0
  8. package/skills/grill-with-docs/SKILL.md +88 -0
  9. package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
  10. package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
  11. package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
  12. package/skills/improve-codebase-architecture/SKILL.md +71 -0
  13. package/skills/plan/SKILL.md +16 -0
  14. package/skills/search-first/SKILL.md +20 -0
  15. package/skills/security-review/SKILL.md +26 -0
  16. package/skills/setup-matt-pocock-skills/SKILL.md +121 -0
  17. package/skills/setup-matt-pocock-skills/domain.md +51 -0
  18. package/skills/setup-matt-pocock-skills/issue-tracker-github.md +22 -0
  19. package/skills/setup-matt-pocock-skills/issue-tracker-gitlab.md +23 -0
  20. package/skills/setup-matt-pocock-skills/issue-tracker-local.md +19 -0
  21. package/skills/setup-matt-pocock-skills/triage-labels.md +15 -0
  22. package/skills/tdd/SKILL.md +109 -0
  23. package/skills/tdd/deep-modules.md +33 -0
  24. package/skills/tdd/interface-design.md +31 -0
  25. package/skills/tdd/mocking.md +59 -0
  26. package/skills/tdd/refactoring.md +10 -0
  27. package/skills/tdd/tests.md +61 -0
  28. package/skills/to-issues/SKILL.md +83 -0
  29. package/skills/to-prd/SKILL.md +76 -0
  30. package/skills/triage/AGENT-BRIEF.md +168 -0
  31. package/skills/triage/OUT-OF-SCOPE.md +101 -0
  32. package/skills/triage/SKILL.md +103 -0
  33. package/skills/write-a-skill/SKILL.md +117 -0
  34. package/skills/zoom-out/SKILL.md +7 -0
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ const _ew=process.emitWarning.bind(process);process.emitWarning=function(w,...a)
3
3
 
4
4
  // src/index.ts
5
5
  import { createRequire } from "module";
6
+ import { resolve, dirname as dirname2 } from "path";
7
+ import { fileURLToPath } from "url";
6
8
  import React4 from "react";
7
9
  import { render } from "ink";
8
10
 
@@ -10,6 +12,38 @@ import { render } from "ink";
10
12
  import { existsSync, readFileSync } from "fs";
11
13
  import { homedir } from "os";
12
14
  import { join } from "path";
15
+ var MODEL_CONTEXT_LIMITS = {
16
+ // OpenAI GPT 系列
17
+ "gpt-4o": 128e3,
18
+ "gpt-4o-mini": 128e3,
19
+ "gpt-4-turbo": 128e3,
20
+ "gpt-4": 8192,
21
+ // 老版 GPT-4 上下文极小,需特别注意
22
+ "gpt-3.5-turbo": 16385,
23
+ // OpenAI o 系列推理模型
24
+ "o1": 2e5,
25
+ "o1-mini": 128e3,
26
+ "o1-preview": 128e3,
27
+ "o3": 2e5,
28
+ "o3-mini": 2e5,
29
+ // Anthropic Claude 系列(全线支持 200K)
30
+ "claude-3-5-sonnet-20241022": 2e5,
31
+ "claude-3-5-haiku-20241022": 2e5,
32
+ "claude-3-opus-20240229": 2e5,
33
+ "claude-3-sonnet-20240229": 2e5,
34
+ "claude-3-haiku-20240307": 2e5,
35
+ "claude-sonnet-4-6": 2e5,
36
+ "claude-opus-4-7": 2e5,
37
+ "claude-haiku-4-5-20251001": 2e5,
38
+ // DeepSeek 系列(上下文较小,截断策略需更激进)
39
+ "deepseek-chat": 65536,
40
+ "deepseek-reasoner": 65536
41
+ };
42
+ var DEFAULT_CONTEXT_LIMIT = 128e3;
43
+ function getContextLimit(model, override) {
44
+ if (override !== void 0) return override;
45
+ return MODEL_CONTEXT_LIMITS[model] ?? DEFAULT_CONTEXT_LIMIT;
46
+ }
13
47
  var DEFAULTS = {
14
48
  baseUrl: "https://api.openai.com/v1",
15
49
  apiKey: "",
@@ -44,13 +78,19 @@ function loadConfig() {
44
78
  }
45
79
  }
46
80
  return {
81
+ // ?? 运算符:仅在左侧为 null / undefined 时才取右侧值,
82
+ // 与 || 的区别在于不会跳过空字符串,保证显式设置 "" 也能生效
47
83
  baseUrl: process.env.ECODE_BASE_URL ?? fileConfig.baseUrl ?? DEFAULTS.baseUrl,
48
84
  apiKey: process.env.ECODE_API_KEY ?? fileConfig.apiKey ?? DEFAULTS.apiKey,
49
85
  model: process.env.ECODE_MODEL ?? fileConfig.model ?? DEFAULTS.model,
50
- // dangerousPatterns: 文件配置覆盖默认值,不支持环境变量(命令数组不适合通过环境变量传递)
86
+ // dangerousPatterns 不支持环境变量注入:命令数组通过单个环境变量传递需要转义,
87
+ // 容易引入歧义,因此仅支持配置文件覆盖
51
88
  dangerousPatterns: fileConfig.dangerousPatterns ?? DEFAULTS.dangerousPatterns,
52
- // logDir: ECODE_LOG_DIR > 文件配置 > undefined
53
- logDir: process.env.ECODE_LOG_DIR ?? fileConfig.logDir ?? DEFAULTS.logDir
89
+ // logDir: ECODE_LOG_DIR 环境变量 > 配置文件 > undefined(禁用日志)
90
+ logDir: process.env.ECODE_LOG_DIR ?? fileConfig.logDir ?? DEFAULTS.logDir,
91
+ // contextLimit 仅支持配置文件配置:数值类型在文件中更直观,
92
+ // 环境变量还需要 parseInt 转换,增加出错风险
93
+ contextLimit: fileConfig.contextLimit
54
94
  };
55
95
  }
56
96
 
@@ -66,6 +106,20 @@ function createLLMClient(config2) {
66
106
  apiKey: config2.apiKey
67
107
  });
68
108
  return {
109
+ /**
110
+ * stream 方法向 LLM 发起一次流式对话请求,返回异步可迭代的 chunk 序列。
111
+ *
112
+ * 实现细节:
113
+ * - 使用 `Symbol.asyncIterator` + async generator 实现懒执行:
114
+ * 只有调用方执行 `for await` 时才真正发起 HTTP 请求,避免浪费连接
115
+ * - 内部维护两个累加器:
116
+ * 1. `tcAccumulator`:按 index 聚合工具调用的分片参数(JSON 字符串)
117
+ * 2. `reasoningAccumulator`:拼接思考链的所有分片文本
118
+ * - 只在最终 chunk(`finish_reason !== null`)中 yield done=true 的完整信息
119
+ *
120
+ * @param messages 完整的对话历史,包含 user/assistant/tool 所有轮次
121
+ * @param tools 可选的工具列表,不传或传空数组时不附加 tools 字段
122
+ */
69
123
  stream(messages, tools) {
70
124
  return {
71
125
  [Symbol.asyncIterator]: async function* () {
@@ -112,8 +166,11 @@ function createLLMClient(config2) {
112
166
  text: delta.content ?? "",
113
167
  done: true,
114
168
  finishReason: choice.finish_reason,
169
+ // tcAccumulator 为空说明本轮没有工具调用,传 undefined 而非空数组,
170
+ // 让调用方用 if (chunk.toolCalls) 做简洁判断
115
171
  toolCalls: tcAccumulator.size > 0 ? Array.from(tcAccumulator.values()) : void 0,
116
172
  reasoning: reasoningAccumulator || void 0,
173
+ // 将 snake_case 的原始字段映射为 camelCase,对外接口保持一致
117
174
  usage: rawUsage ? {
118
175
  promptTokens: rawUsage.prompt_tokens,
119
176
  completionTokens: rawUsage.completion_tokens,
@@ -188,13 +245,13 @@ function classifyCommand(cmd, dangerPatterns) {
188
245
  import { exec } from "child_process";
189
246
  var DEFAULT_TIMEOUT_MS = 3e4;
190
247
  function executeBash(cmd, timeoutMs = DEFAULT_TIMEOUT_MS) {
191
- return new Promise((resolve) => {
248
+ return new Promise((resolve2) => {
192
249
  exec(cmd, { timeout: timeoutMs }, (err, stdout, stderr) => {
193
250
  if (err) {
194
251
  const exitCode = err.code ?? 1;
195
- resolve({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode });
252
+ resolve2({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode });
196
253
  } else {
197
- resolve({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 });
254
+ resolve2({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 });
198
255
  }
199
256
  });
200
257
  });
@@ -252,6 +309,15 @@ function createLogger(logDir, sessionStart) {
252
309
  const filePath = path.join(logDir, filename);
253
310
  return {
254
311
  filePath,
312
+ /**
313
+ * 将单条日志条目序列化为 JSON 并同步追加到文件(末尾加换行符)。
314
+ *
315
+ * 使用 appendFileSync 而非 appendFile(异步版本)的原因:
316
+ * 进程崩溃或 Ctrl-C 退出时,异步写操作可能尚未完成,导致最后几条记录丢失。
317
+ * 同步写入虽然阻塞事件循环,但日志条目通常很小(< 1KB),延迟可忽略。
318
+ *
319
+ * 写入失败时输出到 stderr 而非抛出异常,防止日志错误中断正常业务流程。
320
+ */
255
321
  append(entry) {
256
322
  try {
257
323
  fs.appendFileSync(filePath, JSON.stringify(entry) + "\n");
@@ -263,6 +329,39 @@ function createLogger(logDir, sessionStart) {
263
329
  };
264
330
  }
265
331
 
332
+ // src/skills/resolver.ts
333
+ function isSkillCommand(input) {
334
+ return input.length > 1 && input.startsWith("/");
335
+ }
336
+ function parseSkillCommand(input) {
337
+ const withoutSlash = input.slice(1);
338
+ const spaceIndex = withoutSlash.search(/\s/);
339
+ if (spaceIndex === -1) {
340
+ return { name: withoutSlash, args: "" };
341
+ }
342
+ const name = withoutSlash.slice(0, spaceIndex);
343
+ const args = withoutSlash.slice(spaceIndex).trim();
344
+ return { name, args };
345
+ }
346
+
347
+ // src/skills/handler.ts
348
+ function handleSkillInput(input, registry2) {
349
+ if (!isSkillCommand(input)) return { type: "passthrough" };
350
+ const { name, args } = parseSkillCommand(input);
351
+ const skill = registry2.find(name);
352
+ if (!skill) {
353
+ const available = registry2.list().map((s) => s.name).join(", ") || "none";
354
+ return {
355
+ type: "error",
356
+ message: `Unknown skill: /${name}. Available: ${available}`
357
+ };
358
+ }
359
+ const content = args ? `${skill.body}
360
+
361
+ ${args}` : skill.body;
362
+ return { type: "skill", message: { role: "user", content } };
363
+ }
364
+
266
365
  // src/ui/StatusBar.tsx
267
366
  import { useEffect, useState } from "react";
268
367
  import { Box, Text } from "ink";
@@ -326,7 +425,7 @@ function StatusBar({
326
425
  const ctxStr = `ctx: ${tokenUsage.estimated ? "~" : ""}${usedStr}/${limitStr}`;
327
426
  const versionStr = `v${version2}`;
328
427
  return (
329
- // justifyContent="space-between" 让 Ink 自动撑开左右,
428
+ // 外层 Box:行方向布局,justifyContent="space-between" 实现左右分列
330
429
  // 避免用 string.length 手动计算填充(CJK 双宽字符会导致计算偏差)
331
430
  /* @__PURE__ */ jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [
332
431
  /* @__PURE__ */ jsxs(Box, { flexDirection: "row", children: [
@@ -335,13 +434,13 @@ function StatusBar({
335
434
  " "
336
435
  ] }),
337
436
  confirmPrompt ? (
338
- // 确认模式:整体用黄色显示
437
+ // 确认模式:整体用黄色显示,吸引用户注意
339
438
  /* @__PURE__ */ jsxs(Text, { color: "yellow", children: [
340
439
  icon,
341
440
  label
342
441
  ] })
343
442
  ) : status === "thinking" || status === "tool_calling" ? (
344
- // 进行中状态:icon 用绿色,文字用白色
443
+ // 进行中状态:图标用绿色(活跃感),标签用白色(可读性)
345
444
  /* @__PURE__ */ jsxs(Fragment, { children: [
346
445
  /* @__PURE__ */ jsxs(Text, { color: "green", children: [
347
446
  icon,
@@ -350,7 +449,7 @@ function StatusBar({
350
449
  /* @__PURE__ */ jsx(Text, { color: "white", children: label })
351
450
  ] })
352
451
  ) : (
353
- // 空闲 / 等待确认:淡灰
452
+ // 空闲 / 等待确认过渡态:整体淡灰,降低视觉权重
354
453
  /* @__PURE__ */ jsxs(Fragment, { children: [
355
454
  /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
356
455
  icon,
@@ -384,7 +483,7 @@ function UserMessage({
384
483
  content
385
484
  }) {
386
485
  return (
387
- // 用户消息使用 ">" 前缀,白色,与助手消息对比
486
+ // marginBottom={0} 避免 Ink 默认添加的底部间距,使历史区域更紧凑
388
487
  /* @__PURE__ */ jsx2(Box2, { flexDirection: "row", marginBottom: 0, children: /* @__PURE__ */ jsxs2(Text2, { color: "white", children: [
389
488
  "> ",
390
489
  content
@@ -398,32 +497,47 @@ function AssistantMessage({
398
497
  expandTools
399
498
  }) {
400
499
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginBottom: 0, children: [
401
- reasoning_content && reasoning_content.length > 0 && (expandTools ? /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
402
- /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "<thinking>" }),
403
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: reasoning_content })
404
- ] }) : /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "[+ thinking]" })),
500
+ reasoning_content && reasoning_content.length > 0 && (expandTools ? (
501
+ // 展开:显示 <thinking> 标题和完整推理内容
502
+ /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
503
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "<thinking>" }),
504
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: reasoning_content })
505
+ ] })
506
+ ) : (
507
+ // 折叠:一行简短占位符,告知用户有隐藏的思考内容
508
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "[+ thinking]" })
509
+ )),
405
510
  content && content.trim().length > 0 && /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: content }),
406
- tool_calls && tool_calls.length > 0 && (expandTools ? tool_calls.map((tc, idx) => {
407
- let argsDisplay = tc.function.arguments;
408
- try {
409
- const parsed = JSON.parse(tc.function.arguments);
410
- if (typeof parsed === "object" && parsed !== null) {
411
- argsDisplay = Object.values(parsed).map(String).join(", ");
511
+ tool_calls && tool_calls.length > 0 && (expandTools ? (
512
+ // 展开:逐条渲染每个工具调用
513
+ tool_calls.map((tc, idx) => {
514
+ let argsDisplay = tc.function.arguments;
515
+ try {
516
+ const parsed = JSON.parse(tc.function.arguments);
517
+ if (typeof parsed === "object" && parsed !== null) {
518
+ argsDisplay = Object.values(parsed).map(String).join(", ");
519
+ }
520
+ } catch {
412
521
  }
413
- } catch {
414
- }
415
- return /* @__PURE__ */ jsxs2(Text2, { color: "yellow", children: [
416
- "\u2699 \u8C03\u7528\u5DE5\u5177: ",
417
- tc.function.name,
418
- "(",
419
- argsDisplay,
420
- ")"
421
- ] }, idx);
422
- }) : /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
423
- "[+ ",
424
- tool_calls.length,
425
- " \u4E2A\u5DE5\u5177\u8C03\u7528]"
426
- ] }))
522
+ return (
523
+ // 黄色 ⚙ 图标 + 工具名 + 格式化参数
524
+ /* @__PURE__ */ jsxs2(Text2, { color: "yellow", children: [
525
+ "\u2699 \u8C03\u7528\u5DE5\u5177: ",
526
+ tc.function.name,
527
+ "(",
528
+ argsDisplay,
529
+ ")"
530
+ ] }, idx)
531
+ );
532
+ })
533
+ ) : (
534
+ // 折叠:用计数占位符替代,避免占用多行
535
+ /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
536
+ "[+ ",
537
+ tool_calls.length,
538
+ " \u4E2A\u5DE5\u5177\u8C03\u7528]"
539
+ ] })
540
+ ))
427
541
  ] });
428
542
  }
429
543
  function ToolMessage({
@@ -439,6 +553,39 @@ function ToolMessage({
439
553
  truncated
440
554
  ] }) });
441
555
  }
556
+ function estimateLines(msg, expandTools) {
557
+ if (msg.role === "user") {
558
+ return Math.max(1, msg.content.split("\n").length);
559
+ }
560
+ if (msg.role === "assistant") {
561
+ const contentLines = msg.content ? msg.content.split("\n").length : 0;
562
+ const assistantMsg = msg;
563
+ let reasoningLines = 0;
564
+ if (assistantMsg.reasoning_content && assistantMsg.reasoning_content.length > 0) {
565
+ if (expandTools) {
566
+ reasoningLines = 1 + assistantMsg.reasoning_content.split("\n").length;
567
+ } else {
568
+ reasoningLines = 1;
569
+ }
570
+ }
571
+ let toolLines = 0;
572
+ if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
573
+ if (expandTools) {
574
+ toolLines = assistantMsg.tool_calls.length;
575
+ } else {
576
+ toolLines = 1;
577
+ }
578
+ }
579
+ return Math.max(1, contentLines + reasoningLines + toolLines);
580
+ }
581
+ if (msg.role === "tool") {
582
+ if (!expandTools) {
583
+ return 1;
584
+ }
585
+ return Math.min(TOOL_RESULT_MAX_LINES, msg.content.split("\n").length) + 1;
586
+ }
587
+ return 1;
588
+ }
442
589
  function ConversationHistory({
443
590
  messages,
444
591
  maxHeight = 20,
@@ -447,51 +594,40 @@ function ConversationHistory({
447
594
  const visible = messages.filter(
448
595
  (m) => m.role !== "system"
449
596
  );
450
- function estimateLines(msg) {
451
- if (msg.role === "user") {
452
- return Math.max(1, msg.content.split("\n").length);
453
- }
454
- if (msg.role === "assistant") {
455
- const contentLines = msg.content ? msg.content.split("\n").length : 0;
456
- const toolLines = msg.tool_calls?.length ?? 0;
457
- return Math.max(1, contentLines + toolLines);
458
- }
459
- if (msg.role === "tool") {
460
- return Math.min(TOOL_RESULT_MAX_LINES, msg.content.split("\n").length) + 1;
461
- }
462
- return 1;
463
- }
464
597
  let totalLines = 0;
465
598
  let startIdx = visible.length;
466
599
  for (let i = visible.length - 1; i >= 0; i--) {
467
- const lines = estimateLines(visible[i]);
600
+ const lines = estimateLines(visible[i], expandTools);
468
601
  if (totalLines + lines > maxHeight) break;
469
602
  totalLines += lines;
470
603
  startIdx = i;
471
604
  }
472
605
  const displayMessages = visible.slice(startIdx);
473
- return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: displayMessages.map((msg, idx) => {
474
- if (msg.role === "user") {
475
- return /* @__PURE__ */ jsx2(UserMessage, { content: msg.content }, idx);
476
- }
477
- if (msg.role === "assistant") {
478
- const assistantMsg = msg;
479
- return /* @__PURE__ */ jsx2(
480
- AssistantMessage,
481
- {
482
- content: assistantMsg.content,
483
- tool_calls: assistantMsg.tool_calls,
484
- reasoning_content: assistantMsg.reasoning_content,
485
- expandTools
486
- },
487
- idx
488
- );
489
- }
490
- if (msg.role === "tool") {
491
- return /* @__PURE__ */ jsx2(ToolMessage, { content: msg.content, expandTools }, idx);
492
- }
493
- return null;
494
- }) });
606
+ return (
607
+ // 纵向堆叠所有消息
608
+ /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: displayMessages.map((msg, idx) => {
609
+ if (msg.role === "user") {
610
+ return /* @__PURE__ */ jsx2(UserMessage, { content: msg.content }, idx);
611
+ }
612
+ if (msg.role === "assistant") {
613
+ const assistantMsg = msg;
614
+ return /* @__PURE__ */ jsx2(
615
+ AssistantMessage,
616
+ {
617
+ content: assistantMsg.content,
618
+ tool_calls: assistantMsg.tool_calls,
619
+ reasoning_content: assistantMsg.reasoning_content,
620
+ expandTools
621
+ },
622
+ idx
623
+ );
624
+ }
625
+ if (msg.role === "tool") {
626
+ return /* @__PURE__ */ jsx2(ToolMessage, { content: msg.content, expandTools }, idx);
627
+ }
628
+ return null;
629
+ }) })
630
+ );
495
631
  }
496
632
 
497
633
  // src/ui/Input.tsx
@@ -554,6 +690,7 @@ function Input({ isActive, onSubmit, placeholder }) {
554
690
  });
555
691
  }
556
692
  },
693
+ // 第二个参数:仅在 isActive=true 时注册键盘监听
557
694
  { isActive }
558
695
  );
559
696
  const isEmpty = lines.every((line) => line === "");
@@ -575,21 +712,28 @@ function Input({ isActive, onSubmit, placeholder }) {
575
712
  ] }, idx);
576
713
  }) });
577
714
  };
578
- return /* @__PURE__ */ jsx3(Box3, { children: isActive ? renderLines() : /* @__PURE__ */ jsxs3(Box3, { children: [
579
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "> " }),
580
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: isEmpty ? placeholder ?? "" : lines.join(" ") })
581
- ] }) });
715
+ return /* @__PURE__ */ jsx3(Box3, { children: isActive ? (
716
+ // 激活状态:调用完整的多行渲染逻辑
717
+ renderLines()
718
+ ) : (
719
+ // 禁用状态:暗色单行显示,不响应键盘
720
+ /* @__PURE__ */ jsxs3(Box3, { children: [
721
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "> " }),
722
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: isEmpty ? placeholder ?? "" : lines.join(" ") })
723
+ ] })
724
+ ) });
582
725
  }
583
726
 
584
727
  // src/ui/App.tsx
585
728
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
586
- function App({ config: config2, version: version2, autoMode: autoMode2 = false }) {
729
+ function App({ config: config2, version: version2, autoMode: autoMode2 = false, registry: registry2 }) {
587
730
  const [messages, setMessages] = useState3([]);
588
731
  const [status, setStatus] = useState3("idle");
732
+ const contextLimit = getContextLimit(config2.model, config2.contextLimit);
589
733
  const [tokenUsage, setTokenUsage] = useState3({
590
734
  used: 0,
591
735
  estimated: true,
592
- limit: 128e3
736
+ limit: contextLimit
593
737
  });
594
738
  const [toolName, setToolName] = useState3(void 0);
595
739
  const [confirmPrompt, setConfirmPrompt] = useState3(void 0);
@@ -610,8 +754,11 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false }
610
754
  loggerRef.current.append({
611
755
  ts: (/* @__PURE__ */ new Date()).toISOString(),
612
756
  role: msg.role,
757
+ // content 可能为 null(纯工具调用的 assistant 消息),日志中保留 null
613
758
  content: typeof msg.content === "string" ? msg.content : null,
759
+ // tool_call_id 只在 tool 角色消息中存在,用于关联工具调用与结果
614
760
  tool_call_id: "tool_call_id" in msg ? msg.tool_call_id : void 0,
761
+ // tool_calls 只在 assistant 角色消息中存在(当 LLM 决定调用工具时)
615
762
  tool_calls: "tool_calls" in msg ? msg.tool_calls : void 0
616
763
  });
617
764
  }
@@ -623,10 +770,10 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false }
623
770
  }
624
771
  });
625
772
  const confirm = useCallback((prompt) => {
626
- return new Promise((resolve) => {
773
+ return new Promise((resolve2) => {
627
774
  setStatus("awaiting_confirm");
628
775
  setConfirmPrompt(prompt);
629
- pendingConfirmRef.current = { resolve };
776
+ pendingConfirmRef.current = { resolve: resolve2 };
630
777
  });
631
778
  }, []);
632
779
  const runLlmLoop = useCallback(
@@ -638,6 +785,7 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false }
638
785
  confirm,
639
786
  print,
640
787
  dangerousPatterns: config2.dangerousPatterns,
788
+ // autoMode=true 时自动同意 normal 级工具调用,无需用户确认
641
789
  autoApproveNormal: autoMode2
642
790
  };
643
791
  let currentMessages = history;
@@ -666,7 +814,7 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false }
666
814
  setTokenUsage({
667
815
  used: chunk.usage.totalTokens,
668
816
  estimated: false,
669
- limit: 128e3
817
+ limit: getContextLimit(config2.model, config2.contextLimit)
670
818
  });
671
819
  }
672
820
  } else {
@@ -682,12 +830,14 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false }
682
830
  if (toolCalls.length > 0) {
683
831
  const assistantMsg = {
684
832
  role: "assistant",
833
+ // content 可能为空字符串(纯工具调用),存 null 符合 OpenAI API 规范
685
834
  content: assistantText || null,
686
835
  tool_calls: toolCalls.map((tc) => ({
687
836
  id: tc.id,
688
837
  type: "function",
689
838
  function: { name: tc.name, arguments: tc.arguments }
690
839
  })),
840
+ // 仅在存在思考内容时扩展 reasoning_content 字段
691
841
  ...assistantReasoning ? { reasoning_content: assistantReasoning } : {}
692
842
  };
693
843
  setMessages((prev) => {
@@ -761,6 +911,29 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false }
761
911
  return;
762
912
  }
763
913
  if (!trimmed) return;
914
+ if (registry2) {
915
+ const skillResult = handleSkillInput(trimmed, registry2);
916
+ if (skillResult.type === "error") {
917
+ setMessages((prev) => [
918
+ ...prev,
919
+ { role: "assistant", content: skillResult.message }
920
+ ]);
921
+ return;
922
+ }
923
+ if (skillResult.type === "skill") {
924
+ const nextMessages2 = [...messages, skillResult.message];
925
+ setMessages(nextMessages2);
926
+ runLlmLoop(nextMessages2).catch((err) => {
927
+ setStatus("idle");
928
+ setToolName(void 0);
929
+ setMessages((prev) => [
930
+ ...prev,
931
+ { role: "assistant", content: `[error] ${String(err)}` }
932
+ ]);
933
+ });
934
+ return;
935
+ }
936
+ }
764
937
  const userMsg = { role: "user", content: trimmed };
765
938
  const nextMessages = [...messages, userMsg];
766
939
  setMessages(nextMessages);
@@ -769,6 +942,7 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false }
769
942
  setToolName(void 0);
770
943
  setMessages((prev) => [
771
944
  ...prev,
945
+ // 错误消息前缀 "[error]" 便于用户识别和日志筛选
772
946
  { role: "assistant", content: `[error] ${String(err)}` }
773
947
  ]);
774
948
  });
@@ -776,27 +950,116 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false }
776
950
  [status, messages, runLlmLoop]
777
951
  );
778
952
  const isInputActive = status === "idle" || status === "awaiting_confirm";
779
- return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", height: "100%", children: [
780
- /* @__PURE__ */ jsx4(ConversationHistory, { messages, expandTools }),
781
- /* @__PURE__ */ jsx4(
782
- StatusBar,
783
- {
784
- status,
785
- toolName,
786
- confirmPrompt,
787
- version: version2,
788
- tokenUsage
789
- }
790
- ),
791
- /* @__PURE__ */ jsx4(
792
- Input,
793
- {
794
- isActive: isInputActive,
795
- onSubmit: handleSubmit,
796
- placeholder: status === "awaiting_confirm" ? "y / n" : void 0
797
- }
798
- )
799
- ] });
953
+ return (
954
+ // 顶层容器:纵向堆叠,撑满终端高度(height="100%")
955
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", height: "100%", children: [
956
+ /* @__PURE__ */ jsx4(ConversationHistory, { messages, expandTools }),
957
+ /* @__PURE__ */ jsx4(
958
+ StatusBar,
959
+ {
960
+ status,
961
+ toolName,
962
+ confirmPrompt,
963
+ version: version2,
964
+ tokenUsage
965
+ }
966
+ ),
967
+ /* @__PURE__ */ jsx4(
968
+ Input,
969
+ {
970
+ isActive: isInputActive,
971
+ onSubmit: handleSubmit,
972
+ placeholder: status === "awaiting_confirm" ? "y / n" : void 0
973
+ }
974
+ )
975
+ ] })
976
+ );
977
+ }
978
+
979
+ // src/skills/registry.ts
980
+ var SkillRegistry = class {
981
+ skills = /* @__PURE__ */ new Map();
982
+ /** 注册或覆盖一个 skill(键不区分大小写)。 */
983
+ register(skill) {
984
+ this.skills.set(skill.name.toLowerCase(), skill);
985
+ }
986
+ /** 按名称查找 skill;未找到时返回 undefined。 */
987
+ find(name) {
988
+ return this.skills.get(name.toLowerCase());
989
+ }
990
+ /** 返回所有 skill,按名称字母顺序排序。 */
991
+ list() {
992
+ return [...this.skills.values()].sort(
993
+ (a, b) => a.name.localeCompare(b.name)
994
+ );
995
+ }
996
+ /** 已注册的 skill 数量。 */
997
+ size() {
998
+ return this.skills.size;
999
+ }
1000
+ };
1001
+
1002
+ // src/skills/loader.ts
1003
+ import { readFile, readdir, stat } from "fs/promises";
1004
+ import { join as join3, dirname, basename } from "path";
1005
+ function parseFrontmatter(content) {
1006
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
1007
+ const match = FRONTMATTER_RE.exec(content);
1008
+ if (!match) {
1009
+ return { data: {}, body: content };
1010
+ }
1011
+ const rawFrontmatter = match[1];
1012
+ const body = match[2] ?? "";
1013
+ const data = {};
1014
+ for (const line of rawFrontmatter.split(/\r?\n/)) {
1015
+ const colonIdx = line.indexOf(":");
1016
+ if (colonIdx === -1) continue;
1017
+ const key = line.slice(0, colonIdx).trim();
1018
+ const value = line.slice(colonIdx + 1).trim();
1019
+ if (key) {
1020
+ data[key] = value;
1021
+ }
1022
+ }
1023
+ return { data, body };
1024
+ }
1025
+ async function loadSkillFile(skillMdPath) {
1026
+ const content = await readFile(skillMdPath, "utf-8");
1027
+ const { data, body } = parseFrontmatter(content);
1028
+ const dirName = basename(dirname(skillMdPath));
1029
+ return {
1030
+ name: data["name"] ?? dirName,
1031
+ description: data["description"] ?? "",
1032
+ body,
1033
+ source: skillMdPath
1034
+ };
1035
+ }
1036
+ async function loadSkillsFromDir(dir) {
1037
+ let entries;
1038
+ try {
1039
+ entries = await readdir(dir);
1040
+ } catch {
1041
+ return [];
1042
+ }
1043
+ const skills = [];
1044
+ for (const entry of entries) {
1045
+ const entryPath = join3(dir, entry);
1046
+ let entryStat;
1047
+ try {
1048
+ entryStat = await stat(entryPath);
1049
+ } catch {
1050
+ continue;
1051
+ }
1052
+ if (!entryStat.isDirectory()) continue;
1053
+ const skillMdPath = join3(entryPath, "SKILL.md");
1054
+ try {
1055
+ await stat(skillMdPath);
1056
+ } catch {
1057
+ continue;
1058
+ }
1059
+ const skill = await loadSkillFile(skillMdPath);
1060
+ skills.push(skill);
1061
+ }
1062
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
800
1063
  }
801
1064
 
802
1065
  // src/index.ts
@@ -831,4 +1094,13 @@ if (!finalConfig.apiKey) {
831
1094
  );
832
1095
  process.exit(1);
833
1096
  }
834
- render(React4.createElement(App, { config: finalConfig, version: VERSION, autoMode }));
1097
+ var __dirname = dirname2(fileURLToPath(import.meta.url));
1098
+ var builtinSkillsDir = resolve(__dirname, "../skills");
1099
+ var userSkillsDir = resolve(process.env.HOME ?? "~", ".ecode/skills");
1100
+ var projectSkillsDir = resolve(process.cwd(), ".ecode/skills");
1101
+ var registry = new SkillRegistry();
1102
+ for (const dir of [builtinSkillsDir, userSkillsDir, projectSkillsDir]) {
1103
+ const skills = await loadSkillsFromDir(dir);
1104
+ for (const skill of skills) registry.register(skill);
1105
+ }
1106
+ render(React4.createElement(App, { config: finalConfig, version: VERSION, autoMode, registry }));