draco-cli 0.0.0-stage → 3.0.3

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/src/config.js ADDED
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ let VERSION = '0.0.0';
8
+ try {
9
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
10
+ if (pkg && pkg.version) VERSION = pkg.version;
11
+ } catch {
12
+ // keep fallback version
13
+ }
14
+
15
+ const DEFAULT_API_BASE = 'https://opencode.ai';
16
+ const DEFAULT_MODEL = 'nemotron-3-ultra-free';
17
+ const DEFAULT_FALLBACK_CHAIN = [
18
+ 'nemotron-3-ultra-free',
19
+ 'deepseek-v4-flash-free',
20
+ 'big-pickle',
21
+ 'mimo-v2.5-free',
22
+ ];
23
+ const KNOWN_FREE_MODELS = DEFAULT_FALLBACK_CHAIN.slice();
24
+
25
+ const CHAT_TIMEOUT_MS = 60000;
26
+ const MODELS_TIMEOUT_MS = 15000;
27
+ const SHELL_TIMEOUT_MS = 45000;
28
+ const SHELL_MAX_BUFFER = 8 * 1024 * 1024;
29
+ const FETCH_TIMEOUT_MS = 30000;
30
+ const FETCH_MAX_BYTES = 5 * 1024 * 1024;
31
+ const READ_FILE_MAX_BYTES = 256 * 1024;
32
+
33
+ function configFilePath() {
34
+ return path.join(os.homedir(), '.draco', 'config.json');
35
+ }
36
+
37
+ function loadEnvFile(dir) {
38
+ const p = path.join(dir || process.cwd(), '.env');
39
+ let raw;
40
+ try {
41
+ raw = fs.readFileSync(p, 'utf8');
42
+ } catch {
43
+ return;
44
+ }
45
+ for (const lineRaw of raw.split(/\r?\n/)) {
46
+ const line = lineRaw.trim();
47
+ if (!line || line.startsWith('#')) continue;
48
+ const eq = line.indexOf('=');
49
+ if (eq <= 0) continue;
50
+ const key = line.slice(0, eq).trim();
51
+ let value = line.slice(eq + 1).trim();
52
+ if (
53
+ (value.startsWith('"') && value.endsWith('"')) ||
54
+ (value.startsWith("'") && value.endsWith("'"))
55
+ ) {
56
+ value = value.slice(1, -1);
57
+ }
58
+ if (!(key in process.env)) process.env[key] = value;
59
+ }
60
+ }
61
+
62
+ function loadPersisted() {
63
+ try {
64
+ const parsed = JSON.parse(fs.readFileSync(configFilePath(), 'utf8'));
65
+ return parsed && typeof parsed === 'object' ? parsed : {};
66
+ } catch {
67
+ return {};
68
+ }
69
+ }
70
+
71
+ function persistConfig(patch) {
72
+ const next = { ...loadPersisted() };
73
+ for (const [key, value] of Object.entries(patch)) {
74
+ if (value === undefined) delete next[key];
75
+ else next[key] = value;
76
+ }
77
+ fs.mkdirSync(path.dirname(configFilePath()), { recursive: true });
78
+ fs.writeFileSync(configFilePath(), JSON.stringify(next, null, 2) + '\n');
79
+ return next;
80
+ }
81
+
82
+ const APPROVAL_MODES = ['suggest', 'auto-edit', 'full-auto'];
83
+ const DEFAULT_APPROVALS = 'suggest';
84
+
85
+ function configDirPath() {
86
+ return path.join(os.homedir(), '.draco');
87
+ }
88
+
89
+ function configFilePath() {
90
+ return path.join(configDirPath(), 'config.json');
91
+ }
92
+
93
+ function sessionsDir() {
94
+ return path.join(configDirPath(), 'sessions');
95
+ }
96
+
97
+ function mcpConfigPath() {
98
+ return path.join(configDirPath(), 'mcp.json');
99
+ }
100
+
101
+ function loadMcpConfig() {
102
+ try {
103
+ const parsed = JSON.parse(fs.readFileSync(mcpConfigPath(), 'utf8'));
104
+ const servers = parsed && typeof parsed === 'object' ? parsed.mcpServers : null;
105
+ return servers && typeof servers === 'object' ? servers : {};
106
+ } catch {
107
+ return {};
108
+ }
109
+ }
110
+
111
+ function sessionFileFor(cwd) {
112
+ const key = cwd.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(-80) || 'root';
113
+ return path.join(sessionsDir(), `${key}.json`);
114
+ }
115
+
116
+ function validateConfig(config) {
117
+ const warnings = [];
118
+
119
+ if (!config.apiKey) {
120
+ warnings.push('No API key configured. Set OPENCODE_ZEN_KEY or enter when prompted.');
121
+ }
122
+
123
+ if (config.model && !KNOWN_FREE_MODELS.includes(config.model) && !config.model.includes('/')) {
124
+ warnings.push(`Model "${config.model}" is not in the known free models list.`);
125
+ }
126
+
127
+ if (config.fallbackChain && config.fallbackChain.length === 0) {
128
+ warnings.push('Fallback chain is empty. Using default chain.');
129
+ config.fallbackChain = DEFAULT_FALLBACK_CHAIN.slice();
130
+ }
131
+
132
+ if (config.searchProvider === 'brave' && !config.searchApiKey) {
133
+ warnings.push('Brave search selected but no API key. Will fall back to DuckDuckGo.');
134
+ }
135
+
136
+ return { config, warnings };
137
+ }
138
+
139
+ function resolveConfig(cliFlags) {
140
+ loadEnvFile();
141
+ const persisted = loadPersisted();
142
+ const env = process.env;
143
+ const cli = cliFlags || {};
144
+
145
+ const model =
146
+ cli.model || env.DRACO_MODEL || persisted.model || DEFAULT_MODEL;
147
+
148
+ let yolo = false;
149
+ if (cli.yolo) yolo = true;
150
+ else if (env.DRACO_YOLO === 'true') yolo = true;
151
+ else if (env.DRACO_YOLO === 'false') yolo = false;
152
+ else if (typeof persisted.yolo === 'boolean') yolo = persisted.yolo;
153
+
154
+ let approvals = cli.approvals || env.DRACO_APPROVALS || persisted.approvals;
155
+ if (!APPROVAL_MODES.includes(approvals)) approvals = DEFAULT_APPROVALS;
156
+ if (yolo) approvals = 'full-auto';
157
+
158
+ const fallbackChain =
159
+ Array.isArray(persisted.fallbackChain) && persisted.fallbackChain.length > 0
160
+ ? persisted.fallbackChain
161
+ : DEFAULT_FALLBACK_CHAIN.slice();
162
+
163
+ let apiKey = env.OPENCODE_ZEN_KEY || '';
164
+ let keySource = null;
165
+ if (apiKey) {
166
+ keySource = 'environment';
167
+ } else if (typeof persisted.apiKey === 'string' && persisted.apiKey.trim()) {
168
+ apiKey = persisted.apiKey.trim();
169
+ keySource = 'saved';
170
+ }
171
+
172
+ const config = {
173
+ version: VERSION,
174
+ apiKey,
175
+ keySource,
176
+ apiBase: (env.DRACO_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''),
177
+ model,
178
+ yolo,
179
+ approvals,
180
+ fallbackChain,
181
+ searchProvider: env.DRACO_SEARCH_PROVIDER || 'brave',
182
+ searchApiKey: env.DRACO_SEARCH_API_KEY || '',
183
+ workspaceRoot: process.cwd(),
184
+ configFile: configFilePath(),
185
+ };
186
+
187
+ const validated = validateConfig(config);
188
+ validated.config.warnings = validated.warnings;
189
+ return validated.config;
190
+ }
191
+
192
+ module.exports = {
193
+ VERSION,
194
+ APPROVAL_MODES,
195
+ DEFAULT_APPROVALS,
196
+ DEFAULT_API_BASE,
197
+ DEFAULT_MODEL,
198
+ DEFAULT_FALLBACK_CHAIN,
199
+ KNOWN_FREE_MODELS,
200
+ CHAT_TIMEOUT_MS,
201
+ MODELS_TIMEOUT_MS,
202
+ SHELL_TIMEOUT_MS,
203
+ SHELL_MAX_BUFFER,
204
+ FETCH_TIMEOUT_MS,
205
+ FETCH_MAX_BYTES,
206
+ READ_FILE_MAX_BYTES,
207
+ configFilePath,
208
+ configDirPath,
209
+ sessionsDir,
210
+ sessionFileFor,
211
+ mcpConfigPath,
212
+ loadMcpConfig,
213
+ loadEnvFile,
214
+ loadPersisted,
215
+ persistConfig,
216
+ resolveConfig,
217
+ };
218
+
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ const APPROVAL_MODES = ['suggest', 'auto-edit', 'full-auto'];
4
+
5
+ function approvalMode(ctx) {
6
+ if (!ctx || !ctx.cfg) return 'suggest';
7
+ if (ctx.cfg.yolo) return 'full-auto';
8
+ return ctx.cfg.approvals || 'suggest';
9
+ }
10
+
11
+ async function gate(ctx, question, autoModes) {
12
+ const mode = approvalMode(ctx);
13
+ if (autoModes.includes(mode)) return true;
14
+ if (typeof ctx.ask !== 'function') return false;
15
+ const answer = await ctx.ask(question);
16
+ const normalized = String(answer || '').trim().toLowerCase();
17
+ return normalized === 'y' || normalized === 'yes';
18
+ }
19
+
20
+ module.exports = { APPROVAL_MODES, approvalMode, gate };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const { streamChatCompletion } = require('../providers/openai-compatible.js');
4
+ const ui = require('../ui/terminal.js');
5
+
6
+ function simplifyMessageForSummary(message) {
7
+ let content = message.content;
8
+ if (Array.isArray(content)) {
9
+ const texts = content
10
+ .filter((part) => part && part.type === 'text')
11
+ .map((part) => part.text);
12
+ content = texts.join('\n') || '[media attachment]';
13
+ }
14
+ if (message.role === 'tool') {
15
+ return { role: 'user', content: `[tool result] ${String(content).slice(0, 2000)}` };
16
+ }
17
+ return {
18
+ role: message.role === 'assistant' ? 'assistant' : 'user',
19
+ content: String(content ?? ''),
20
+ };
21
+ }
22
+
23
+ const SUMMARY_SYSTEM_PROMPT =
24
+ 'Summarize this coding-agent conversation. Capture: the user goals, files created/modified (with paths), key decisions, commands run, errors hit and their fixes, and any unfinished next steps. Be dense and factual.';
25
+
26
+ async function compactConversation({ cfg, model, messages }) {
27
+ const spinner = new ui.Spinner('Compacting conversation…');
28
+ spinner.start();
29
+ const controller = new AbortController();
30
+ let summary = '';
31
+ try {
32
+ await streamChatCompletion({
33
+ cfg,
34
+ model,
35
+ messages: [
36
+ { role: 'system', content: SUMMARY_SYSTEM_PROMPT },
37
+ ...messages.map(simplifyMessageForSummary),
38
+ ],
39
+ tools: [],
40
+ signal: controller.signal,
41
+ onDelta(t) {
42
+ summary += t;
43
+ },
44
+ });
45
+ spinner.stop();
46
+ return summary.trim();
47
+ } catch (err) {
48
+ spinner.stop();
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ module.exports = { compactConversation, simplifyMessageForSummary };
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const theme = require('../ui/theme.js');
7
+
8
+ class ConfigManager {
9
+ constructor(options = {}) {
10
+ this.configDir = options.configDir || path.join(os.homedir(), '.draco');
11
+ this.configFile = path.join(this.configDir, 'config.json');
12
+ this.config = {};
13
+ this.watchers = new Map();
14
+ this.defaults = {};
15
+ }
16
+
17
+ setDefaults(defaults) {
18
+ this.defaults = { ...defaults };
19
+ return this;
20
+ }
21
+
22
+ load() {
23
+ try {
24
+ if (!fs.existsSync(this.configDir)) {
25
+ fs.mkdirSync(this.configDir, { recursive: true });
26
+ }
27
+
28
+ if (fs.existsSync(this.configFile)) {
29
+ const data = fs.readFileSync(this.configFile, 'utf8');
30
+ this.config = JSON.parse(data);
31
+ }
32
+ } catch (error) {
33
+ theme.printError(`Failed to load config: ${error.message}`);
34
+ this.config = {};
35
+ }
36
+
37
+ return this;
38
+ }
39
+
40
+ save() {
41
+ try {
42
+ if (!fs.existsSync(this.configDir)) {
43
+ fs.mkdirSync(this.configDir, { recursive: true });
44
+ }
45
+
46
+ fs.writeFileSync(this.configFile, JSON.stringify(this.config, null, 2));
47
+ this.notifyWatchers();
48
+ } catch (error) {
49
+ theme.printError(`Failed to save config: ${error.message}`);
50
+ }
51
+
52
+ return this;
53
+ }
54
+
55
+ get(key, defaultValue = undefined) {
56
+ const keys = key.split('.');
57
+ let value = this.config;
58
+
59
+ for (const k of keys) {
60
+ if (value === null || value === undefined) {
61
+ return this.getDefaults(key, defaultValue);
62
+ }
63
+ value = value[k];
64
+ }
65
+
66
+ return value !== undefined ? value : this.getDefaults(key, defaultValue);
67
+ }
68
+
69
+ getDefaults(key, defaultValue) {
70
+ const keys = key.split('.');
71
+ let value = this.defaults;
72
+
73
+ for (const k of keys) {
74
+ if (value === null || value === undefined) {
75
+ return defaultValue;
76
+ }
77
+ value = value[k];
78
+ }
79
+
80
+ return value !== undefined ? value : defaultValue;
81
+ }
82
+
83
+ set(key, value) {
84
+ const keys = key.split('.');
85
+ let current = this.config;
86
+
87
+ for (let i = 0; i < keys.length - 1; i++) {
88
+ if (!(keys[i] in current) || typeof current[keys[i]] !== 'object') {
89
+ current[keys[i]] = {};
90
+ }
91
+ current = current[keys[i]];
92
+ }
93
+
94
+ current[keys[keys.length - 1]] = value;
95
+ this.save();
96
+
97
+ return this;
98
+ }
99
+
100
+ delete(key) {
101
+ const keys = key.split('.');
102
+ let current = this.config;
103
+
104
+ for (let i = 0; i < keys.length - 1; i++) {
105
+ if (!(keys[i] in current)) return false;
106
+ current = current[keys[i]];
107
+ }
108
+
109
+ const deleted = delete current[keys[keys.length - 1]];
110
+ if (deleted) this.save();
111
+
112
+ return deleted;
113
+ }
114
+
115
+ has(key) {
116
+ return this.get(key) !== undefined;
117
+ }
118
+
119
+ getAll() {
120
+ return { ...this.config };
121
+ }
122
+
123
+ merge(otherConfig) {
124
+ this.config = this.deepMerge(this.config, otherConfig);
125
+ this.save();
126
+ return this;
127
+ }
128
+
129
+ deepMerge(target, source) {
130
+ const result = { ...target };
131
+
132
+ for (const key in source) {
133
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
134
+ result[key] = this.deepMerge(result[key] || {}, source[key]);
135
+ } else {
136
+ result[key] = source[key];
137
+ }
138
+ }
139
+
140
+ return result;
141
+ }
142
+
143
+ watch(key, callback) {
144
+ if (!this.watchers.has(key)) {
145
+ this.watchers.set(key, []);
146
+ }
147
+ this.watchers.get(key).push(callback);
148
+
149
+ return () => {
150
+ const callbacks = this.watchers.get(key);
151
+ if (callbacks) {
152
+ const index = callbacks.indexOf(callback);
153
+ if (index !== -1) callbacks.splice(index, 1);
154
+ }
155
+ };
156
+ }
157
+
158
+ notifyWatchers() {
159
+ for (const [key, callbacks] of this.watchers) {
160
+ const value = this.get(key);
161
+ for (const callback of callbacks) {
162
+ callback(value);
163
+ }
164
+ }
165
+ }
166
+
167
+ reset() {
168
+ this.config = {};
169
+ this.save();
170
+ return this;
171
+ }
172
+ }
173
+
174
+ module.exports = { ConfigManager };
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ function nextFallback(chain, model) {
4
+ if (!Array.isArray(chain)) return null;
5
+ const idx = chain.indexOf(model);
6
+ if (idx >= 0) return chain[idx + 1] || null;
7
+ if (chain[0] !== model) return chain[0] || null;
8
+ return chain[1] || null;
9
+ }
10
+
11
+ module.exports = { nextFallback };
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
7
+
8
+ function extractImages(workspaceRoot, text) {
9
+ const images = [];
10
+ const seen = new Set();
11
+ const re = /(?:[A-Za-z]:)?[^\s"']+\.(?:png|jpe?g|gif|webp)/gi;
12
+ let match;
13
+ while ((match = re.exec(text)) !== null) {
14
+ try {
15
+ const abs = path.resolve(workspaceRoot, match[0]);
16
+ if (seen.has(abs)) continue;
17
+ const stat = fs.statSync(abs);
18
+ if (!stat.isFile() || stat.size > MAX_IMAGE_BYTES) continue;
19
+ seen.add(abs);
20
+ const ext = path.extname(abs).slice(1).toLowerCase();
21
+ const mime =
22
+ ext === 'jpg' || ext === 'jpeg'
23
+ ? 'image/jpeg'
24
+ : ext === 'gif'
25
+ ? 'image/gif'
26
+ : ext === 'webp'
27
+ ? 'image/webp'
28
+ : 'image/png';
29
+ images.push({
30
+ type: 'image_url',
31
+ image_url: { url: `data:${mime};base64,${fs.readFileSync(abs).toString('base64')}` },
32
+ });
33
+ } catch {
34
+ // not a readable file; leave it as plain text
35
+ }
36
+ }
37
+ return images;
38
+ }
39
+
40
+ module.exports = { extractImages };