dgxay-code-cli 0.0.0-stage → 1.0.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 (45) hide show
  1. package/LICENSE +674 -0
  2. package/README-zh.md +200 -0
  3. package/README.md +182 -2
  4. package/bin/dgxay.js +42 -0
  5. package/package.json +47 -4
  6. package/src/cli/ansi.js +509 -0
  7. package/src/cli/approval.js +186 -0
  8. package/src/cli/args.js +162 -0
  9. package/src/cli/command-table.js +52 -0
  10. package/src/cli/editor.js +574 -0
  11. package/src/cli/index.js +228 -0
  12. package/src/cli/print.js +148 -0
  13. package/src/cli/render.js +324 -0
  14. package/src/cli/repl-state.js +85 -0
  15. package/src/cli/repl.js +1167 -0
  16. package/src/cli/slash.js +74 -0
  17. package/src/cli/ui.js +52 -0
  18. package/src/commands/_shared.js +20 -0
  19. package/src/commands/chat.js +212 -0
  20. package/src/commands/config.js +363 -0
  21. package/src/commands/context.js +43 -0
  22. package/src/commands/doctor.js +192 -0
  23. package/src/commands/export.js +102 -0
  24. package/src/commands/mcp.js +257 -0
  25. package/src/commands/model.js +223 -0
  26. package/src/commands/oauth.js +246 -0
  27. package/src/commands/permission.js +108 -0
  28. package/src/commands/provider.js +332 -0
  29. package/src/commands/run.js +172 -0
  30. package/src/commands/skill.js +94 -0
  31. package/src/commands/workspace.js +100 -0
  32. package/src/core/agent.js +419 -0
  33. package/src/core/llm.js +709 -0
  34. package/src/core/mcp.js +271 -0
  35. package/src/core/oauth.js +737 -0
  36. package/src/core/prompt.js +269 -0
  37. package/src/core/sessions.js +208 -0
  38. package/src/core/tools.js +391 -0
  39. package/src/services/config.js +182 -0
  40. package/src/services/input.js +91 -0
  41. package/src/services/session-bus.js +104 -0
  42. package/src/services/settings.js +340 -0
  43. package/src/services/skills.js +125 -0
  44. package/src/services/terminal.js +124 -0
  45. package/src/services/version.js +25 -0
@@ -0,0 +1,269 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 系统提示与安全审查(与桌面版 main.js 中的逻辑一一对应)。
5
+ */
6
+
7
+ const { streamChat } = require('./llm');
8
+ const oauth = require('./oauth');
9
+
10
+ const REVIEW_TIMEOUT = 25000;
11
+
12
+ const REVIEW_SYSTEM = [
13
+ '你是 DgxayCode 的本机操作安全审查模型。AI 助手在获得用户授权前,由你判断一次操作是否可能伤害用户的电脑或泄露数据。',
14
+ '只输出 JSON,不要代码块:{"decision":"allow","reason":"..."} 或 {"decision":"review","reason":"..."}',
15
+ 'allow 适用于:读取与任务直接相关的文件、在工作空间内写入普通文本文件、查看目录、执行只读查询、访问公开文档。',
16
+ 'review 适用于:删除或覆盖重要文件、修改工作空间外的系统/用户目录、安装/卸载/提权/服务/批量脚本、访问 .ssh/.env/密钥/浏览器数据、上传本地数据到外部、命令或目标不明确、可能不可逆。',
17
+ '不确定时选择 review。理由用简体中文,不超过 60 字。'
18
+ ].join('\n');
19
+
20
+ function systemPromptFor({ mode, permission, workspace, customPrompt, skill }) {
21
+ const parts = [];
22
+ parts.push(
23
+ '你是 DgxayCode 命令行版内置的 AI 助手。回答使用简体中文(除非用户使用其他语言),结构清晰,尽量简洁,合理使用 Markdown。'
24
+ );
25
+ if (skill?.content) {
26
+ parts.push(`【本对话启用的技能:${skill.name}】\n${skill.content}`);
27
+ }
28
+ if (customPrompt && String(customPrompt).trim()) {
29
+ parts.push(`用户自定义指令:\n${String(customPrompt).trim()}`);
30
+ }
31
+
32
+ if (mode !== 'work') {
33
+ parts.push('当前为「对话模式」:不提供文件系统与终端工具,直接通过文字回答用户。');
34
+ if (permission !== 'none') {
35
+ parts.push(
36
+ '如果用户明确要求安装 MCP server,可以调用 install_mcp_server 保存启动配置,并提示用户运行 dgxay mcp start 启动。'
37
+ );
38
+ }
39
+ return parts.join('\n\n');
40
+ }
41
+
42
+ parts.push(`当前为「工作模式」。工作空间目录:${workspace || '(尚未选择)'}。`);
43
+ if (permission === 'none') {
44
+ parts.push(
45
+ '权限级别:无权限。你只能以只读方式访问工作空间内的文件,禁止修改/创建/删除文件,禁止联网,禁止执行命令。' +
46
+ '如果用户要求写入文件、联网或执行命令,请说明需要切换到「普通权限」「自动审查」或「完全权限」。'
47
+ );
48
+ } else if (permission === 'normal') {
49
+ parts.push(
50
+ '权限级别:普通权限。你拥有全部能力:可以读写任意文件、执行终端命令、访问网络。' +
51
+ '但每一次工具调用或终端命令都需要用户在终端确认后才能执行;如果用户拒绝,不要重试,改用已经获得的信息回答。'
52
+ );
53
+ } else if (permission === 'auto') {
54
+ parts.push(
55
+ '权限级别:自动审查。你拥有全部能力:可以读写任意文件、执行终端命令、访问网络。' +
56
+ '每一次操作会先由自动审查模型判断风险,安全操作自动放行,可疑操作转交用户确认;如果被拒绝,不要重试。'
57
+ );
58
+ } else {
59
+ parts.push(
60
+ '权限级别:完全权限。你可以执行终端命令、访问任意文件、无限制联网,不再经过审查或询问。执行破坏性操作前请先向用户确认。'
61
+ );
62
+ }
63
+
64
+ parts.push(
65
+ '安装 MCP:如果用户明确要求安装 MCP server,调用 install_mcp_server 保存启动配置,完成后提示用户运行 dgxay mcp start 或在 /mcp 面板中启动。不要伪造安装结果。'
66
+ );
67
+
68
+ if (workspace) {
69
+ parts.push(
70
+ [
71
+ '工作模式规则:',
72
+ '1. 需要查看或修改文件时,必须调用工具,不要凭空假设文件内容;修改前先 read_file。',
73
+ '2. 所有生成的文件必须保存到工作空间内,优先使用相对路径。',
74
+ '3. 完成任务后用简短的中文总结你创建/修改了哪些文件。',
75
+ '',
76
+ '【任务执行纪律】',
77
+ '• 收到目标后先拆成可执行步骤,然后立刻调用工具开始执行。禁止只输出计划、承诺或“接下来我会…”。',
78
+ '• 在任务真正完成之前不得停止,也不要等待用户说“继续”。每次工具返回后自动进入下一步,直到所有步骤都完成。',
79
+ '• 需要多次读写、多个文件或多个命令时,连续执行完再结束;不要做完一步就交差。',
80
+ '• 工具报错时先分析原因并换一种方式重试;只有缺少必要信息、权限被拒绝或任务确实无法继续时才可停下,并说明具体阻塞点。',
81
+ '• 结束前做一次验证:重新读取关键文件、检查命令输出或运行必要测试。',
82
+ '• 全部完成后,再用简短列表报告已完成内容与验证结果。未完成时禁止输出结束语或“如需继续请告诉我”。'
83
+ ].join('\n')
84
+ );
85
+ } else {
86
+ parts.push('尚未选择工作空间时,请不要调用任何文件工具,提示用户先用 /cd 选择工作目录。');
87
+ }
88
+ return parts.join('\n\n');
89
+ }
90
+
91
+ async function resolveProviderAuth(provider) {
92
+ if (provider?.authMode === 'oauth' && provider.oauthProvider) {
93
+ const token = await oauth.getAccessToken(provider.oauthProvider);
94
+ return { apiKey: token, authType: 'oauth' };
95
+ }
96
+ return { apiKey: provider?.apiKey || '', authType: 'apiKey' };
97
+ }
98
+
99
+ function summarizeArgs(args) {
100
+ if (args == null) return '';
101
+ if (typeof args !== 'object') return String(args).slice(0, 1800);
102
+ const copy = { ...args };
103
+ if (typeof copy.content === 'string' && copy.content.length > 1200) {
104
+ copy.content = `${copy.content.slice(0, 1200)}…(共 ${copy.content.length} 字符)`;
105
+ }
106
+ let text = JSON.stringify(copy);
107
+ if (text.length > 2000) text = `${text.slice(0, 2000)}…`;
108
+ return text;
109
+ }
110
+
111
+ /** 审查模型解析:优先当前供应商,未配置时回退到 providers[0](默认供应商) */
112
+ function resolveReviewer(config, providerId) {
113
+ const usable = (provider) =>
114
+ Boolean(
115
+ provider?.reviewModelId &&
116
+ Array.isArray(provider.models) &&
117
+ provider.models.some((model) => model.id === provider.reviewModelId)
118
+ );
119
+ const own = (config.providers || []).find((provider) => provider.id === providerId);
120
+ if (usable(own)) return { provider: own, modelId: own.reviewModelId, source: 'own' };
121
+ const fallback = (config.providers || [])[0];
122
+ if (usable(fallback)) return { provider: fallback, modelId: fallback.reviewModelId, source: 'default' };
123
+ return null;
124
+ }
125
+
126
+ function hasReviewModel(config) {
127
+ return Boolean(resolveReviewer(config, config?.activeProviderId));
128
+ }
129
+
130
+ async function reviewOperation({ config, providerId, kind, name, args, command, workspace }) {
131
+ const reviewer = resolveReviewer(config, providerId);
132
+ if (!reviewer) {
133
+ return { decision: 'review', reason: '尚未配置自动审查模型,需要你确认', reviewer: null, modelId: null };
134
+ }
135
+
136
+ const controller = new AbortController();
137
+ const timer = setTimeout(() => controller.abort(), REVIEW_TIMEOUT);
138
+ try {
139
+ const reviewerAuth = await resolveProviderAuth(reviewer.provider);
140
+ const result = await streamChat({
141
+ protocol: reviewer.provider.protocol || 'openai',
142
+ baseUrl: reviewer.provider.baseUrl,
143
+ apiKey: reviewerAuth.apiKey,
144
+ authType: reviewerAuth.authType,
145
+ model: reviewer.modelId,
146
+ messages: [
147
+ { role: 'system', content: REVIEW_SYSTEM },
148
+ {
149
+ role: 'user',
150
+ content: [
151
+ `操作类型:${kind === 'terminal' ? '终端命令' : '工具调用'}`,
152
+ `操作名称:${name}`,
153
+ command ? `命令:${command}` : '',
154
+ `工作空间:${workspace || '(未选择)'}`,
155
+ args ? `参数:${summarizeArgs(args)}` : '',
156
+ '请判断这次操作是否安全。'
157
+ ]
158
+ .filter(Boolean)
159
+ .join('\n')
160
+ }
161
+ ],
162
+ stream: false,
163
+ temperature: 0,
164
+ maxTokens: 320,
165
+ signal: controller.signal
166
+ });
167
+
168
+ const text = String(result.content || '').trim();
169
+ let decision = 'review';
170
+ let reason = text.slice(0, 160) || '审查模型未给出结论';
171
+ const left = text.indexOf('{');
172
+ const right = text.lastIndexOf('}');
173
+ if (left !== -1 && right > left) {
174
+ try {
175
+ const parsed = JSON.parse(text.slice(left, right + 1));
176
+ if (String(parsed.decision || '').toLowerCase() === 'allow') decision = 'allow';
177
+ if (parsed.reason) reason = String(parsed.reason).slice(0, 200);
178
+ } catch {
179
+ /* keep review */
180
+ }
181
+ }
182
+ return {
183
+ decision,
184
+ reason,
185
+ reviewer: reviewer.provider.name,
186
+ modelId: reviewer.modelId,
187
+ source: reviewer.source
188
+ };
189
+ } catch (err) {
190
+ // 超时、网络失败或返回无法解析时一律按“可疑”处理,转交用户确认
191
+ return {
192
+ decision: 'review',
193
+ reason: `自动审查失败:${err.message}`,
194
+ reviewer: reviewer.provider.name,
195
+ modelId: reviewer.modelId,
196
+ source: reviewer.source
197
+ };
198
+ } finally {
199
+ clearTimeout(timer);
200
+ }
201
+ }
202
+
203
+ function looksUnfinishedWork(result) {
204
+ const text = String(result?.content || '').trim();
205
+ if (result?.finishReason === 'length') return true;
206
+ if (!text) return true;
207
+ const tail = text.slice(-520);
208
+ return /(我(将|会|马上|这就|现在)(继续|开始|去|来|处理|执行|写入|创建|生成|读取|检查|整理)|接下来(我)?(会|将|继续|需要)|下一步(我)?(会|将)|稍后(会|将|再)|let me (now )?(proceed|continue|start|check)|i(?:'|’)ll (now )?(proceed|continue|start|check|write)|next,? i (?:will|'ll))/i.test(
209
+ tail
210
+ );
211
+ }
212
+
213
+ /** 上下文压缩(/compress):用一条摘要替换全部历史,有损且不可撤销 */
214
+ async function compressMessages({ config, providerId, modelId, messages }) {
215
+ const provider = (config.providers || []).find((item) => item.id === providerId) || (config.providers || [])[0];
216
+ const targetModel = modelId || provider?.models?.[0]?.id;
217
+ if (!provider || !targetModel) throw new Error('没有可用模型,无法压缩上下文');
218
+
219
+ const history = (Array.isArray(messages) ? messages : []).filter(
220
+ (m) => m && ['user', 'assistant'].includes(m.role) && typeof m.content === 'string' && m.content.trim()
221
+ );
222
+ if (!history.length) return { ok: true, summary: '', usage: null };
223
+
224
+ const transcript = history
225
+ .map((message) => `${message.role === 'user' ? '用户' : '助手'}:${message.content}`)
226
+ .join('\n\n')
227
+ .slice(-150000);
228
+
229
+ const providerAuth = await resolveProviderAuth(provider);
230
+ const result = await streamChat({
231
+ protocol: provider.protocol || 'openai',
232
+ baseUrl: provider.baseUrl,
233
+ apiKey: providerAuth.apiKey,
234
+ authType: providerAuth.authType,
235
+ model: targetModel,
236
+ messages: [
237
+ {
238
+ role: 'system',
239
+ content: '你是上下文压缩器。把长对话压缩成一份可供后续继续工作的摘要。只输出摘要正文,不要寒暄,不要代码块围栏。'
240
+ },
241
+ {
242
+ role: 'user',
243
+ content:
244
+ '请压缩下面的对话。必须保留:用户目标与偏好、关键事实与数字、已做出的决定、涉及的文件路径与代码位置、未完成事项、重要工具结果。删除寒暄、重复解释和无关内容。用简体中文输出,控制在 800 字以内。\n\n' +
245
+ transcript
246
+ }
247
+ ],
248
+ stream: false,
249
+ temperature: 0.2,
250
+ maxTokens: Math.min(2048, Number(config.chat?.maxTokens) || 2048)
251
+ });
252
+
253
+ const summary = String(result.content || '').trim();
254
+ if (!summary) throw new Error('模型没有返回摘要,请稍后重试');
255
+ return { ok: true, summary, usage: result.usage || null };
256
+ }
257
+
258
+ module.exports = {
259
+ REVIEW_SYSTEM,
260
+ REVIEW_TIMEOUT,
261
+ systemPromptFor,
262
+ resolveProviderAuth,
263
+ summarizeArgs,
264
+ resolveReviewer,
265
+ hasReviewModel,
266
+ reviewOperation,
267
+ looksUnfinishedWork,
268
+ compressMessages
269
+ };
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 会话存储:~/.dcb/chats.json
5
+ *
6
+ * 存储结构兼容桌面版,便于两端共用同一个数据目录:
7
+ * { id, title, mode, skill, createdAt, updatedAt, messages: [{role, content, ts, tools, files, ...}] }
8
+ *
9
+ * 最多保留 200 段对话(超出丢弃最旧的);写入使用原子替换。
10
+ */
11
+
12
+ const os = require('os');
13
+ const { loadChats, saveChats } = require('../services/config');
14
+
15
+ const MAX_CHATS = 200;
16
+
17
+ function newId(prefix = 'chat') {
18
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
19
+ }
20
+
21
+ function titleFrom(text) {
22
+ const cleaned = String(text || '')
23
+ .replace(/\s+/g, ' ')
24
+ .trim();
25
+ if (!cleaned) return '新对话';
26
+ return cleaned.length > 26 ? `${cleaned.slice(0, 26)}…` : cleaned;
27
+ }
28
+
29
+ function normalizeChat(chat) {
30
+ if (!chat || typeof chat !== 'object') return null;
31
+ return {
32
+ id: String(chat.id || newId()),
33
+ title: String(chat.title || '新对话'),
34
+ mode: chat.mode === 'work' ? 'work' : 'chat',
35
+ skill: typeof chat.skill === 'string' ? chat.skill : '',
36
+ createdAt: Number(chat.createdAt) || Date.now(),
37
+ updatedAt: Number(chat.updatedAt) || Date.now(),
38
+ messages: Array.isArray(chat.messages) ? chat.messages.filter((m) => m && m.role) : []
39
+ };
40
+ }
41
+
42
+ function sortChats(chats) {
43
+ return chats.slice().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
44
+ }
45
+
46
+ /** 读取全部会话(按更新时间倒序) */
47
+ function listChats() {
48
+ return sortChats(loadChats().map(normalizeChat).filter(Boolean));
49
+ }
50
+
51
+ function getChat(id) {
52
+ if (!id) return null;
53
+ return listChats().find((chat) => chat.id === id) || null;
54
+ }
55
+
56
+ /** 按序号(1 = 最新)或 id 前缀查找 */
57
+ function resolveChat(ref, chats = listChats()) {
58
+ if (!ref) return null;
59
+ const text = String(ref).trim();
60
+ const exact = chats.find((chat) => chat.id === text);
61
+ if (exact) return exact;
62
+ const index = Number(text);
63
+ if (Number.isInteger(index) && index >= 1 && index <= chats.length) return chats[index - 1];
64
+ const prefixMatches = chats.filter((chat) => chat.id.startsWith(text));
65
+ if (prefixMatches.length === 1) return prefixMatches[0];
66
+ if (prefixMatches.length > 1) {
67
+ const error = new Error(`会话编号不唯一:「${text}」匹配到 ${prefixMatches.length} 个会话,请输入更长的 id`);
68
+ error.code = 'AMBIGUOUS';
69
+ throw error;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /** 保存单段会话(插入或更新),返回保存后的会话 */
75
+ function saveChat(chat) {
76
+ const normalized = normalizeChat(chat);
77
+ if (!normalized) throw new Error('无效的会话');
78
+ if (!normalized.messages.length) return normalized;
79
+ const chats = listChats();
80
+ const index = chats.findIndex((item) => item.id === normalized.id);
81
+ normalized.updatedAt = Date.now();
82
+ if (index === -1) chats.unshift(normalized);
83
+ else chats[index] = normalized;
84
+ const trimmed = sortChats(chats).slice(0, MAX_CHATS);
85
+ saveChats(trimmed);
86
+ return normalized;
87
+ }
88
+
89
+ function deleteChat(id) {
90
+ const chats = listChats();
91
+ const next = chats.filter((chat) => chat.id !== id);
92
+ if (next.length === chats.length) return false;
93
+ saveChats(next.length ? next : []);
94
+ return true;
95
+ }
96
+
97
+ function clearChats() {
98
+ return saveChats([]);
99
+ }
100
+
101
+ function createChat(overrides = {}) {
102
+ return {
103
+ id: newId('chat'),
104
+ title: '新对话',
105
+ mode: overrides.mode === 'work' ? 'work' : 'chat',
106
+ skill: typeof overrides.skill === 'string' ? overrides.skill : '',
107
+ createdAt: Date.now(),
108
+ updatedAt: Date.now(),
109
+ messages: []
110
+ };
111
+ }
112
+
113
+ /* ------------------------------ 历史转换 ------------------------------ */
114
+
115
+ /** 发送给模型的消息:只保留 user / assistant 且有内容的部分(与桌面版 buildHistory 等价) */
116
+ function buildHistory(chat) {
117
+ return (chat?.messages || [])
118
+ .map((msg) => {
119
+ if (!msg || !msg.role) return null;
120
+ if (msg.compressed) return { role: 'user', content: `[上下文摘要]\n${String(msg.content || '').replace(/^> 上下文已压缩[^\n]*\n+/, '')}` };
121
+ if (msg.role === 'terminal') {
122
+ const output = `${msg.output || ''}${msg.stderr ? `\n${msg.stderr}` : ''}`.slice(0, 4000);
123
+ return {
124
+ role: 'user',
125
+ content: `[终端命令] ${msg.command}\n[输出]\n${output}\n[退出码] ${
126
+ msg.exitCode ?? (msg.status === 'running' ? '运行中' : '未知')
127
+ }`
128
+ };
129
+ }
130
+ if (msg.role !== 'user' && msg.role !== 'assistant') return null;
131
+ if (!msg.content || msg.error) return null;
132
+ return { role: msg.role, content: msg.content };
133
+ })
134
+ .filter(Boolean);
135
+ }
136
+
137
+ /** 每次请求只发送最近 N 条消息(与桌面版一致的 24 条) */
138
+ function trimHistory(history, limit = 24) {
139
+ const list = Array.isArray(history) ? history : [];
140
+ return limit > 0 ? list.slice(-limit) : list;
141
+ }
142
+
143
+ /* ------------------------------ token 估算 ------------------------------ */
144
+
145
+ function estimateTokens(text) {
146
+ if (!text) return 0;
147
+ let total = 0;
148
+ for (const char of String(text)) {
149
+ if (/[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/.test(char)) total += 1;
150
+ else if (/\s/.test(char)) total += 0.25;
151
+ else total += 0.28;
152
+ }
153
+ return Math.ceil(total);
154
+ }
155
+
156
+ function contextLimit(model) {
157
+ const configured = Number(model?.contextWindow);
158
+ if (configured > 0) return configured;
159
+ if (model?.protocol === 'anthropic' || model?.protocol === 'responses') return 200000;
160
+ return 128000;
161
+ }
162
+
163
+ function estimateChatTokens(chat) {
164
+ let total = 700;
165
+ if (!chat) return total;
166
+ for (const msg of chat.messages || []) {
167
+ if (msg.role === 'terminal') {
168
+ total += estimateTokens(msg.command) + estimateTokens(msg.output) + estimateTokens(msg.stderr) + 12;
169
+ } else {
170
+ total += estimateTokens(msg.content) + 6;
171
+ }
172
+ }
173
+ if (chat.skill) total += 1200;
174
+ return Math.round(total);
175
+ }
176
+
177
+ function formatTokens(value) {
178
+ const number = Math.max(0, Math.round(Number(value) || 0));
179
+ if (number >= 10000) return `${Math.round(number / 1000)}k`;
180
+ if (number >= 1000) return `${(number / 1000).toFixed(1)}k`;
181
+ return String(number);
182
+ }
183
+
184
+ function homeShort(path) {
185
+ const home = os.homedir();
186
+ if (!path) return '';
187
+ return String(path).startsWith(home) ? `~${String(path).slice(home.length)}` : String(path);
188
+ }
189
+
190
+ module.exports = {
191
+ MAX_CHATS,
192
+ newId,
193
+ titleFrom,
194
+ listChats,
195
+ getChat,
196
+ resolveChat,
197
+ saveChat,
198
+ deleteChat,
199
+ clearChats,
200
+ createChat,
201
+ buildHistory,
202
+ trimHistory,
203
+ estimateTokens,
204
+ estimateChatTokens,
205
+ contextLimit,
206
+ formatTokens,
207
+ homeShort
208
+ };