mingdao-harness 0.1.54
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 +246 -0
- package/assets/tokenizer-data.json.gz +0 -0
- package/docs/ARCHITECTURE.md +108 -0
- package/docs/CONFIG.md +257 -0
- package/docs/DESKTOP-EVALUATION.md +48 -0
- package/docs/PROVIDERS.md +98 -0
- package/docs/QA-REPORT.md +333 -0
- package/install.bat +8 -0
- package/install.ps1 +57 -0
- package/install.sh +154 -0
- package/package.json +61 -0
- package/skills/api-design/SKILL.md +21 -0
- package/skills/code-review/SKILL.md +31 -0
- package/skills/debugging/SKILL.md +21 -0
- package/skills/docker/SKILL.md +27 -0
- package/skills/docx/SKILL.md +28 -0
- package/skills/frontend-design/SKILL.md +31 -0
- package/skills/git-commit/SKILL.md +32 -0
- package/skills/pdf/SKILL.md +30 -0
- package/skills/pptx/SKILL.md +24 -0
- package/skills/refactoring/SKILL.md +24 -0
- package/skills/release-checklist/SKILL.md +20 -0
- package/skills/testing/SKILL.md +30 -0
- package/skills/webapp-testing/SKILL.md +27 -0
- package/skills/xlsx/SKILL.md +28 -0
- package/src/agent.js +472 -0
- package/src/audit.js +68 -0
- package/src/autostart.js +74 -0
- package/src/batch.js +182 -0
- package/src/cachestats.js +215 -0
- package/src/cli.js +1099 -0
- package/src/commands/key.js +75 -0
- package/src/commands/schedule.js +176 -0
- package/src/commands/skill.js +168 -0
- package/src/commands/sync.js +222 -0
- package/src/commands/update.js +157 -0
- package/src/commands/workspace.js +109 -0
- package/src/compact.js +112 -0
- package/src/config.js +134 -0
- package/src/context.js +86 -0
- package/src/cost-guard.js +58 -0
- package/src/credentials.js +69 -0
- package/src/hooks.js +123 -0
- package/src/index.js +42 -0
- package/src/mcp-presets.js +78 -0
- package/src/mcp.js +284 -0
- package/src/memory.js +242 -0
- package/src/model-discovery.js +153 -0
- package/src/models.js +186 -0
- package/src/notify.js +38 -0
- package/src/permissions.js +83 -0
- package/src/pricing.js +156 -0
- package/src/prompts.js +58 -0
- package/src/providers/index.js +135 -0
- package/src/providers/openai-compatible.js +171 -0
- package/src/routing.js +126 -0
- package/src/schedule.js +434 -0
- package/src/session-index.js +122 -0
- package/src/session.js +131 -0
- package/src/skill-lib.js +350 -0
- package/src/skill-registry.js +165 -0
- package/src/skills.js +135 -0
- package/src/sync-server.js +564 -0
- package/src/sync.js +479 -0
- package/src/tasks.js +126 -0
- package/src/titles.js +75 -0
- package/src/tokenizer.js +287 -0
- package/src/tools/bash.js +165 -0
- package/src/tools/fs-tools.js +346 -0
- package/src/tools/index.js +287 -0
- package/src/ui.js +643 -0
- package/src/update.js +222 -0
- package/src/web/attachments.js +49 -0
- package/src/web/index.html +993 -0
- package/src/web/server.js +1173 -0
- package/src/web/web-io.js +107 -0
- package/src/workspace.js +157 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// 命令族:mingdao update / rollback / batch / cost / audit(自 cli.js 拆出,评估 P0-1 拆包)
|
|
2
|
+
// 每个 handler 返回 true 表示已处理(主流程 return);false 表示按普通提问继续(子命令劫持防护)。
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { loadConfig } from '../config.js';
|
|
6
|
+
|
|
7
|
+
export async function handleUpdateFamily(cmd, args) {
|
|
8
|
+
// 自更新与回滚(git 安装形态)。
|
|
9
|
+
// 评估 P3-1 子命令劫持防护:参数不合法时回退为普通提问(如 "mingdao update the docs" 是提问而非更新命令)
|
|
10
|
+
if (cmd === 'update' && args.every((a) => a === '--check')) {
|
|
11
|
+
const { updateCheck, mingdaoUpdate } = await import('../update.js');
|
|
12
|
+
const checkOnly = args.includes('--check');
|
|
13
|
+
const r = await (checkOnly ? updateCheck() : mingdaoUpdate());
|
|
14
|
+
console.log(r.lines?.join('\n') || '');
|
|
15
|
+
process.exitCode = r.ok ? 0 : 1;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
if (cmd === 'rollback' && args.length === 0) {
|
|
19
|
+
const { mingdaoRollback } = await import('../update.js');
|
|
20
|
+
const r = mingdaoRollback();
|
|
21
|
+
console.log(r.lines?.join('\n') || '');
|
|
22
|
+
process.exitCode = r.ok ? 0 : 1;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Batch API 半价批处理(A1):mingdao batch <问题文件|-> [--model 名] [--max-tokens n]
|
|
27
|
+
if (cmd === 'batch') {
|
|
28
|
+
const { runBatch } = await import('../batch.js');
|
|
29
|
+
const cfgB = loadConfig();
|
|
30
|
+
if (!cfgB) {
|
|
31
|
+
console.log('[错误] 未初始化配置:请先运行 mingdao init');
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
let model = cfgB.model || 'deepseek-v4-flash';
|
|
36
|
+
let maxTokens = 4096;
|
|
37
|
+
let srcFile = null;
|
|
38
|
+
for (let i = 0; i < args.length; i++) {
|
|
39
|
+
const a = args[i];
|
|
40
|
+
if (a === '--model') model = args[++i];
|
|
41
|
+
else if (a === '--max-tokens') maxTokens = Number(args[++i]) || 4096;
|
|
42
|
+
else if (a === '--out') { i += 1; /* 输出默认落工作目录 */ }
|
|
43
|
+
else if (!srcFile) srcFile = a;
|
|
44
|
+
}
|
|
45
|
+
if (!srcFile) {
|
|
46
|
+
console.log('用法:mingdao batch <问题文件|-> 每行一个问题(--model 名 --max-tokens n)');
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
let questions = [];
|
|
51
|
+
try {
|
|
52
|
+
const raw = srcFile === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(srcFile, 'utf8');
|
|
53
|
+
questions = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 1000);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.log('[错误] 读取问题文件失败:' + (err?.message || err));
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
if (!questions.length) {
|
|
60
|
+
console.log('[错误] 问题文件为空');
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
console.log(`Batch 半价批处理:${questions.length} 个问题 · 模型 ${model}(价格 ×${0.5},结果异步返回,最长 24h)`);
|
|
65
|
+
const ac = new AbortController();
|
|
66
|
+
process.on('SIGINT', () => ac.abort());
|
|
67
|
+
const r = await runBatch({
|
|
68
|
+
cfg: cfgB,
|
|
69
|
+
model,
|
|
70
|
+
questions,
|
|
71
|
+
workingDir: process.cwd(),
|
|
72
|
+
maxTokens,
|
|
73
|
+
signal: ac.signal,
|
|
74
|
+
onStatus: (st) => console.log(' ' + st),
|
|
75
|
+
});
|
|
76
|
+
process.removeAllListeners('SIGINT');
|
|
77
|
+
if (r.error) {
|
|
78
|
+
console.log('[错误] ' + r.error);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
console.log(`✓ 完成 ${r.results.length} 条 · ↑${r.usage.prompt_tokens} ↓${r.usage.completion_tokens} tokens · 费用 ≈¥${r.cost.toFixed(5)}(已按半价)`);
|
|
83
|
+
console.log(` 结果文件:${r.outputFile}`);
|
|
84
|
+
console.log(` 任务 ID:${r.batchId}(结果已计入 /cost 分账)`);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 费用报告(/cost 月度导出):mingdao cost [report [YYYY-MM|all]]
|
|
89
|
+
if (cmd === 'cost') {
|
|
90
|
+
const valid = args.length === 0 || (args.length >= 1 && args[0] === 'report' && args.length <= 2 && (!args[1] || args[1] === 'all' || /^\d{4}-\d{2}$/.test(args[1])));
|
|
91
|
+
if (!valid) return false; // 参数不合法 → 按提问处理
|
|
92
|
+
const { costMonthlyReport } = await import('../cachestats.js');
|
|
93
|
+
const months = args.length === 0 ? null : args[1] && args[1] !== 'all' ? args[1] : null;
|
|
94
|
+
const rows = costMonthlyReport(months);
|
|
95
|
+
if (!rows.length) {
|
|
96
|
+
console.log(args.length === 0 ? '暂无费用数据(对话若干轮后自动累积)。' : `没有 ${months || '任何'} 月份的记录。`);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (args.length === 0) {
|
|
100
|
+
const total = rows.reduce((s, r) => s + r.cost, 0);
|
|
101
|
+
console.log(`累计费用 ≈¥${total.toFixed(5)}(${rows.length} 个月份)· 最近:`);
|
|
102
|
+
for (const r of rows.slice(-3)) {
|
|
103
|
+
console.log(` ${r.month}:≈¥${r.cost.toFixed(5)}${r.rate != null ? ` · 命中 ${(r.rate * 100).toFixed(0)}%` : ''}`);
|
|
104
|
+
}
|
|
105
|
+
console.log('月度报告导出:mingdao cost report [YYYY-MM|all]');
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
const bar = (cost, max) => '█'.repeat(Math.max(1, Math.round((cost / max) * 20)));
|
|
109
|
+
const md = [];
|
|
110
|
+
for (const r of rows) {
|
|
111
|
+
const maxDay = Math.max(...r.days.map((d) => d.cost), 1e-9);
|
|
112
|
+
md.push(`# MingDao 费用报告 ${r.month}`);
|
|
113
|
+
md.push('');
|
|
114
|
+
md.push(`- 实际费用 ≈¥${r.cost.toFixed(5)}${r.batchCost > 0 ? `(含 Batch 半价任务 ≈¥${r.batchCost.toFixed(5)})` : ''}`);
|
|
115
|
+
md.push(`- 相比全未命中已节省 ≈¥${r.saved.toFixed(5)}${r.rate != null ? ` · 缓存命中率 ${(r.rate * 100).toFixed(0)}%` : ''}`);
|
|
116
|
+
md.push(`- Tokens:↑${r.prompt} / ↓${r.completion}`);
|
|
117
|
+
md.push('');
|
|
118
|
+
md.push('## 按模型分账');
|
|
119
|
+
md.push('');
|
|
120
|
+
md.push('| 模型 | 轮次 | ↑prompt | ↓completion | 费用 |');
|
|
121
|
+
md.push('| --- | --- | --- | --- | --- |');
|
|
122
|
+
for (const m of r.models) md.push(`| ${m.model} | ${m.turns} | ${m.prompt} | ${m.completion} | ≈¥${m.cost.toFixed(5)} |`);
|
|
123
|
+
md.push('');
|
|
124
|
+
md.push('## 每日费用');
|
|
125
|
+
md.push('');
|
|
126
|
+
md.push('```');
|
|
127
|
+
for (const d of r.days) md.push(`${d.day} ${bar(d.cost, maxDay)} ¥${d.cost.toFixed(5)}`);
|
|
128
|
+
md.push('```');
|
|
129
|
+
md.push('');
|
|
130
|
+
}
|
|
131
|
+
const outFile = path.join(process.cwd(), `mingdao-cost-report-${rows.length === 1 ? rows[0].month : 'all'}.md`);
|
|
132
|
+
fs.writeFileSync(outFile, md.join('\n') + '\n');
|
|
133
|
+
console.log(`✓ 已导出 ${rows.length} 个月份报告 → ${outFile}`);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 审计日志查看(P3-5):mingdao audit [数量](仅裸命令或单个数字;其余按提问处理)
|
|
138
|
+
if (cmd === 'audit' && (args.length === 0 || (args.length === 1 && /^\d+$/.test(args[0])))) {
|
|
139
|
+
const { listAudit, auditFile } = await import('../audit.js');
|
|
140
|
+
const n = Number(args[0]) || 20;
|
|
141
|
+
const rows = listAudit(n);
|
|
142
|
+
if (!rows.length) {
|
|
143
|
+
console.log(`暂无审计记录(工具调用将自动记录到 ${auditFile()})。`);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
console.log(`审计日志(最近 ${rows.length} 条):${auditFile()}`);
|
|
147
|
+
for (const r of rows) {
|
|
148
|
+
const when = new Date(r.at).toISOString().slice(0, 19).replace('T', ' ');
|
|
149
|
+
const status = r.denied ? `✖拒绝(${r.reason || ''})` : r.ok ? '✓' : '✖错误';
|
|
150
|
+
const extra = !r.denied && r.timedOut ? '(超时)' : !r.denied && r.exitCode !== null && r.exitCode !== undefined ? `(exit ${r.exitCode})` : '';
|
|
151
|
+
const sess = r.session ? ` [会话 ${String(r.session).slice(0, 28)}]` : '';
|
|
152
|
+
console.log(` ${when} ${status} ${r.tool} ${(r.args || '').slice(0, 80)} ${extra}${sess}`);
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// 命令族:mingdao workspace / mcp preset(自 cli.js 拆出,评估 P0-1 拆包)
|
|
2
|
+
import { addWorkspace, listWorkspaces, removeWorkspace, workspacePath, touchWorkspace } from '../workspace.js';
|
|
3
|
+
import { presetList, buildPreset } from '../mcp-presets.js';
|
|
4
|
+
import { ensureHome, loadConfig, saveConfig } from '../config.js';
|
|
5
|
+
|
|
6
|
+
export async function handleWorkspace(cmd, args) {
|
|
7
|
+
const sub = args[0] || 'list';
|
|
8
|
+
const name = args[1];
|
|
9
|
+
if (sub === 'add') {
|
|
10
|
+
if (!name) {
|
|
11
|
+
console.log('用法:mingdao workspace add <名称> [目录]');
|
|
12
|
+
process.exitCode = 1;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
const r = addWorkspace(name, args[2]);
|
|
16
|
+
if (r.error) {
|
|
17
|
+
console.log('[错误] ' + r.error);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
console.log(`✓ 已登记工作空间 ${r.name} → ${r.dir}`);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
if (sub === 'remove') {
|
|
25
|
+
if (!name) {
|
|
26
|
+
console.log('用法:mingdao workspace remove <名称>');
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
console.log(removeWorkspace(name) ? `✓ 已移除 ${name}` : '未找到该工作空间');
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (sub === 'path') {
|
|
34
|
+
if (!name) {
|
|
35
|
+
console.log('用法:mingdao workspace path <名称>');
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
const p = workspacePath(name);
|
|
40
|
+
if (p) console.log(p);
|
|
41
|
+
else {
|
|
42
|
+
console.error('未找到该工作空间(mingdao workspace list 查看)');
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (sub === 'use') {
|
|
48
|
+
if (!name) {
|
|
49
|
+
console.log('用法:mingdao workspace use <名称>');
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
const p = workspacePath(name);
|
|
54
|
+
if (!p) {
|
|
55
|
+
console.log('未找到该工作空间(mingdao workspace list 查看)');
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
touchWorkspace(name);
|
|
60
|
+
console.log(`✓ 工作空间 ${name}:${p}`);
|
|
61
|
+
console.log(` 快速进入:cd "$(mingdao workspace path ${name})"(建议做成 shell 函数/别名,如 mdw() { cd "$(mingdao workspace path "$1")"; })`);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
const ws = listWorkspaces();
|
|
65
|
+
if (!ws.length) {
|
|
66
|
+
console.log('暂无工作空间。添加:mingdao workspace add <名称> [目录]');
|
|
67
|
+
} else {
|
|
68
|
+
console.log('工作空间(最近使用优先)');
|
|
69
|
+
for (const w of ws) console.log(` ${w.name.padEnd(16)} ${w.dir}`);
|
|
70
|
+
console.log('\n 进入:cd "$(mingdao workspace path <名称>)"');
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// MCP 预设:mingdao mcp preset list|add <名称> [参数]
|
|
76
|
+
export async function handleMcp(cmd, args) {
|
|
77
|
+
if (args[0] !== 'preset') return false;
|
|
78
|
+
const home0 = ensureHome();
|
|
79
|
+
if (args[1] === 'add') {
|
|
80
|
+
const name = args[2];
|
|
81
|
+
if (!name) {
|
|
82
|
+
console.log('用法:mingdao mcp preset add <名称> [参数](列表见 mingdao mcp preset list)');
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
const r = buildPreset(name, args[3], process.cwd());
|
|
87
|
+
if (r.error) {
|
|
88
|
+
console.log('[错误] ' + r.error);
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
const cfg0 = loadConfig();
|
|
93
|
+
if (!cfg0) {
|
|
94
|
+
console.log('未初始化配置,请先运行 mingdao init');
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
cfg0.mcpServers = cfg0.mcpServers || {};
|
|
99
|
+
cfg0.mcpServers[name] = r.config;
|
|
100
|
+
saveConfig(cfg0);
|
|
101
|
+
console.log(`✓ 已添加 MCP 服务器 ${name}(重启 mingdao web 后生效;会话内 /mcp 查看状态)`);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
console.log('MCP 生态预设(mingdao mcp preset add <名称> 一键接入):');
|
|
105
|
+
for (const p of presetList()) {
|
|
106
|
+
console.log(` ${p.name.padEnd(20)} ${p.label}${p.argLabel ? `(参数:${p.argLabel})` : ''}`);
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
package/src/compact.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 上下文自动压缩(P3-1,复审建议第一优先):
|
|
2
|
+
// 长会话超出预算、静默裁剪即将丢弃早期段落时,先用 executor 模型把被裁段落压成摘要,
|
|
3
|
+
// 以单条 user 消息注入(替代「失忆」);被裁段落不足最小阈值时仍走普通裁剪(省一次模型调用)。
|
|
4
|
+
// 摘要失败绝不阻塞会话:任何异常回退普通裁剪。
|
|
5
|
+
//
|
|
6
|
+
// 触发条件:总 token 数 > 预算,且被裁段落(不含 system)≥ MIN_DROP_MESSAGES 条、≥ MIN_DROP_TOKENS tokens。
|
|
7
|
+
// 注入格式:system → 摘要(user) → 保留段(保留段头部经 cleanToolPairing 清洗孤儿 tool 消息)。
|
|
8
|
+
|
|
9
|
+
import { messageTokens, cleanToolPairing, clampText } from './context.js';
|
|
10
|
+
|
|
11
|
+
const MIN_DROP_MESSAGES = 3; // 至少裁掉 3 条才值得压缩
|
|
12
|
+
const MIN_DROP_TOKENS = 2000; // 被裁段落 token 数低于此值不值得一次模型调用
|
|
13
|
+
// B1/B2(评估建议):达到预算 80% 即提前压缩、压到 60%——滞回缓冲带避免在预算线附近
|
|
14
|
+
// 反复触发裁剪/压缩,让摘要成为稳定前缀、后续轮次全程缓存命中;压缩触发频率也大幅下降。
|
|
15
|
+
const DEFAULT_TRIGGER_RATIO = 0.8;
|
|
16
|
+
const TARGET_RATIO = 0.6;
|
|
17
|
+
const SUMMARY_MAX_CHARS = 1600; // 摘要上限(约几百 token,远小于被裁段落)
|
|
18
|
+
const INPUT_MAX_CHARS = 30000; // 摘要输入上限(防超长工具输出撑爆摘要请求)
|
|
19
|
+
const TOOL_OUTPUT_CAP = 300; // 摘要输入中每条工具结果截断长度
|
|
20
|
+
|
|
21
|
+
const SUMMARY_SYSTEM =
|
|
22
|
+
'你是 MingDao 的会话压缩器。把对话记录压成紧凑中文摘要(≤500 字,要点列表):' +
|
|
23
|
+
'保留用户目标与关键要求、已完成的步骤与结论、修改/创建的文件、未完成事项、重要约定与决策;' +
|
|
24
|
+
'省略已完成的中间过程与细节。只输出摘要本身,不要任何解释。';
|
|
25
|
+
|
|
26
|
+
export async function summarizeConversation(provider, model, convoText) {
|
|
27
|
+
const res = await provider.chat({
|
|
28
|
+
model,
|
|
29
|
+
messages: [
|
|
30
|
+
{ role: 'system', content: SUMMARY_SYSTEM },
|
|
31
|
+
{ role: 'user', content: convoText },
|
|
32
|
+
],
|
|
33
|
+
tools: [],
|
|
34
|
+
temperature: 0.2,
|
|
35
|
+
maxTokens: 2048, // 审计 Q2:与 SUMMARY_MAX_CHARS(1600 字) 匹配,避免模型侧先截断
|
|
36
|
+
});
|
|
37
|
+
const text = String(res?.text || '').trim();
|
|
38
|
+
if (!text) return { text: null, usage: res?.usage || null };
|
|
39
|
+
return { text: text.slice(0, SUMMARY_MAX_CHARS), usage: res?.usage || null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function compactConversation({ messages, budget, count, provider, executorModel, triggerRatio }) {
|
|
43
|
+
if (!messages.length) return null;
|
|
44
|
+
// 各消息 token 与总量;低于触发线(默认预算 80%)无需压缩
|
|
45
|
+
const sizes = [];
|
|
46
|
+
let total = 0;
|
|
47
|
+
for (const m of messages) {
|
|
48
|
+
const t = messageTokens(m, count);
|
|
49
|
+
sizes.push(t);
|
|
50
|
+
total += t;
|
|
51
|
+
}
|
|
52
|
+
const trigger = Number.isFinite(Number(triggerRatio)) ? Number(triggerRatio) : DEFAULT_TRIGGER_RATIO;
|
|
53
|
+
if (total <= budget * trigger) return null;
|
|
54
|
+
// 保留边界:保留段(boundary..end)≤ budget×TARGET_RATIO(system 恒保留)
|
|
55
|
+
let keepTokens = sizes[0] ?? 0;
|
|
56
|
+
let boundary = messages.length;
|
|
57
|
+
for (let i = messages.length - 1; i >= 1; i--) {
|
|
58
|
+
if (keepTokens + sizes[i] > budget * TARGET_RATIO) {
|
|
59
|
+
boundary = i + 1;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
keepTokens += sizes[i];
|
|
63
|
+
}
|
|
64
|
+
const droppedCount = boundary - 1; // 不含 system
|
|
65
|
+
if (droppedCount < MIN_DROP_MESSAGES) return null;
|
|
66
|
+
let droppedTokens = 0;
|
|
67
|
+
for (let i = 1; i < boundary; i++) droppedTokens += sizes[i];
|
|
68
|
+
if (droppedTokens < MIN_DROP_TOKENS) return null;
|
|
69
|
+
|
|
70
|
+
// 被裁段落 → 文本(工具结果截断;tool_calls 带名称标注)
|
|
71
|
+
const convoText = messages
|
|
72
|
+
.slice(1, boundary)
|
|
73
|
+
.map((m) => {
|
|
74
|
+
if (m.role === 'tool') {
|
|
75
|
+
return `工具结果(${m.tool_call_id ?? ''}): ${clampText(String(m.content ?? ''), TOOL_OUTPUT_CAP)}`;
|
|
76
|
+
}
|
|
77
|
+
const head = m.role === 'user' ? '用户' : m.role === 'assistant' ? 'MingDao' : String(m.role);
|
|
78
|
+
const calls =
|
|
79
|
+
Array.isArray(m.tool_calls) && m.tool_calls.length
|
|
80
|
+
? ` [调用: ${m.tool_calls.map((tc) => `${tc.function?.name ?? ''}(${String(tc.function?.arguments ?? '').slice(0, 200)})`).join('; ')}]`
|
|
81
|
+
: '';
|
|
82
|
+
return `${head}: ${String(m.content ?? '')}${calls}`;
|
|
83
|
+
})
|
|
84
|
+
.join('\n')
|
|
85
|
+
.slice(0, INPUT_MAX_CHARS);
|
|
86
|
+
|
|
87
|
+
let summary = null;
|
|
88
|
+
let usage = null;
|
|
89
|
+
try {
|
|
90
|
+
const r = await summarizeConversation(provider, executorModel, convoText);
|
|
91
|
+
summary = r.text;
|
|
92
|
+
usage = r.usage;
|
|
93
|
+
} catch {
|
|
94
|
+
return null; // 摘要失败:退回普通裁剪
|
|
95
|
+
}
|
|
96
|
+
if (!summary) return null;
|
|
97
|
+
|
|
98
|
+
// 组装:system + 摘要(user) + 保留段(清洗保留段头部因裁剪而孤立的 tool 消息)
|
|
99
|
+
const kept = cleanToolPairing(messages.slice(boundary));
|
|
100
|
+
const next = [
|
|
101
|
+
messages[0],
|
|
102
|
+
{
|
|
103
|
+
role: 'user',
|
|
104
|
+
content:
|
|
105
|
+
'(以下是本会话早期内容的自动压缩摘要,细节已省略;如摘要与你的最新要求冲突,以最新指令为准)\n<conversation_summary>\n' +
|
|
106
|
+
summary +
|
|
107
|
+
'\n</conversation_summary>',
|
|
108
|
+
},
|
|
109
|
+
...kept,
|
|
110
|
+
];
|
|
111
|
+
return { messages: next, droppedCount, droppedTokens, summary, usage };
|
|
112
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// 配置管理:<mingdao-home>/config.json(默认 ~/.mingdao/config.json)与初始化向导。
|
|
2
|
+
// 优先级:命令行参数 > 环境变量 > config.json > 内置预设。
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { PROVIDERS, modelPreset, providerPreset } from './models.js';
|
|
8
|
+
import { setStoredKey, resolveApiKey, maskKey } from './credentials.js';
|
|
9
|
+
|
|
10
|
+
export function mingdaoHome() {
|
|
11
|
+
return process.env.MINGDAO_HOME || path.join(os.homedir(), '.mingdao');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ensureHome() {
|
|
15
|
+
const home = mingdaoHome();
|
|
16
|
+
for (const sub of ['', 'sessions', 'providers']) {
|
|
17
|
+
fs.mkdirSync(sub ? path.join(home, sub) : home, { recursive: true, mode: 0o700 });
|
|
18
|
+
}
|
|
19
|
+
return home;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function configPath() {
|
|
23
|
+
return path.join(mingdaoHome(), 'config.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadConfig() {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function saveConfig(cfg) {
|
|
35
|
+
ensureHome();
|
|
36
|
+
const p = configPath();
|
|
37
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
38
|
+
try {
|
|
39
|
+
fs.chmodSync(p, 0o600);
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function effectiveApiKey(cfg, providerName) {
|
|
44
|
+
return resolveApiKey(cfg, providerName, providerPreset(providerName)?.envKey);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runWizard(io) {
|
|
48
|
+
io.box('MingDao 初始化向导', ['三步完成:选服务商/模型 → 填 API Key → 选权限模式']);
|
|
49
|
+
io.print('');
|
|
50
|
+
|
|
51
|
+
const providerKeys = Object.keys(PROVIDERS);
|
|
52
|
+
const provider = await io.choose(
|
|
53
|
+
'① 选择模型服务商:',
|
|
54
|
+
providerKeys.map((k) => ({ value: k, label: `${k} — ${PROVIDERS[k].label}` }))
|
|
55
|
+
);
|
|
56
|
+
const pp = PROVIDERS[provider];
|
|
57
|
+
|
|
58
|
+
let model;
|
|
59
|
+
if (provider === 'custom') {
|
|
60
|
+
model = await io.ask('② 模型名(例如 deepseek-v4-flash):');
|
|
61
|
+
} else {
|
|
62
|
+
const options = pp.models.map((m) => {
|
|
63
|
+
const preset = modelPreset(m);
|
|
64
|
+
return { value: m, label: preset ? `${m} — ${preset.label}` : m };
|
|
65
|
+
});
|
|
66
|
+
options.push({ value: '__custom__', label: '自定义模型名(手动输入)' });
|
|
67
|
+
const choice = await io.choose('② 选择模型:', options);
|
|
68
|
+
model = choice === '__custom__' ? await io.ask('模型名:') : choice;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const envDetected = pp.envKey && process.env[pp.envKey] ? `(检测到环境变量 ${pp.envKey},回车直接使用)` : '';
|
|
72
|
+
const apiKey = await io.ask(`③ API Key${envDetected}:`, { hidden: true });
|
|
73
|
+
|
|
74
|
+
if (apiKey) {
|
|
75
|
+
setStoredKey(provider, apiKey);
|
|
76
|
+
io.print(`✓ API Key 已保存到独立凭证库(权限 600),不会写入 config.json。`);
|
|
77
|
+
} else {
|
|
78
|
+
io.print(`✓ 未输入 API Key:将使用环境变量 ${pp.envKey || 'MINGDAO_API_KEY'}。`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let baseUrl = '';
|
|
82
|
+
if (provider === 'custom') {
|
|
83
|
+
baseUrl = await io.ask(`API 地址(回车默认 ${pp.baseUrl}):`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const perm = await io.choose('权限模式(写文件 / 执行命令时):', [
|
|
87
|
+
{ value: 'ask', label: 'ask — 每次询问(推荐,最安全)' },
|
|
88
|
+
{ value: 'auto', label: 'auto — 全部自动允许(完全自主,注意风险)' },
|
|
89
|
+
{ value: 'readonly', label: 'readonly — 只读模式,不允许修改或执行' },
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const sandbox = await io.choose('沙箱模式(bash 工具执行隔离,Linux + bubblewrap):', [
|
|
93
|
+
{ value: 'off', label: 'off — 直接执行(默认)' },
|
|
94
|
+
{ value: 'readonly', label: 'readonly — 全盘只读(/tmp 可写,网络可用)' },
|
|
95
|
+
{ value: 'safe', label: 'safe — 只读 + 断网(工作目录与 /tmp 可写,最安全)' },
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
let routing = null;
|
|
99
|
+
if (modelPreset('deepseek-v4-pro') && modelPreset('deepseek-v4-flash')) {
|
|
100
|
+
const routeChoice = await io.choose('自动模型路由(规划类任务→pro,执行类→flash):', [
|
|
101
|
+
{ value: 'on', label: 'on — 开启(省钱又高效,推荐)' },
|
|
102
|
+
{ value: 'off', label: 'off — 关闭(始终用当前模型)' },
|
|
103
|
+
]);
|
|
104
|
+
if (routeChoice === 'on') routing = { enabled: true, planner: 'deepseek-v4-pro', executor: 'deepseek-v4-flash' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const preset = modelPreset(model);
|
|
108
|
+
const defaultBudget = preset?.budgetTokens ?? 128000;
|
|
109
|
+
const budgetInput = await io.ask(`上下文预算 tokens(回车默认 ${defaultBudget}):`);
|
|
110
|
+
const contextBudget = Number(budgetInput) > 0 ? Number(budgetInput) : defaultBudget;
|
|
111
|
+
|
|
112
|
+
// 注意:config.json 不含任何密钥(可安全分享/提交),密钥只存 credentials.json。
|
|
113
|
+
const cfg = {
|
|
114
|
+
provider,
|
|
115
|
+
model,
|
|
116
|
+
baseUrl: baseUrl || pp.baseUrl,
|
|
117
|
+
permission: perm,
|
|
118
|
+
sandbox,
|
|
119
|
+
contextBudget,
|
|
120
|
+
};
|
|
121
|
+
if (routing) cfg.routing = routing;
|
|
122
|
+
saveConfig(cfg);
|
|
123
|
+
io.print('');
|
|
124
|
+
io.box('配置完成 ✓', [
|
|
125
|
+
`服务商 ${provider}(${pp.label})`,
|
|
126
|
+
`模型 ${model}`,
|
|
127
|
+
`权限 ${perm} · 沙箱 ${sandbox}`,
|
|
128
|
+
`路由 ${routing ? '自动(pro⇄flash)' : '关闭'}`,
|
|
129
|
+
`密钥 ${apiKey ? '凭证库 ' + maskKey(apiKey) : '环境变量 ' + (pp.envKey || 'MINGDAO_API_KEY')}`,
|
|
130
|
+
`保存于 ${configPath()}`,
|
|
131
|
+
]);
|
|
132
|
+
io.print('现在输入 mingdao 即可开始对话。');
|
|
133
|
+
return cfg;
|
|
134
|
+
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// 上下文管理:token 计数与预算裁剪。
|
|
2
|
+
// 计数优先级:DeepSeek 词表精确计数(tokenizer.js)→ 启发式估算(无词表模型回退)。
|
|
3
|
+
|
|
4
|
+
import { heuristicTokens } from './tokenizer.js';
|
|
5
|
+
|
|
6
|
+
export const TOOL_RESULT_LIMIT = 20000;
|
|
7
|
+
|
|
8
|
+
// 兼容旧接口的启发式估算(英文≈4 字符/token,CJK≈1 字符/token)
|
|
9
|
+
export function approxTokens(text) {
|
|
10
|
+
return heuristicTokens(text);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function partTokens(msg, count) {
|
|
14
|
+
let total = count(msg.content || '');
|
|
15
|
+
for (const tc of msg.tool_calls || []) {
|
|
16
|
+
total += count(JSON.stringify(tc));
|
|
17
|
+
}
|
|
18
|
+
return total;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 消息对象级 token 缓存(WeakMap:消息被会话持有期间有效,随消息一起回收)。
|
|
22
|
+
// 会话历史消息内容基本不可变(裁剪是投影,不改原对象),同一回合的 24 步里
|
|
23
|
+
// trimMessages 每步全量重算时直接命中,跳过重复 BPE 计数;key 附上 count 函数,
|
|
24
|
+
// 模型切换(新计数器)后自动失效重算。
|
|
25
|
+
const messageTokenCache = new WeakMap();
|
|
26
|
+
|
|
27
|
+
export function messageTokens(msg, count = approxTokens) {
|
|
28
|
+
const hit = messageTokenCache.get(msg);
|
|
29
|
+
if (hit && hit.fn === count) return hit.value;
|
|
30
|
+
const total = partTokens(msg, count) + 4; // 每条消息的协议开销
|
|
31
|
+
messageTokenCache.set(msg, { fn: count, value: total });
|
|
32
|
+
return total;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 从尾部向前保留消息,直到预算用尽;始终保留首条 system 消息。
|
|
36
|
+
// 裁剪可能切断 assistant(tool_calls) ↔ tool 的配对,清洗掉孤立消息,
|
|
37
|
+
// 否则 OpenAI 兼容 API 会因 tool_call_id 找不到对应调用而报 400。
|
|
38
|
+
export function cleanToolPairing(kept) {
|
|
39
|
+
const callIds = new Set();
|
|
40
|
+
for (const m of kept) {
|
|
41
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
|
|
42
|
+
for (const tc of m.tool_calls) if (tc?.id) callIds.add(tc.id);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const clean = kept.filter((m) => m.role !== 'tool' || callIds.has(m.tool_call_id));
|
|
46
|
+
const toolIds = new Set(clean.filter((m) => m.role === 'tool').map((m) => m.tool_call_id));
|
|
47
|
+
// 注意:裁剪是每次请求的投影,绝不能改写调用方 messages 里的原对象
|
|
48
|
+
// (否则会话历史被永久污染:assistant 消息丢失 tool_calls,下一轮 API 报 400)
|
|
49
|
+
return clean.map((m) => {
|
|
50
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
51
|
+
const keptCalls = m.tool_calls.filter((tc) => toolIds.has(tc.id));
|
|
52
|
+
if (keptCalls.length !== m.tool_calls.length) {
|
|
53
|
+
if (keptCalls.length) return { ...m, tool_calls: keptCalls };
|
|
54
|
+
const copy = { ...m };
|
|
55
|
+
delete copy.tool_calls; // 无任何对应 tool 响应,退化为纯文本消息
|
|
56
|
+
return copy;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return m;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function trimMessages(messages, budget, count = approxTokens) {
|
|
64
|
+
if (!messages.length) return [];
|
|
65
|
+
const hasSystem = messages[0]?.role === 'system';
|
|
66
|
+
const system = hasSystem ? [messages[0]] : [];
|
|
67
|
+
const rest = hasSystem ? messages.slice(1) : messages;
|
|
68
|
+
let total = system.reduce((s, m) => s + messageTokens(m, count), 0);
|
|
69
|
+
const tail = [];
|
|
70
|
+
for (let i = rest.length - 1; i >= 0; i--) {
|
|
71
|
+
const t = messageTokens(rest[i], count);
|
|
72
|
+
if (total + t > budget) break;
|
|
73
|
+
total += t;
|
|
74
|
+
tail.unshift(rest[i]);
|
|
75
|
+
}
|
|
76
|
+
const kept = cleanToolPairing(system.concat(tail));
|
|
77
|
+
// 静默裁剪:不向中间插入说明消息——保持消息前缀字节稳定,
|
|
78
|
+
// 让 DeepSeek 等支持上下文缓存的 API 最大化缓存命中(命中价仅为未命中的约 1/30)。
|
|
79
|
+
return kept;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function clampText(text, maxChars = TOOL_RESULT_LIMIT) {
|
|
83
|
+
const s = String(text);
|
|
84
|
+
if (s.length <= maxChars) return s;
|
|
85
|
+
return s.slice(0, maxChars) + `\n…[输出过长已截断,原文共 ${s.length} 字符]`;
|
|
86
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// 费用护栏(四报告共识 A2/Kimi P-1:把峰谷定价从「展示」升级为「主动约束」):
|
|
2
|
+
// config.costGuard = { dailyLimitYuan: 10, warnAtYuan: 8, action: 'warn'|'block' }
|
|
3
|
+
// 按北京时间自然日累计 cache-stats 实际费用(含 batch 半价与缓存折扣后的真实口径)。
|
|
4
|
+
// Agent 每轮开始前检查:超 warn 线提醒、超 limit 且 action=block 时暂停执行。
|
|
5
|
+
|
|
6
|
+
import { listCacheStats } from './cachestats.js';
|
|
7
|
+
import { beijingDayStart } from './pricing.js';
|
|
8
|
+
import { loadConfig } from './config.js';
|
|
9
|
+
|
|
10
|
+
export function costGuardConfig() {
|
|
11
|
+
return loadConfig()?.costGuard || null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 今日(北京时间 0 点起)累计实际费用
|
|
15
|
+
export function todayCost() {
|
|
16
|
+
const start = beijingDayStart().getTime();
|
|
17
|
+
let sum = 0;
|
|
18
|
+
for (const e of listCacheStats(100000)) {
|
|
19
|
+
if (e.at >= start) sum += Number(e.cost) || 0;
|
|
20
|
+
}
|
|
21
|
+
return sum;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function costGuardStatus() {
|
|
25
|
+
const g = costGuardConfig();
|
|
26
|
+
if (!g) return null;
|
|
27
|
+
const cost = todayCost();
|
|
28
|
+
const limit = Number(g.dailyLimitYuan) || 0;
|
|
29
|
+
const warnAt = Number(g.warnAtYuan) || (limit > 0 ? limit * 0.8 : 0);
|
|
30
|
+
const action = g.action === 'block' ? 'block' : 'warn';
|
|
31
|
+
return {
|
|
32
|
+
cost,
|
|
33
|
+
limit,
|
|
34
|
+
warnAt,
|
|
35
|
+
action,
|
|
36
|
+
overWarn: limit > 0 && cost >= warnAt,
|
|
37
|
+
overLimit: limit > 0 && cost >= limit,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Agent 每轮开始前调用:返回 null 放行;blocked=true 表示应暂停本轮
|
|
42
|
+
export function checkCostGuard() {
|
|
43
|
+
const st = costGuardStatus();
|
|
44
|
+
if (!st) return null;
|
|
45
|
+
if (st.overLimit && st.action === 'block') {
|
|
46
|
+
return {
|
|
47
|
+
blocked: true,
|
|
48
|
+
message: `今日费用已达上限 ¥${st.limit.toFixed(2)}(实际 ¥${st.cost.toFixed(4)}),已暂停执行——调整 config.costGuard 或明天自动恢复。`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (st.overWarn) {
|
|
52
|
+
return {
|
|
53
|
+
blocked: false,
|
|
54
|
+
message: `⚠ 费用护栏:今日已用 ¥${st.cost.toFixed(4)} / 上限 ¥${st.limit.toFixed(2)}${st.action === 'block' ? '(到达即暂停)' : '(仅提醒)'}。`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|