mocode-ai 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wxy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ <img src="./assets/banner.svg" alt="mocode">
2
+
3
+ # mocode
4
+
5
+ 一个终端编码 agent:**LLM + tool-call 循环 + 流式输出 + 思考可见**。
6
+
7
+ 接任意 OpenAI 兼容接口(GLM、DeepSeek、Qwen、本地 Ollama / vLLM 等),全屏 TUI 交互式 REPL。agent 自主探索代码、读写文件、执行命令、联网搜索,以「思考 → 调用工具 → 观察结果 → 再思考」的循环完成编程任务。
8
+
9
+ ## 特性
10
+
11
+ - **流式输出** — 回复边生成边显示,模型支持 reasoning 时思考过程实时可见,思考段自动折叠(不占屏),`/think N` 按需展开
12
+ - **全屏 TUI** — 备用屏(alt screen)+ 固定底栏状态行 + 滚动回看(PgUp/PgDn),运行中可打字(typeahead),下一轮自动预填
13
+ - **9 个内置工具** — 读 / 写 / 改文件、执行命令、glob 找路径、grep 搜内容、联网搜索、抓取网页、加载 skill
14
+ - **会话持久化** — 每轮自动落盘,`--resume` / `/resume` 续接历史会话
15
+ - **轮次回滚** — `/rollback` 菜单选轮次,删该轮及之后 + 逐个文件「保留/撤销」(快照恢复,不依赖 git)
16
+ - **上下文工程** — 接近窗口上限时自动三层压缩,`/context` 实时显示 token 用量条,`/compact` 手动压缩(可带焦点)
17
+ - **Skills 系统** — 自动扫描 `~/.mocode/skills/` 等目录,description 注入系统提示,模型按需调 `use_skill` 加载完整指令
18
+ - **斜杠命令** — `/exit` `/clear` `/context` `/skills` `/compact` `/resume` `/think` `/rollback`,输入时下拉过滤
19
+ - **Ctrl+C 中断** — 运行中随时中断当前 agent 轮次(不退进程)
20
+
21
+ ## 安装
22
+
23
+ ```bash
24
+ cd mocode
25
+ npm install
26
+ ```
27
+
28
+ > 依赖:`openai`、`dotenv`、`fast-glob`(运行时);`tsx`、`typescript`、`@types/node`(开发)。
29
+
30
+ ## 配置
31
+
32
+ 复制 `.env.example` 为 `.env`,填三个必填项:
33
+
34
+ ```bash
35
+ cp .env.example .env
36
+ ```
37
+
38
+ ```env
39
+ LLM_BASE_URL=https://open.bigmodel.cn/api/v3 # 换成你的后端
40
+ LLM_API_KEY=your-key-here
41
+ LLM_MODEL=glm-4.6 # 换成你的模型名
42
+ ```
43
+
44
+ 常见后端 `base_url`:
45
+
46
+ | 后端 | base_url |
47
+ |------|----------|
48
+ | GLM(智谱) | `https://open.bigmodel.cn/api/v3` |
49
+ | DeepSeek | `https://api.deepseek.com` |
50
+ | Qwen(阿里) | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
51
+ | 本地 Ollama | `http://localhost:11434/v1` |
52
+ | 本地 vLLM | `http://localhost:8000/v1` |
53
+
54
+ > 模型必须支持 OpenAI 风格的 function calling,否则工具不会触发。
55
+
56
+ ### 可选配置
57
+
58
+ | 环境变量 | 说明 | 默认值 |
59
+ |----------|------|--------|
60
+ | `MAX_TOKENS` | 单次回复最大 token | 不限 |
61
+ | `CONTEXT_WINDOW_TOKENS` | 模型上下文窗口,须对齐真实模型 | `128000` |
62
+ | `COMPACT_THRESHOLD` | 自动压缩触发阈值(占窗口比例) | `0.85` |
63
+ | `LLM_STREAM_USAGE` | 流式请求带 `stream_options.include_usage` 拿真实用量 | `true` |
64
+ | `AUTO_COMPACT` | 自动压缩总开关 | `true` |
65
+ | `ANYSEARCH_API_KEY` | 联网搜索 API key(不配走匿名免费额度) | 无 |
66
+ | `ANYSEARCH_BASE_URL` | 搜索 API 端点 | `https://api.anysearch.com` |
67
+ | `SKILLS_DIRS` | 覆盖默认 skill 扫描目录(平台分隔符) | 三目录自动扫描 |
68
+
69
+ ## 运行
70
+
71
+ ```bash
72
+ npm start # 新会话
73
+ npm start -- --resume # 列出已保存会话
74
+ npm start -- --resume <id> # 续接指定会话
75
+ ```
76
+
77
+ 进入 REPL 后直接对话。启动即进全屏 TUI,显示横幅(模型 / 后端 / 工作目录 / 工具列表)。回复流式打印,思考段实时可见后折叠。
78
+
79
+ agent 工作在**启动时所在的工作目录**——想让它操作某个项目,就 `cd` 到那个项目再 `npm start`。
80
+
81
+ ## 工具
82
+
83
+ | 工具 | 作用 |
84
+ |------|------|
85
+ | `read_file` | 读文件,带行号,支持 `offset` / `limit` |
86
+ | `write_file` | 创建/覆盖文件,自动建父目录 |
87
+ | `edit_file` | 精确字符串替换(`old_string` 须唯一匹配) |
88
+ | `run_command` | 执行 shell 命令,合并 stdout+stderr,默认 120s 超时 |
89
+ | `glob` | 按 glob 模式找文件(排除 node_modules/.git) |
90
+ | `grep` | 内容正则搜索,纯 JS 实现,不依赖 `rg` |
91
+ | `web_search` | 联网搜索(AnySearch),返回标题/URL/摘要/正文 |
92
+ | `web_fetch` | 抓取指定 URL,HTML 清洗成纯文本 |
93
+ | `use_skill` | 加载某 skill 的完整 SKILL.md 指令 |
94
+
95
+ ## 斜杠命令
96
+
97
+ | 命令 | 作用 |
98
+ |------|------|
99
+ | `/exit` `/quit` | 退出 mocode |
100
+ | `/clear` | 清空历史(保留系统提示)+ 清屏 |
101
+ | `/context` | 显示上下文用量条(token / 消息数 / 估算或实测) |
102
+ | `/skills` | 列出已发现的 skill |
103
+ | `/compact` | 压缩历史(可带焦点 `/compact …`) |
104
+ | `/resume` | 续接已保存的会话 |
105
+ | `/think` | 展开折叠思考段(`/think N`) |
106
+ | `/rollback` | 菜单选轮次回滚(↑↓ · Enter) |
107
+
108
+ 输入 `/` 触发下拉菜单,继续打字过滤;Esc 取消。
109
+
110
+ ## 快速验证(配好 key 后)
111
+
112
+ ```
113
+ > 你好,你是谁 # 验证 LLM 连通
114
+ > 读一下 sample.txt # 触发 read_file
115
+ > 把 sample.txt 里的 foo 改成 bar # 触发 read_file + edit_file
116
+ > 列出当前目录所有 .txt 文件 # 触发 glob
117
+ > 搜一下代码里出现 runAgent 的地方 # 触发 grep
118
+ > 跑一下 node -e "console.log(1+1)" # 触发 run_command
119
+ > 搜一下 TypeScript 5.5 有什么新特性 # 触发 web_search
120
+ ```
121
+
122
+ 每步终端会打印 `● 工具名 + 参数摘要` 与 `↳ 结果预览`,agent 在循环里自己决定下一步;回复流式打印,边生成边显示。
123
+
124
+ ## Skills
125
+
126
+ mocode 自动扫描以下目录的 skill(每个 skill 是 `<name>/SKILL.md`,带 frontmatter):
127
+
128
+ - `~/.claude/skills/`
129
+ - `~/.mocode/skills/`
130
+ - `<cwd>/.mocode/skills/`
131
+
132
+ skill 的 `description` 注入系统提示(渐进式披露第①层),模型只在任务相关时调 `use_skill` 加载完整正文(第②层)。用 `/skills` 查看已发现的 skill。
133
+
134
+ ## 结构
135
+
136
+ ```
137
+ src/
138
+ ├── index.ts # 入口:装配 + 启动 REPL + --resume 续接
139
+ ├── repl/index.ts # 全屏 TUI、斜杠命令、运行态交互、Ctrl+C 中断
140
+ ├── agent/index.ts # tool-call 循环(调 LLM→执行工具→回灌→再调,≤25 步)
141
+ ├── llm/index.ts # OpenAI 兼容客户端 + 工具格式转换 + chat() 流式
142
+ ├── tools/ # types / constants / registry + builtins/(一工具一文件)
143
+ │ └── builtins/ # read-file write-file edit-file run-command glob grep
144
+ │ # web-search web-fetch use-skill
145
+ ├── config/index.ts # 读 .env、校验必填项、系统提示词
146
+ ├── skills/ # discover(扫描)+ index(缓存/拼系统提示)
147
+ ├── session/ # 落盘/续接 + compact(三层压缩)+ token 估算
148
+ ├── rollback/ # 轮次快照 + /rollback 菜单 + 文件撤销
149
+ ├── ui/ # theme(颜色)render(横幅/摘要)layout(全屏布局)spinner prompt
150
+ └── agents/ commands/ mcp/ memory/ permissions/ # 未来子系统(空骨架)
151
+ ```
152
+
153
+ ## 类型检查
154
+
155
+ ```bash
156
+ npm run typecheck # tsc --noEmit
157
+ ```
158
+
159
+ ## 可后续扩展
160
+
161
+ 子 agent / 并行任务、MCP 工具集成、权限确认 UI、代码图谱(CodeGraph)。当前版本是一个流式、思考可见、可回滚的终端编码 agent。
package/bin/mocode.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // mocode 全局入口:shebang + 拉起编译产物。子命令(--resume / config)路由在 dist/index.js 的 main() 内,
3
+ // 用动态 import 按需加载,避免 `mocode config`(零配置时)触发 config/index.ts 的 requireEnv 退出。
4
+ await import('../dist/index.js');
@@ -0,0 +1,156 @@
1
+ import { chat, } from '../llm/index.js';
2
+ import { executeTool } from '../tools/registry.js';
3
+ import { ui } from '../ui/theme.js';
4
+ import { Spinner } from '../ui/spinner.js';
5
+ import { summarizeToolCall, summarizeToolResult, truncateDisplay, } from '../ui/render.js';
6
+ import * as layout from '../ui/layout.js';
7
+ import { beginTurn } from '../rollback/index.js';
8
+ import { maybeCompact, capToolResultForHistory, contextState, } from '../session/index.js';
9
+ const MAX_STEPS = 25; // 防止无限循环
10
+ /**
11
+ * agent 核心循环:
12
+ * 流式调 LLM(onText / onThinking 实时写内容区)→ 有 tool_calls 就执行并回灌
13
+ * → 否则流式正文即最终回复。history 在调用间持久,由 REPL 持有。
14
+ * 步前经 session/maybeCompact 自动压缩(接近窗口上限时三层压缩);
15
+ * 工具结果进 history 前经 capToolResultForHistory 裁到单条上限。
16
+ *
17
+ * 所有正文写经 layout.contentWrite(保证落在内容区、跟踪续写位、底栏不被顶);
18
+ * spinner 经 onFrame 回调刷状态行(layout.drawStatusBar),等待时内容区静止、底栏转圈。
19
+ * 思考段用 layout.beginSegment / eraseSegmentBack 精确按物理行折叠(修预存按 \n 数行漏擦 bug),
20
+ * 原文存入 collapsedThinkings 供 /think N 重打。
21
+ */
22
+ export async function runAgent(history, userInput, collapsedThinkings = [], signal) {
23
+ // 中断回滚快照:入口(本 turn push 任何消息前)整段浅拷贝。abort 时 length=0;push(...saved) 还原。
24
+ // 用 slice() 而非 length:maybeCompact 会原地重建(length=0;push(...rebuilt)),savedLen 会失效。
25
+ const savedHistory = history.slice();
26
+ history.push({ role: 'user', content: userInput });
27
+ // 开新轮次(回滚用):首行截断 40,供 /rollback 轮次菜单展示。
28
+ beginTurn(truncateDisplay(userInput.split('\n')[0] ?? '', 40));
29
+ layout.contentMode(); // 防御性:确保光标在内容续写位(enterRunningMode 已置,这里兜底)
30
+ const spinner = new Spinner((msg, frame) => layout.setStatus(msg, frame ?? undefined));
31
+ // 本轮流式状态:区分「思考」与「正文」,首个 token 到达即停 spinner。
32
+ let mode = 'idle';
33
+ let gotText = false;
34
+ let lastChar = '';
35
+ // 折叠用:累计本段思考原文缓冲。
36
+ let thinkingBuffer = '';
37
+ /** 把当前还在屏幕上可见的思考段擦掉,换成一行折叠标题;原文入栈。 */
38
+ const flushThinkCollapsed = () => {
39
+ if (mode !== 'thinking')
40
+ return;
41
+ collapsedThinkings.push(thinkingBuffer);
42
+ const idx = collapsedThinkings.length;
43
+ layout.eraseSegmentBack(); // 逐行擦思考段(不用 ED——ED 会清穿底栏)
44
+ layout.contentWrite(`${ui.dim}▎ 思考 ▸ (${thinkingBuffer.length} 字符, /think ${idx} 展开)${ui.reset}\n`);
45
+ thinkingBuffer = '';
46
+ // 之后任何输出按 text 走,不再回到 thinking(避免后续再被错误折叠)
47
+ mode = 'text';
48
+ };
49
+ const onThinking = (s) => {
50
+ if (mode === 'idle') {
51
+ spinner.stop();
52
+ if (lastChar && lastChar !== '\n')
53
+ layout.contentWrite('\n');
54
+ layout.beginSegment(); // 思考段起点(标题行)
55
+ layout.contentWrite(`${ui.dim}▎ 思考${ui.reset}\n`);
56
+ }
57
+ else if (mode === 'text') {
58
+ // 续思考:先把续标题放到新行,再开新段(段起点 = 续标题行,擦除不误伤上文)
59
+ if (lastChar && lastChar !== '\n')
60
+ layout.contentWrite('\n');
61
+ layout.beginSegment();
62
+ layout.contentWrite(`${ui.dim}▎ 思考(续)${ui.reset}\n`);
63
+ }
64
+ mode = 'thinking';
65
+ thinkingBuffer += s;
66
+ layout.contentWrite(`${ui.dim}${s}${ui.reset}`);
67
+ if (s)
68
+ lastChar = s[s.length - 1];
69
+ };
70
+ const onText = (s) => {
71
+ flushThinkCollapsed(); // 思考→正文过渡时折叠
72
+ if (mode === 'idle')
73
+ spinner.stop();
74
+ mode = 'text';
75
+ gotText = true;
76
+ layout.contentWrite(s);
77
+ if (s)
78
+ lastChar = s[s.length - 1];
79
+ };
80
+ try {
81
+ for (let step = 0; step < MAX_STEPS; step++) {
82
+ // 步前:接近窗口上限时自动压缩(三层)。此时 spinner 已停,通知行干净。
83
+ await maybeCompact(history);
84
+ spinner.start('思考中');
85
+ mode = 'idle';
86
+ gotText = false;
87
+ lastChar = '';
88
+ let result;
89
+ try {
90
+ result = await chat(history, { onText, onThinking }, signal);
91
+ }
92
+ catch (e) {
93
+ // 中断(用户运行中 Ctrl+C):停 spinner、补换行、提示、history 还原到本 turn 前、return(不抛)。
94
+ // abort 只在 await chat() 期生效;tool 执行不可中断,故不会留下未配对的 tool_call_id。
95
+ if (signal?.aborted ||
96
+ (e instanceof Error &&
97
+ (e.name === 'AbortError' || e.name === 'APIUserAbortError'))) {
98
+ spinner.stop();
99
+ if (lastChar && lastChar !== '\n')
100
+ layout.contentWrite('\n');
101
+ layout.contentWrite(`${ui.dim}(已中断)${ui.reset}\n`);
102
+ history.length = 0;
103
+ history.push(...savedHistory);
104
+ return;
105
+ }
106
+ throw e;
107
+ }
108
+ contextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
109
+ spinner.stop();
110
+ if (result.toolCalls.length > 0) {
111
+ // 思考后直接进入 tool_call(无 text):先把可见的思考段折叠
112
+ flushThinkCollapsed();
113
+ // 带工具调用的 assistant 消息原样回灌(OpenAI 格式要求)
114
+ history.push({
115
+ role: 'assistant',
116
+ content: result.content,
117
+ tool_calls: result.toolCalls.map((tc) => ({
118
+ id: tc.id,
119
+ type: 'function',
120
+ function: { name: tc.name, arguments: tc.arguments },
121
+ })),
122
+ });
123
+ for (const tc of result.toolCalls) {
124
+ const summary = summarizeToolCall(tc.name, tc.arguments);
125
+ layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}${tc.name}${ui.reset} ${ui.dim}${summary}${ui.reset}\n`);
126
+ spinner.start(`执行 ${tc.name}`);
127
+ const output = await executeTool(tc.name, tc.arguments);
128
+ spinner.stop();
129
+ const preview = summarizeToolResult(tc.name, output);
130
+ if (preview) {
131
+ layout.contentWrite(` ${ui.gray}↳ ${preview}${ui.reset}\n`);
132
+ }
133
+ history.push({
134
+ role: 'tool',
135
+ tool_call_id: tc.id,
136
+ content: capToolResultForHistory(tc.name, output),
137
+ });
138
+ }
139
+ continue; // 带着工具结果再调一次 LLM
140
+ }
141
+ // 思考后既无 tool_call 也无 text(纯思考):兜底折叠
142
+ flushThinkCollapsed();
143
+ if (mode !== 'idle' && lastChar !== '\n')
144
+ layout.contentWrite('\n'); // 流式末尾补换行
145
+ // 没有工具调用:流式正文即最终回复(已实时打印)
146
+ if (!gotText)
147
+ layout.contentWrite(`${ui.dim}(无回复)${ui.reset}\n`);
148
+ history.push({ role: 'assistant', content: result.content });
149
+ return;
150
+ }
151
+ layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}达到最大步数(${MAX_STEPS}),本轮停止。${ui.reset}\n`);
152
+ }
153
+ finally {
154
+ spinner.stop();
155
+ }
156
+ }
@@ -0,0 +1,61 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import * as readline from 'node:readline';
5
+ import dotenv from 'dotenv';
6
+ /** ~/.mocode/config:全局配置(与 ~/.mocode/skills 同目录,任意终端 / 任意目录生效)。 */
7
+ const CONFIG_PATH = path.join(os.homedir(), '.mocode', 'config');
8
+ function ask(rl, q) {
9
+ return new Promise((resolve) => {
10
+ rl.question(q, (ans) => resolve(ans.trim()));
11
+ });
12
+ }
13
+ /** 读已有配置(没有则空对象),用于回填默认值与保留其它键。 */
14
+ function readExisting() {
15
+ try {
16
+ return dotenv.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ /**
23
+ * 首跑配置向导:交互填 LLM_BASE_URL / LLM_API_KEY / LLM_MODEL,写 ~/.mocode/config。
24
+ * 只交互三键、保留文件里其它键(MAX_TOKENS / CONTEXT_WINDOW_TOKENS 等)。prompt 全纯文本(readline 光标按字符算,不能含 ANSI)。
25
+ * 由 index.ts 在 `mocode config` 时动态加载,故不 import config/index.ts(避免缺配置时 requireEnv 直接退出)。
26
+ */
27
+ export async function runConfigWizard() {
28
+ const cur = readExisting();
29
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
30
+ console.log(`mocode 配置向导 — 写入 ${CONFIG_PATH}(Ctrl+C 取消)\n`);
31
+ const baseURLIn = await ask(rl, `LLM_BASE_URL${cur.LLM_BASE_URL ? ` [${cur.LLM_BASE_URL}]` : ''}(如 https://open.bigmodel.cn/api/v3): `);
32
+ const baseURL = baseURLIn || cur.LLM_BASE_URL || '';
33
+ if (!baseURL) {
34
+ console.error('\n[config] LLM_BASE_URL 不能为空,已取消。');
35
+ rl.close();
36
+ process.exit(1);
37
+ }
38
+ const apiKeyIn = await ask(rl, `LLM_API_KEY${cur.LLM_API_KEY ? ' [已设置,回车保留]' : ''}: `);
39
+ const apiKey = apiKeyIn || cur.LLM_API_KEY || '';
40
+ if (!apiKey) {
41
+ console.error('\n[config] LLM_API_KEY 不能为空,已取消。');
42
+ rl.close();
43
+ process.exit(1);
44
+ }
45
+ const modelIn = await ask(rl, `LLM_MODEL${cur.LLM_MODEL ? ` [${cur.LLM_MODEL}]` : ''}(回车默认 gpt-4o-mini): `);
46
+ const model = modelIn || cur.LLM_MODEL || 'gpt-4o-mini';
47
+ rl.close();
48
+ // 合并:保留其它键,只覆盖三键。
49
+ const merged = {
50
+ ...cur,
51
+ LLM_BASE_URL: baseURL,
52
+ LLM_API_KEY: apiKey,
53
+ LLM_MODEL: model,
54
+ };
55
+ const body = Object.entries(merged)
56
+ .map(([k, v]) => `${k}=${v}`)
57
+ .join('\n');
58
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
59
+ fs.writeFileSync(CONFIG_PATH, `${body}\n`, 'utf8');
60
+ console.log(`\n已写入 ${CONFIG_PATH}。现在运行 \`mocode\` 即可启动(任意目录、任意终端)。`);
61
+ }
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import dotenv from 'dotenv';
5
+ /**
6
+ * 按优先级加载配置文件并回填 process.env:
7
+ * 候选(文件内升序、后者覆盖前者):~/.mocode/config(全局)→ <cwd>/.mocode/config → <cwd>/.env(兼容旧用法)。
8
+ * 合并后只回填 process.env 里**尚未设置**的键——shell 里 export 的环境变量永远优先。
9
+ * 故 `mocode` 可在任意目录 / 任意终端启动:全局配置(~/.mocode/config)兜底,项目级文件按需覆盖。
10
+ */
11
+ function loadEnvFiles() {
12
+ const candidates = [
13
+ path.join(os.homedir(), '.mocode', 'config'),
14
+ path.join(process.cwd(), '.mocode', 'config'),
15
+ path.join(process.cwd(), '.env'),
16
+ ];
17
+ const fromFiles = {};
18
+ for (const p of candidates) {
19
+ try {
20
+ Object.assign(fromFiles, dotenv.parse(fs.readFileSync(p, 'utf8')));
21
+ }
22
+ catch {
23
+ // 文件不存在或不可读:跳过
24
+ }
25
+ }
26
+ for (const [k, v] of Object.entries(fromFiles)) {
27
+ if (process.env[k] === undefined)
28
+ process.env[k] = v;
29
+ }
30
+ }
31
+ loadEnvFiles();
32
+ function requireEnv(key) {
33
+ const v = process.env[key];
34
+ if (!v) {
35
+ console.error(`\n[config] 缺少 ${key}。运行 \`mocode config\` 初始化,或在 ~/.mocode/config / <cwd>/.env 中设置(参考 .env.example)。\n`);
36
+ process.exit(1);
37
+ }
38
+ return v;
39
+ }
40
+ const SYSTEM_PROMPT = `你是 mocode,一个终端编码 agent。你以"思考 → 调用工具 → 观察结果 → 再思考"的循环完成编程任务,直到问题解决。面向中文用户,回复用中文。
41
+
42
+ ## 工作流
43
+ - 先理解再动手:不确定需求或代码现状时,先 read_file / grep / glob 探索,不凭空假设。
44
+ - 小步推进:任务拆成可验证的子步骤,每步动手前想清楚改什么、为什么。
45
+ - 改完即验证:用 run_command 跑 typecheck / 测试 / 构建确认有效,未验证不声称完成。
46
+
47
+ ## 工具准则
48
+ - 参数与用法见各工具自带说明;这里只讲选择策略与易踩坑。
49
+ - 改代码前先 read_file 确认实际内容(带行号),不凭记忆猜。
50
+ - 局部改用 edit_file:old_string 须唯一且精确匹配(含缩进/换行),多带上下文行确保唯一;新建或整体重写用 write_file。
51
+ - 找路径用 glob,找内容用 grep;不要用 run_command 拼 cat / sed / find / grep。
52
+ - run_command 按平台执行(Win 用 cmd、其他用 bash);有副作用的命令(删文件、装包、git push、重置等)执行前先简述意图。
53
+ - 需要训练数据之外的最新信息(新版本、新闻、实时数据、最新 API)时用 web_search 联网搜索,不要凭记忆答可能过时的内容。
54
+ - 要读取某个具体 URL 的内容(搜索结果里的链接、用户给的 URL)时用 web_fetch 抓取;它只抓静态 HTML,JS 渲染页面拿不到正文时改用 web_search(其结果自带清洗后的正文)。
55
+
56
+ ## 失败处理
57
+ - 工具以字符串返回错误(edit_file 未匹配或不唯一、run_command 非零退出码等)。先分析根因,调整后重试,不要原样重发同一条调用。
58
+ - 命令报错时把真实输出读进去再判断,别跳过。
59
+
60
+ ## 安全与边界
61
+ - 不可逆或外向操作(删除、覆盖既有文件、推送、请求外部服务)执行前向用户确认,除非已获明确授权。
62
+ - 只在授权范围内操作;不确定就问,别猜。
63
+
64
+ ## 终止与汇报
65
+ - 无需更多工具时立即停止,直接给结论。
66
+ - 如实汇报:成功说成功,失败说卡在哪,跳过的也要说。引用代码用 "path:行号" 格式(如 src/index.ts:42)。保持简洁。`;
67
+ export const config = {
68
+ baseURL: requireEnv('LLM_BASE_URL'),
69
+ apiKey: requireEnv('LLM_API_KEY'),
70
+ model: process.env.LLM_MODEL || 'gpt-4o-mini',
71
+ maxTokens: process.env.MAX_TOKENS ? Number(process.env.MAX_TOKENS) : undefined,
72
+ systemPrompt: SYSTEM_PROMPT,
73
+ contextWindowTokens: Number(process.env.CONTEXT_WINDOW_TOKENS) || 128000,
74
+ compactThreshold: Number(process.env.COMPACT_THRESHOLD) || 0.85,
75
+ includeUsage: process.env.LLM_STREAM_USAGE !== 'false',
76
+ autoCompact: process.env.AUTO_COMPACT !== 'false',
77
+ sessionDir: path.join(process.cwd(), '.mocode', 'sessions'),
78
+ searchApiKey: process.env.ANYSEARCH_API_KEY,
79
+ searchBaseUrl: process.env.ANYSEARCH_BASE_URL || 'https://api.anysearch.com',
80
+ };
package/dist/index.js ADDED
@@ -0,0 +1,75 @@
1
+ import { exitAltScreen } from './ui/layout.js';
2
+ // 终端恢复兜底:任一退出 / 中断 / 未捕获异常路径都要恢复 alt screen,避免残留备用屏 + 滚动区域。
3
+ // exitAltScreen 幂等(未激活时空操作),故全局注册安全——进 alt screen 前的路径(如 --resume 列表、缺环境变量、`mocode config`)调用它无副作用。
4
+ // 仅 layout 是叶子(不依赖 config),故静态导入安全;repl / session 依赖 config(requireEnv 缺项即退出),改动态按需加载——`mocode config` 才能在零配置下跑。
5
+ process.on('exit', () => exitAltScreen());
6
+ process.on('SIGINT', () => {
7
+ exitAltScreen();
8
+ process.exit(130);
9
+ });
10
+ process.on('uncaughtException', (e) => {
11
+ try {
12
+ process.stderr.write(`\n[uncaught] ${e instanceof Error ? e.stack || e.message : String(e)}\n`);
13
+ }
14
+ catch {
15
+ // 忽略
16
+ }
17
+ exitAltScreen();
18
+ process.exit(1);
19
+ });
20
+ process.on('unhandledRejection', (e) => {
21
+ try {
22
+ process.stderr.write(`\n[unhandled] ${e instanceof Error ? e.stack || e.message : String(e)}\n`);
23
+ }
24
+ catch {
25
+ // 忽略
26
+ }
27
+ exitAltScreen();
28
+ process.exit(1);
29
+ });
30
+ /**
31
+ * 入口:装配并启动 REPL。支持 --resume <id> 续接历史会话(裸 --resume 列出会话)。
32
+ * `mocode config` 走首跑配置向导(动态加载 commands/config,不引入 REPL/config 图,故缺配置也能跑)。
33
+ * REPL / session 用动态 import 按需加载——只在真正启动时才拉入 config 依赖图。
34
+ * 显式 process.exit(0)——OpenAI 客户端的 keep-alive 会卡住事件循环。
35
+ */
36
+ async function main() {
37
+ const args = process.argv.slice(2);
38
+ // 首跑配置向导:写 ~/.mocode/config。独立模块,不触发 config 校验,故零配置也能跑。
39
+ if (args[0] === 'config') {
40
+ const { runConfigWizard } = await import('./commands/config.js');
41
+ await runConfigWizard();
42
+ process.exit(0);
43
+ }
44
+ const i = args.indexOf('--resume');
45
+ if (i !== -1) {
46
+ const { listSessions, loadSession } = await import('./session/index.js');
47
+ const id = args[i + 1];
48
+ if (!id) {
49
+ // 裸 --resume:列出会话后退出
50
+ const sessions = listSessions();
51
+ if (sessions.length === 0) {
52
+ console.log('(没有已保存的会话)');
53
+ }
54
+ else {
55
+ for (const s of sessions) {
56
+ console.log(`${s.id} ${s.firstUser || '(无)'} ${s.model}`);
57
+ }
58
+ }
59
+ process.exit(0);
60
+ }
61
+ const loaded = loadSession(id);
62
+ if (!loaded || !loaded.history.length) {
63
+ console.error(`[session] 找不到会话 ${id}(用 --resume 查看列表)`);
64
+ process.exit(1);
65
+ }
66
+ const { startRepl } = await import('./repl/index.js');
67
+ await startRepl(loaded.history, loaded.id);
68
+ }
69
+ else {
70
+ const { startRepl } = await import('./repl/index.js');
71
+ await startRepl();
72
+ }
73
+ process.exit(0);
74
+ }
75
+ main();