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 +21 -0
- package/README.md +161 -0
- package/bin/mocode.js +4 -0
- package/dist/agent/index.js +156 -0
- package/dist/commands/config.js +61 -0
- package/dist/config/index.js +80 -0
- package/dist/index.js +75 -0
- package/dist/llm/index.js +162 -0
- package/dist/repl/index.js +530 -0
- package/dist/rollback/index.js +219 -0
- package/dist/session/compact.js +277 -0
- package/dist/session/index.js +8 -0
- package/dist/session/persist.js +109 -0
- package/dist/skills/discover.js +139 -0
- package/dist/skills/index.js +50 -0
- package/dist/tools/builtins/edit-file.js +34 -0
- package/dist/tools/builtins/glob.js +30 -0
- package/dist/tools/builtins/grep.js +62 -0
- package/dist/tools/builtins/index.js +24 -0
- package/dist/tools/builtins/read-file.js +34 -0
- package/dist/tools/builtins/run-command.js +49 -0
- package/dist/tools/builtins/use-skill.js +27 -0
- package/dist/tools/builtins/web-fetch.js +125 -0
- package/dist/tools/builtins/web-search.js +132 -0
- package/dist/tools/builtins/write-file.js +23 -0
- package/dist/tools/constants.js +11 -0
- package/dist/tools/registry.js +32 -0
- package/dist/tools/types.js +1 -0
- package/dist/ui/content.js +98 -0
- package/dist/ui/layout.js +609 -0
- package/dist/ui/prompt.js +414 -0
- package/dist/ui/render.js +204 -0
- package/dist/ui/spinner.js +63 -0
- package/dist/ui/theme.js +21 -0
- package/package.json +39 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { MAX_OUTPUT } from '../constants.js';
|
|
2
|
+
import { config } from '../../config/index.js';
|
|
3
|
+
const SEARCH_TIMEOUT_MS = 30000;
|
|
4
|
+
/** 每条结果的 content 截断长度(字符),避免单条正文占满整个输出。 */
|
|
5
|
+
const MAX_CONTENT_CHARS = 800;
|
|
6
|
+
// ---------- web_search ----------
|
|
7
|
+
export const webSearchTool = {
|
|
8
|
+
name: 'web_search',
|
|
9
|
+
description: '联网搜索(AnySearch)。返回每条结果的标题/URL/摘要/正文,用于查模型训练数据之外的最新信息(新版本发布、新闻、实时数据、最新 API 文档等)。可选 tag 切换子域:general.general(默认通用网页)/ code.doc(开发文档,须配 params.library)/ code.snippet(代码片段)。',
|
|
10
|
+
parameters: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
query: { type: 'string', description: '搜索查询词' },
|
|
14
|
+
max_results: { type: 'integer', description: '返回条数,1-20,默认 10' },
|
|
15
|
+
tag: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: '子域能力标签,如 general.general / code.doc / code.snippet;不传走通用搜索',
|
|
18
|
+
},
|
|
19
|
+
language: { type: 'string', description: '偏好语言,如 zh-CN / en,默认 zh-CN' },
|
|
20
|
+
params: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
description: '特定 tag 的扩展参数,如 code.doc 的 {"library":"golang"};通用搜索不需要',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
required: ['query'],
|
|
26
|
+
},
|
|
27
|
+
async execute(args) {
|
|
28
|
+
const query = String(args.query ?? '').trim();
|
|
29
|
+
if (!query)
|
|
30
|
+
return '错误:query 不能为空。';
|
|
31
|
+
const body = { query, format: 'json', language: 'zh-CN' };
|
|
32
|
+
const maxResults = Number(args.max_results);
|
|
33
|
+
if (Number.isFinite(maxResults)) {
|
|
34
|
+
body.max_results = Math.min(20, Math.max(1, Math.trunc(maxResults)));
|
|
35
|
+
}
|
|
36
|
+
if (args.tag)
|
|
37
|
+
body.tag = String(args.tag);
|
|
38
|
+
if (args.language)
|
|
39
|
+
body.language = String(args.language);
|
|
40
|
+
if (args.params &&
|
|
41
|
+
typeof args.params === 'object' &&
|
|
42
|
+
!Array.isArray(args.params)) {
|
|
43
|
+
body.params = args.params;
|
|
44
|
+
}
|
|
45
|
+
const base = config.searchBaseUrl.replace(/\/+$/, '');
|
|
46
|
+
const url = `${base}/v1/search`;
|
|
47
|
+
const headers = {
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
};
|
|
50
|
+
// 无 key 走匿名(按 IP 限流 + 每日免费额度);有 key 用付费额度。
|
|
51
|
+
if (config.searchApiKey) {
|
|
52
|
+
headers['Authorization'] = `Bearer ${config.searchApiKey}`;
|
|
53
|
+
}
|
|
54
|
+
const ctrl = new AbortController();
|
|
55
|
+
const timer = setTimeout(() => ctrl.abort(), SEARCH_TIMEOUT_MS);
|
|
56
|
+
try {
|
|
57
|
+
const resp = await fetch(url, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers,
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
signal: ctrl.signal,
|
|
62
|
+
});
|
|
63
|
+
const text = await resp.text();
|
|
64
|
+
let data;
|
|
65
|
+
try {
|
|
66
|
+
data = JSON.parse(text);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return `错误:搜索返回非 JSON(HTTP ${resp.status}): ${text.slice(0, 500)}`;
|
|
70
|
+
}
|
|
71
|
+
// AnySearch 成功返回 code===0;否则把 message/request_id 喂回 LLM。
|
|
72
|
+
if (!resp.ok || data?.code !== 0) {
|
|
73
|
+
const code = data?.code ?? resp.status;
|
|
74
|
+
const message = data?.message ?? resp.statusText ?? '未知错误';
|
|
75
|
+
const rid = data?.request_id ? ` (request_id=${data.request_id})` : '';
|
|
76
|
+
let hint = '';
|
|
77
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
78
|
+
hint = ' API Key 非法/过期;可在 .env 去掉 ANYSEARCH_API_KEY 走匿名免费额度。';
|
|
79
|
+
}
|
|
80
|
+
else if (resp.status === 402) {
|
|
81
|
+
hint = ' 额度用尽,可次日重试或去掉 key 走匿名额度。';
|
|
82
|
+
}
|
|
83
|
+
else if (resp.status === 429) {
|
|
84
|
+
hint = ' 触发限流,请稍后重试。';
|
|
85
|
+
}
|
|
86
|
+
return `错误:搜索失败 [${code}] ${message}${rid}${hint}`;
|
|
87
|
+
}
|
|
88
|
+
const results = data?.data?.results;
|
|
89
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
90
|
+
return `无搜索结果(query="${query}")。`;
|
|
91
|
+
}
|
|
92
|
+
const lines = [];
|
|
93
|
+
results.forEach((r, i) => {
|
|
94
|
+
const title = String(r?.title ?? '').trim();
|
|
95
|
+
const u = String(r?.url ?? '').trim();
|
|
96
|
+
const snippet = String(r?.snippet ?? '').trim();
|
|
97
|
+
const content = String(r?.content ?? '').trim();
|
|
98
|
+
lines.push(`[${i + 1}] ${title}`);
|
|
99
|
+
if (u)
|
|
100
|
+
lines.push(` ${u}`);
|
|
101
|
+
if (snippet)
|
|
102
|
+
lines.push(` ${snippet}`);
|
|
103
|
+
if (content) {
|
|
104
|
+
const c = content.length > MAX_CONTENT_CHARS
|
|
105
|
+
? content.slice(0, MAX_CONTENT_CHARS) +
|
|
106
|
+
` …(共 ${content.length} 字符)`
|
|
107
|
+
: content;
|
|
108
|
+
lines.push(` ${c}`);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
const meta = data?.data?.metadata;
|
|
112
|
+
if (meta) {
|
|
113
|
+
lines.push(`(共 ${meta.total_results ?? results.length} 条,耗时 ${meta.search_time_ms ?? '?'}ms)`);
|
|
114
|
+
}
|
|
115
|
+
let out = lines.join('\n');
|
|
116
|
+
if (out.length > MAX_OUTPUT) {
|
|
117
|
+
out = out.slice(0, MAX_OUTPUT) + '\n...(结果已截断)';
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
if (ctrl.signal.aborted) {
|
|
123
|
+
return `错误:搜索超时(${SEARCH_TIMEOUT_MS}ms)。`;
|
|
124
|
+
}
|
|
125
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
126
|
+
return `错误:联网搜索请求失败: ${msg}`;
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
// ---------- write_file ----------
|
|
4
|
+
export const writeFileTool = {
|
|
5
|
+
name: 'write_file',
|
|
6
|
+
description: '创建或覆盖文件,自动创建父目录。',
|
|
7
|
+
parameters: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
path: { type: 'string', description: '文件路径' },
|
|
11
|
+
content: { type: 'string', description: '完整文件内容' },
|
|
12
|
+
},
|
|
13
|
+
required: ['path', 'content'],
|
|
14
|
+
},
|
|
15
|
+
async execute(args) {
|
|
16
|
+
const path = String(args.path);
|
|
17
|
+
const content = String(args.content);
|
|
18
|
+
const full = resolve(path);
|
|
19
|
+
await mkdir(dirname(full), { recursive: true });
|
|
20
|
+
await writeFile(full, content, 'utf8');
|
|
21
|
+
return `已写入 ${path} (${content.length} 字符)`;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** 工具共享的截断 / 上限 / 忽略规则。 */
|
|
2
|
+
export const MAX_FILE_LINES = 2000;
|
|
3
|
+
export const MAX_OUTPUT = 20000;
|
|
4
|
+
export const MAX_RESULTS = 100;
|
|
5
|
+
/** 进 history 的单条工具结果上限(字符)。push-time 第一层裁剪,保 head + 标记 + tail。 */
|
|
6
|
+
export const MAX_HISTORY_RESULT = 8000;
|
|
7
|
+
/** use_skill 结果(SKILL.md 正文)的放宽上限:指令须完整,中截会破坏语义。 */
|
|
8
|
+
export const MAX_SKILL_RESULT = 64000;
|
|
9
|
+
/** 微压缩时旧工具结果截到的存根长度(字符)。 */
|
|
10
|
+
export const MAX_OLD_TOOL_STUB = 600;
|
|
11
|
+
export const IGNORE = ['**/node_modules/**', '**/.git/**'];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { builtinTools } from './builtins/index.js';
|
|
2
|
+
import { recordMutation } from '../rollback/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* 工具注册表。当前 = 内置工具;
|
|
5
|
+
* 未来可在此合并 MCP 工具、用户自定义工具等(见 src/mcp/)。
|
|
6
|
+
*/
|
|
7
|
+
export const tools = builtinTools;
|
|
8
|
+
/** 按名调度工具,统一 try/catch + JSON 解析,返回字符串而非抛错。 */
|
|
9
|
+
export async function executeTool(name, argsRaw) {
|
|
10
|
+
const tool = tools.find((t) => t.name === name);
|
|
11
|
+
if (!tool)
|
|
12
|
+
return `错误:未知工具 "${name}"`;
|
|
13
|
+
let args;
|
|
14
|
+
try {
|
|
15
|
+
args = argsRaw.trim() ? JSON.parse(argsRaw) : {};
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return `错误:工具 ${name} 的 arguments 不是合法 JSON: ${argsRaw}`;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
// 撤销回滚用:write_file/edit_file 改动前记 before 快照(回滚时恢复到轮末状态)。
|
|
22
|
+
if ((name === 'write_file' || name === 'edit_file') &&
|
|
23
|
+
typeof args.path === 'string' &&
|
|
24
|
+
args.path) {
|
|
25
|
+
recordMutation(args.path);
|
|
26
|
+
}
|
|
27
|
+
return await tool.execute(args);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
return `错误:工具 ${name} 执行失败: ${e instanceof Error ? e.message : String(e)}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内容物理行缓冲(Phase 2 滚动回滚用)。
|
|
3
|
+
*
|
|
4
|
+
* 存折行后的物理行,每行自洽带色,供 layout.repaintViewport 单行直出、无需继承 SGR 状态。
|
|
5
|
+
* contentWrite 逐字符喂入(feedChar / feedSgr / breakRow);eraseSegmentBack 用 popRows 同步删段。
|
|
6
|
+
*
|
|
7
|
+
* **SGR 自洽模型**:每行存 `rowStartSgr + curRaw + \x1B[0m`:
|
|
8
|
+
* - rowStartSgr:本行起始时继承的活跃 SGR 前缀(来自上一行末状态);
|
|
9
|
+
* - curRaw:本行原始字节——可见字符 + 行内出现的 SGR 码(保留行内开合,如 `${dim}ab${reset}cd`);
|
|
10
|
+
* - 末尾 reset:使单行渲染不依赖后续。
|
|
11
|
+
* 这样行内 SGR 变化与跨行继承都正确——简单"前缀+可见文本"模型会丢行内开合的色。
|
|
12
|
+
*
|
|
13
|
+
* **当前行**:任何 feedChar/feedSgr/breakRow 后 hasCurrent=true;breakRow(\n/折行)提交本行并开新空行
|
|
14
|
+
* (hasCurrent 仍 true,代表光标所在的新空行)。这让 offset=0 的 viewport 重画与实时屏严格一致
|
|
15
|
+
* (含 \n 后的光标空行),避免回尾时内容错位。
|
|
16
|
+
*
|
|
17
|
+
* 主题(theme.ts)只用单参数 SGR 码,故 curSgr 状态机简单:遇 \x1B[0m / \x1B[m 清空,其余累加。
|
|
18
|
+
* 上限 20000 行防无限增长(超限丢头部,批量 trim)。
|
|
19
|
+
*/
|
|
20
|
+
const MAX_ROWS = 20000;
|
|
21
|
+
let rows = [];
|
|
22
|
+
let curSgr = ''; // 当前活跃 SGR 状态(行内演进;行末值 = 下行继承值)
|
|
23
|
+
let rowStartSgr = ''; // 当前行起始继承的 SGR 前缀
|
|
24
|
+
let curRaw = ''; // 当前行原始字节(可见 + 行内 SGR 码)
|
|
25
|
+
let hasCurrent = false; // 是否有当前行(feed/breakRow 后;新空行也算)
|
|
26
|
+
let segMark = null; // 段起点快照(供 eraseSegment 恢复)
|
|
27
|
+
export function reset() {
|
|
28
|
+
rows = [];
|
|
29
|
+
curSgr = '';
|
|
30
|
+
rowStartSgr = '';
|
|
31
|
+
curRaw = '';
|
|
32
|
+
hasCurrent = false;
|
|
33
|
+
segMark = null;
|
|
34
|
+
}
|
|
35
|
+
/** 喂一个 SGR 码(\\x1B[...m):入 curRaw,并更新活跃状态(遇 reset 清空)。 */
|
|
36
|
+
export function feedSgr(code) {
|
|
37
|
+
curRaw += code;
|
|
38
|
+
if (code === '\x1B[0m' || code === '\x1B[m')
|
|
39
|
+
curSgr = '';
|
|
40
|
+
else
|
|
41
|
+
curSgr += code;
|
|
42
|
+
hasCurrent = true;
|
|
43
|
+
}
|
|
44
|
+
/** 喂一个可见字符(已按码点取出)。 */
|
|
45
|
+
export function feedChar(ch) {
|
|
46
|
+
curRaw += ch;
|
|
47
|
+
hasCurrent = true;
|
|
48
|
+
}
|
|
49
|
+
/** 提交当前行为自洽物理行,开新空行(SGR 状态继承到下行)。在 \\n / 折行 / tab 换行时调。 */
|
|
50
|
+
export function breakRow() {
|
|
51
|
+
rows.push(rowStartSgr + curRaw + '\x1B[0m');
|
|
52
|
+
if (rows.length > MAX_ROWS + 512)
|
|
53
|
+
rows.splice(0, rows.length - MAX_ROWS);
|
|
54
|
+
curRaw = '';
|
|
55
|
+
rowStartSgr = curSgr; // 下行继承本行末状态
|
|
56
|
+
hasCurrent = true; // 新空行即当前行
|
|
57
|
+
}
|
|
58
|
+
/** 标记段(思考段)起点:快照当前缓冲状态,供 eraseSegment 恢复。 */
|
|
59
|
+
export function beginSegment() {
|
|
60
|
+
segMark = { rowIdx: rows.length, rowStartSgr, curSgr, curRaw, hasCurrent };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 删段并恢复到 beginSegment 时的状态;返回段物理行数(= segLines + 末行部分,与屏幕擦除行数一致)。
|
|
64
|
+
* 段足迹 = 自 mark 后提交的行(committedErased)+ 当前行若有内容(currentErased)。
|
|
65
|
+
* 恢复后当前行 = 段起点的那一行(折叠标题将写入此处),保证滚动回看只看到标题、不看到原文。
|
|
66
|
+
*/
|
|
67
|
+
export function eraseSegment() {
|
|
68
|
+
if (!segMark)
|
|
69
|
+
return 0;
|
|
70
|
+
const committedErased = rows.length - segMark.rowIdx;
|
|
71
|
+
const currentErased = hasCurrent && curRaw.length > 0 ? 1 : 0;
|
|
72
|
+
rows.length = segMark.rowIdx;
|
|
73
|
+
rowStartSgr = segMark.rowStartSgr;
|
|
74
|
+
curSgr = segMark.curSgr;
|
|
75
|
+
curRaw = segMark.curRaw;
|
|
76
|
+
hasCurrent = segMark.hasCurrent;
|
|
77
|
+
segMark = null;
|
|
78
|
+
return committedErased + currentErased;
|
|
79
|
+
}
|
|
80
|
+
/** 快照(已提交行 + 当前行若有)。viewport 取行窗用。 */
|
|
81
|
+
function snapshot() {
|
|
82
|
+
return hasCurrent
|
|
83
|
+
? [...rows, rowStartSgr + curRaw + '\x1B[0m']
|
|
84
|
+
: rows;
|
|
85
|
+
}
|
|
86
|
+
/** 距尾 offset 行、取 count 行的窗口(映射到屏 1..count)。offset=0 即尾窗。 */
|
|
87
|
+
export function sliceFromEnd(offset, count) {
|
|
88
|
+
const all = snapshot();
|
|
89
|
+
const end = all.length - offset;
|
|
90
|
+
if (end <= 0)
|
|
91
|
+
return [];
|
|
92
|
+
const start = Math.max(0, end - count);
|
|
93
|
+
return all.slice(start, end);
|
|
94
|
+
}
|
|
95
|
+
/** 快照总行数(含当前行),供 scrollBy 钳位。 */
|
|
96
|
+
export function totalRows() {
|
|
97
|
+
return rows.length + (hasCurrent ? 1 : 0);
|
|
98
|
+
}
|