natureco-cli 5.7.0 → 5.7.1
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/CHANGELOG.md +89 -0
- package/package.json +14 -6
- package/src/commands/chat.js +5 -0
- package/src/commands/config.js +4 -0
- package/src/commands/gateway-server.js +38 -3
- package/src/commands/gateway.js +2 -0
- package/src/commands/nodes.js +4 -0
- package/src/commands/repl.js +13 -18
- package/src/commands/setup.js +7 -6
- package/src/tools/dashboard.js +2 -1
- package/src/tools/memory_write.js +70 -16
- package/src/utils/api.js +47 -40
- package/src/utils/approvals.js +27 -12
- package/src/utils/atomic-file.js +91 -0
- package/src/utils/dashboard-server.js +3 -2
- package/src/utils/error.js +4 -0
- package/src/utils/history.js +6 -14
- package/src/utils/ports.js +44 -0
- package/src/utils/process-errors.js +115 -0
- package/src/utils/provider-detect.js +78 -0
- package/src/utils/sessions.js +6 -13
- package/src/utils/streaming-tools.js +97 -0
- package/src/utils/tools.js +4 -2
package/src/utils/api.js
CHANGED
|
@@ -9,26 +9,17 @@ const { getConfig } = require('./config');
|
|
|
9
9
|
const { getToolDefinitions, executeToolCalls } = require('./tool-runner');
|
|
10
10
|
const { MCPClient } = require('./mcp-client');
|
|
11
11
|
const TB = require('./token-budget');
|
|
12
|
+
const { accumulateToolCallDeltas } = require('./streaming-tools');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* v5.5.0: Provider-specific format detection
|
|
15
16
|
* Groq, OpenAI, Anthropic, Mistral, DeepSeek, OpenRouter, Ollama, MiniMax
|
|
17
|
+
*
|
|
18
|
+
* Canonical implementation lives in src/utils/provider-detect.js;
|
|
19
|
+
* re-exported here so the historical `detectProvider` reference inside
|
|
20
|
+
* api.js continues to work without touching every call site.
|
|
16
21
|
*/
|
|
17
|
-
|
|
18
|
-
const url = (providerUrl || '').toLowerCase();
|
|
19
|
-
const m = (model || '').toLowerCase();
|
|
20
|
-
if (url.includes('anthropic.com') || m.includes('claude')) return 'anthropic';
|
|
21
|
-
if (url.includes('groq.com') || m.includes('groq') || m.includes('llama-3') || m.includes('mixtral')) return 'groq';
|
|
22
|
-
if (url.includes('openrouter.ai')) return 'openrouter';
|
|
23
|
-
if (url.includes('api.deepseek.com') || m.includes('deepseek')) return 'deepseek';
|
|
24
|
-
if (url.includes('mistral.ai') || m.includes('mistral') || m.includes('codestral')) return 'mistral';
|
|
25
|
-
if (url.includes('together.xyz') || m.includes('together')) return 'together';
|
|
26
|
-
if (url.includes('fireworks.ai') || m.includes('fireworks')) return 'fireworks';
|
|
27
|
-
if (url.includes('perplexity.ai') || m.includes('pplx') || m.includes('sonar')) return 'perplexity';
|
|
28
|
-
if (url.includes('localhost') || url.includes('127.0.0.1') || url.includes('ollama')) return 'ollama';
|
|
29
|
-
if (url.includes('minimax.io') || url.includes('minimax')) return 'minimax';
|
|
30
|
-
return 'openai'; // default
|
|
31
|
-
}
|
|
22
|
+
const { detectProvider, isMiniMax } = require('./provider-detect');
|
|
32
23
|
|
|
33
24
|
/**
|
|
34
25
|
* v5.5.0: Tool definitions'ı provider'a göre normalize et
|
|
@@ -81,6 +72,29 @@ function parseToolCallsFromResponse(message, provider) {
|
|
|
81
72
|
return message.tool_calls || [];
|
|
82
73
|
}
|
|
83
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Anthropic Messages API requires `system` to be either a non-empty string
|
|
77
|
+
* or omitted entirely. Sending `undefined` (which JSON.stringify drops to
|
|
78
|
+
* silent absence) leaves the model unanchored; sending `''` returns 400
|
|
79
|
+
* "system: cannot be empty" on recent API revisions. Always pass a
|
|
80
|
+
* meaningful default when no system message is present.
|
|
81
|
+
*/
|
|
82
|
+
const DEFAULT_ANTHROPIC_SYSTEM =
|
|
83
|
+
'You are a helpful AI assistant running inside the natureco CLI.';
|
|
84
|
+
|
|
85
|
+
function extractSystemForAnthropic(messages) {
|
|
86
|
+
const systemMsg = messages.find(m => m.role === 'system');
|
|
87
|
+
if (!systemMsg) return DEFAULT_ANTHROPIC_SYSTEM;
|
|
88
|
+
// content may be a string or an array of content blocks; both round-trip.
|
|
89
|
+
if (typeof systemMsg.content === 'string') {
|
|
90
|
+
return systemMsg.content.trim() || DEFAULT_ANTHROPIC_SYSTEM;
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(systemMsg.content) && systemMsg.content.length > 0) {
|
|
93
|
+
return systemMsg.content;
|
|
94
|
+
}
|
|
95
|
+
return DEFAULT_ANTHROPIC_SYSTEM;
|
|
96
|
+
}
|
|
97
|
+
|
|
84
98
|
/**
|
|
85
99
|
* v5.5.0: System mesajı provider'a göre ayarla
|
|
86
100
|
* - OpenAI: messages[].role=system
|
|
@@ -88,8 +102,6 @@ function parseToolCallsFromResponse(message, provider) {
|
|
|
88
102
|
*/
|
|
89
103
|
function buildRequestBody(messages, model, options, provider) {
|
|
90
104
|
if (provider === 'anthropic') {
|
|
91
|
-
// System mesajını ayır
|
|
92
|
-
const systemMsg = messages.find(m => m.role === 'system');
|
|
93
105
|
const userMsgs = messages.filter(m => m.role !== 'system');
|
|
94
106
|
return {
|
|
95
107
|
model,
|
|
@@ -97,7 +109,7 @@ function buildRequestBody(messages, model, options, provider) {
|
|
|
97
109
|
role: m.role,
|
|
98
110
|
content: m.content
|
|
99
111
|
})),
|
|
100
|
-
system:
|
|
112
|
+
system: extractSystemForAnthropic(messages),
|
|
101
113
|
max_tokens: options.max_tokens || 4096,
|
|
102
114
|
temperature: options.temperature || 0.7,
|
|
103
115
|
...(options.tools && options.tools.length > 0 ? { tools: options.tools } : {})
|
|
@@ -534,9 +546,8 @@ function formatToolsForAnthropic() {
|
|
|
534
546
|
*/
|
|
535
547
|
async function sendMessageOpenAICompatible(providerConfig, messages, tools) {
|
|
536
548
|
const baseUrl = providerConfig.url.replace(/\/+$/, '');
|
|
537
|
-
// MiniMax özel endpoint tespiti
|
|
538
|
-
const
|
|
539
|
-
const endpoint = isMiniMax
|
|
549
|
+
// MiniMax özel endpoint tespiti — provider-detect.js'deki kanonik tanım.
|
|
550
|
+
const endpoint = isMiniMax(baseUrl)
|
|
540
551
|
? `${baseUrl}/v1/text/chatcompletion_v2`
|
|
541
552
|
: `${baseUrl}/chat/completions`;
|
|
542
553
|
const requestBody = {
|
|
@@ -587,11 +598,10 @@ async function sendMessageOpenAICompatible(providerConfig, messages, tools) {
|
|
|
587
598
|
*/
|
|
588
599
|
async function sendMessageAnthropic(providerConfig, messages, tools) {
|
|
589
600
|
const endpoint = `${providerConfig.url}/v1/messages`;
|
|
590
|
-
|
|
591
|
-
// Anthropic requires system message separate
|
|
592
|
-
const systemMessage = messages.find(m => m.role === 'system');
|
|
601
|
+
|
|
602
|
+
// Anthropic requires system message separate; never send empty string.
|
|
593
603
|
const userMessages = messages.filter(m => m.role !== 'system');
|
|
594
|
-
|
|
604
|
+
|
|
595
605
|
const response = await fetch(endpoint, {
|
|
596
606
|
method: 'POST',
|
|
597
607
|
headers: {
|
|
@@ -602,7 +612,7 @@ async function sendMessageAnthropic(providerConfig, messages, tools) {
|
|
|
602
612
|
body: JSON.stringify({
|
|
603
613
|
model: providerConfig.model,
|
|
604
614
|
max_tokens: 2000,
|
|
605
|
-
system:
|
|
615
|
+
system: extractSystemForAnthropic(messages),
|
|
606
616
|
messages: userMessages,
|
|
607
617
|
tools: tools,
|
|
608
618
|
}),
|
|
@@ -992,9 +1002,8 @@ async function streamProviderCompletion(providerConfig, messages, tools) {
|
|
|
992
1002
|
|
|
993
1003
|
async function streamOpenAICompletion(providerConfig, messages, tools) {
|
|
994
1004
|
const baseUrl = providerConfig.url.replace(/\/+$/, '');
|
|
995
|
-
// MiniMax özel endpoint tespiti (streaming için de aynı)
|
|
996
|
-
const
|
|
997
|
-
const endpoint = isMiniMax
|
|
1005
|
+
// MiniMax özel endpoint tespiti (streaming için de aynı) — provider-detect.js.
|
|
1006
|
+
const endpoint = isMiniMax(baseUrl)
|
|
998
1007
|
? `${baseUrl}/v1/text/chatcompletion_v2`
|
|
999
1008
|
: `${baseUrl}/chat/completions`;
|
|
1000
1009
|
|
|
@@ -1046,15 +1055,7 @@ async function streamOpenAICompletion(providerConfig, messages, tools) {
|
|
|
1046
1055
|
|
|
1047
1056
|
if (delta.tool_calls) {
|
|
1048
1057
|
hasToolCalls = true;
|
|
1049
|
-
|
|
1050
|
-
const idx = tc.index;
|
|
1051
|
-
if (!toolCalls[idx]) {
|
|
1052
|
-
toolCalls[idx] = { id: tc.id || '', type: 'function', function: { name: '', arguments: '' } };
|
|
1053
|
-
}
|
|
1054
|
-
if (tc.id) toolCalls[idx].id = tc.id;
|
|
1055
|
-
if (tc.function?.name) toolCalls[idx].function.name += tc.function.name;
|
|
1056
|
-
if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments;
|
|
1057
|
-
}
|
|
1058
|
+
accumulateToolCallDeltas(toolCalls, delta.tool_calls);
|
|
1058
1059
|
}
|
|
1059
1060
|
|
|
1060
1061
|
const token = delta.content || '';
|
|
@@ -1089,7 +1090,6 @@ async function streamOpenAICompletion(providerConfig, messages, tools) {
|
|
|
1089
1090
|
async function streamAnthropicCompletion(providerConfig, messages) {
|
|
1090
1091
|
const endpoint = `${providerConfig.url}/v1/messages`;
|
|
1091
1092
|
|
|
1092
|
-
const systemMessage = messages.find(m => m.role === 'system');
|
|
1093
1093
|
const userMessages = messages.filter(m => m.role !== 'system');
|
|
1094
1094
|
|
|
1095
1095
|
const response = await fetch(endpoint, {
|
|
@@ -1102,7 +1102,7 @@ async function streamAnthropicCompletion(providerConfig, messages) {
|
|
|
1102
1102
|
body: JSON.stringify({
|
|
1103
1103
|
model: providerConfig.model,
|
|
1104
1104
|
max_tokens: 2000,
|
|
1105
|
-
system:
|
|
1105
|
+
system: extractSystemForAnthropic(messages),
|
|
1106
1106
|
messages: userMessages,
|
|
1107
1107
|
stream: true,
|
|
1108
1108
|
}),
|
|
@@ -1154,5 +1154,12 @@ module.exports = {
|
|
|
1154
1154
|
streamProviderCompletion,
|
|
1155
1155
|
streamOpenAICompletion,
|
|
1156
1156
|
streamAnthropicCompletion,
|
|
1157
|
+
// Exposed for tests + advanced consumers (does not appear in the
|
|
1158
|
+
// public API surface of natureco's user-facing docs).
|
|
1159
|
+
_internals: {
|
|
1160
|
+
extractSystemForAnthropic,
|
|
1161
|
+
DEFAULT_ANTHROPIC_SYSTEM,
|
|
1162
|
+
buildRequestBody,
|
|
1163
|
+
},
|
|
1157
1164
|
_sendMessage: sendMessage,
|
|
1158
1165
|
};
|
package/src/utils/approvals.js
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const os = require('os');
|
|
4
|
-
const net = require('net');
|
|
5
4
|
const chalk = require('chalk');
|
|
6
5
|
const inquirer = require('./inquirer-wrapper');
|
|
7
6
|
const { NatureCoError } = require('./errors');
|
|
7
|
+
const { writeJsonAtomicSync, readJsonSafeSync } = require('./atomic-file');
|
|
8
8
|
|
|
9
9
|
const APPROVALS_FILE = path.join(os.homedir(), '.natureco', 'exec-approvals.json');
|
|
10
|
-
const APPROVALS_SOCKET_PATH = path.join(os.homedir(), '.natureco', 'exec-approvals.sock');
|
|
11
10
|
const DEFAULT_TIMEOUT_MS = 1800000; // 30 min
|
|
12
11
|
|
|
12
|
+
// 0o600 = owner read/write only. The allowlist controls which commands
|
|
13
|
+
// auto-execute without prompting; on a shared machine, world-read leaks
|
|
14
|
+
// the user's automation surface and world-write lets another local
|
|
15
|
+
// account inject auto-approved commands. Treat it like ssh keys.
|
|
16
|
+
const APPROVALS_FILE_MODE = 0o600;
|
|
17
|
+
const APPROVALS_DIR_MODE = 0o700;
|
|
18
|
+
|
|
19
|
+
function _emptyApprovals() {
|
|
20
|
+
return { version: 1, defaults: { security: 'full', ask: 'off' }, agents: {} };
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
class ExecApprovalError extends NatureCoError {
|
|
14
24
|
constructor(message, options = {}) {
|
|
15
25
|
super(message, options);
|
|
@@ -33,20 +43,22 @@ function getApprovalsPath() {
|
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
function loadApprovals() {
|
|
36
|
-
|
|
37
|
-
return { version: 1, defaults: { security: 'full', ask: 'off' }, agents: {} };
|
|
38
|
-
}
|
|
39
|
-
try {
|
|
40
|
-
return JSON.parse(fs.readFileSync(APPROVALS_FILE, 'utf8'));
|
|
41
|
-
} catch {
|
|
42
|
-
return { version: 1, defaults: { security: 'full', ask: 'off' }, agents: {} };
|
|
43
|
-
}
|
|
46
|
+
return readJsonSafeSync(APPROVALS_FILE, _emptyApprovals());
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
function saveApprovals(data) {
|
|
47
50
|
const dir = path.dirname(APPROVALS_FILE);
|
|
48
|
-
if (!fs.existsSync(dir))
|
|
49
|
-
|
|
51
|
+
if (!fs.existsSync(dir)) {
|
|
52
|
+
fs.mkdirSync(dir, { recursive: true, mode: APPROVALS_DIR_MODE });
|
|
53
|
+
} else {
|
|
54
|
+
// Tighten the dir even if it pre-existed with looser bits.
|
|
55
|
+
try { fs.chmodSync(dir, APPROVALS_DIR_MODE); } catch { /* best-effort */ }
|
|
56
|
+
}
|
|
57
|
+
writeJsonAtomicSync(APPROVALS_FILE, data, { mode: APPROVALS_FILE_MODE });
|
|
58
|
+
// Tighten the file even if it pre-existed with looser bits (the rename
|
|
59
|
+
// in writeJsonAtomicSync preserves the temp's mode, but only when we
|
|
60
|
+
// pass it through — defensively chmod again in case of older installs).
|
|
61
|
+
try { fs.chmodSync(APPROVALS_FILE, APPROVALS_FILE_MODE); } catch { /* best-effort */ }
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
function resolveEffectivePolicy(agentId) {
|
|
@@ -294,4 +306,7 @@ module.exports = {
|
|
|
294
306
|
getApprovalsPath,
|
|
295
307
|
DANGEROUS_PATTERNS,
|
|
296
308
|
SAFE_COMMANDS,
|
|
309
|
+
APPROVALS_FILE,
|
|
310
|
+
APPROVALS_FILE_MODE,
|
|
311
|
+
APPROVALS_DIR_MODE,
|
|
297
312
|
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-safe file write + safe read.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: `fs.writeFileSync(path, data)` is NOT atomic. If the
|
|
5
|
+
* process is killed mid-write (SIGTERM, OOM, power loss) the file is
|
|
6
|
+
* left truncated, which becomes a "corrupted session" on next load
|
|
7
|
+
* (JSON.parse throws and session history is lost).
|
|
8
|
+
*
|
|
9
|
+
* `writeFileAtomicSync(path, data)` writes to a temp sibling first, then
|
|
10
|
+
* `rename(2)`s it into place — atomic on POSIX when both paths are on
|
|
11
|
+
* the same filesystem (always true here, since the temp is in the same
|
|
12
|
+
* directory).
|
|
13
|
+
*
|
|
14
|
+
* `readFileSafeSync(path, fallback)` returns the parsed JSON or falls
|
|
15
|
+
* back gracefully on missing/corrupted files instead of throwing.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
|
|
21
|
+
function _tempName(target) {
|
|
22
|
+
// Same directory so rename(2) is atomic. Include pid+rand to avoid
|
|
23
|
+
// collisions if two processes write the same file.
|
|
24
|
+
const dir = path.dirname(target);
|
|
25
|
+
const base = path.basename(target);
|
|
26
|
+
const suffix = `${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
27
|
+
return path.join(dir, `.${base}.${suffix}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Atomically write a string/Buffer to `filePath`.
|
|
32
|
+
* On error the temp file is cleaned up and the original is untouched.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} filePath
|
|
35
|
+
* @param {string|Buffer} data
|
|
36
|
+
* @param {{encoding?: BufferEncoding, mode?: number}} [opts]
|
|
37
|
+
*/
|
|
38
|
+
function writeFileAtomicSync(filePath, data, opts = {}) {
|
|
39
|
+
const encoding = opts.encoding ?? 'utf-8';
|
|
40
|
+
const mode = opts.mode; // undefined → fs default
|
|
41
|
+
const tmp = _tempName(filePath);
|
|
42
|
+
try {
|
|
43
|
+
if (mode !== undefined) {
|
|
44
|
+
fs.writeFileSync(tmp, data, { encoding, mode });
|
|
45
|
+
} else {
|
|
46
|
+
fs.writeFileSync(tmp, data, encoding);
|
|
47
|
+
}
|
|
48
|
+
fs.renameSync(tmp, filePath);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// Best-effort cleanup; if the temp already vanished, ignore.
|
|
51
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read `filePath` as JSON; return `fallback` on missing or corrupt.
|
|
58
|
+
*
|
|
59
|
+
* @template T
|
|
60
|
+
* @param {string} filePath
|
|
61
|
+
* @param {T} fallback
|
|
62
|
+
* @returns {T | object | Array<any>}
|
|
63
|
+
*/
|
|
64
|
+
function readJsonSafeSync(filePath, fallback) {
|
|
65
|
+
if (!fs.existsSync(filePath)) return fallback;
|
|
66
|
+
try {
|
|
67
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
68
|
+
if (!raw.trim()) return fallback;
|
|
69
|
+
return JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
return fallback;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Convenience: stringify + atomic write. Pretty-printed for human-edit
|
|
77
|
+
* friendliness (matches the existing JSON.stringify(..., null, 2) calls).
|
|
78
|
+
*
|
|
79
|
+
* @param {string} filePath
|
|
80
|
+
* @param {any} value
|
|
81
|
+
* @param {{mode?: number}} [opts] e.g. {mode: 0o600} for secrets/PII
|
|
82
|
+
*/
|
|
83
|
+
function writeJsonAtomicSync(filePath, value, opts = {}) {
|
|
84
|
+
writeFileAtomicSync(filePath, JSON.stringify(value, null, 2), opts);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
writeFileAtomicSync,
|
|
89
|
+
writeJsonAtomicSync,
|
|
90
|
+
readJsonSafeSync,
|
|
91
|
+
};
|
|
@@ -18,9 +18,10 @@ const fs = require('fs');
|
|
|
18
18
|
const path = require('path');
|
|
19
19
|
const os = require('os');
|
|
20
20
|
const url = require('url');
|
|
21
|
+
const { getDashboardPort, getDashboardHost } = require('./ports');
|
|
21
22
|
|
|
22
|
-
const PORT =
|
|
23
|
-
const HOST =
|
|
23
|
+
const PORT = getDashboardPort();
|
|
24
|
+
const HOST = getDashboardHost();
|
|
24
25
|
|
|
25
26
|
const NATURECO_DIR = path.join(os.homedir(), '.natureco');
|
|
26
27
|
|
package/src/utils/error.js
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* - Error codes
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
// checkMacPermission below uses os.platform() — was relying on a global
|
|
13
|
+
// `os` from another module's load order.
|
|
14
|
+
const os = require('os');
|
|
15
|
+
|
|
12
16
|
class ToolError extends Error {
|
|
13
17
|
constructor(message, code = "TOOL_ERROR", details = {}) {
|
|
14
18
|
super(message);
|
package/src/utils/history.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { CONFIG_DIR } = require('./config');
|
|
4
|
+
const { writeJsonAtomicSync, readJsonSafeSync } = require('./atomic-file');
|
|
4
5
|
|
|
5
6
|
const HISTORY_DIR = path.join(CONFIG_DIR, 'history');
|
|
6
7
|
|
|
@@ -20,26 +21,17 @@ function getHistoryFilePath(botId) {
|
|
|
20
21
|
// Load conversation history for a bot
|
|
21
22
|
function loadHistory(botId) {
|
|
22
23
|
const filePath = getHistoryFilePath(botId);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
try {
|
|
29
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
30
|
-
return JSON.parse(content);
|
|
31
|
-
} catch {
|
|
32
|
-
return [];
|
|
33
|
-
}
|
|
24
|
+
const data = readJsonSafeSync(filePath, []);
|
|
25
|
+
// Normalize: older versions may have saved non-array; defensive cast.
|
|
26
|
+
return Array.isArray(data) ? data : [];
|
|
34
27
|
}
|
|
35
28
|
|
|
36
29
|
// Save conversation history for a bot
|
|
37
30
|
function saveHistory(botId, history) {
|
|
38
31
|
const filePath = getHistoryFilePath(botId);
|
|
39
|
-
|
|
40
32
|
try {
|
|
41
|
-
|
|
42
|
-
} catch
|
|
33
|
+
writeJsonAtomicSync(filePath, history);
|
|
34
|
+
} catch {
|
|
43
35
|
// Silently fail - history is not critical
|
|
44
36
|
}
|
|
45
37
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized port + host defaults with env-var overrides.
|
|
3
|
+
*
|
|
4
|
+
* Hardcoding `7421` across multiple modules invited "port already in use"
|
|
5
|
+
* crashes in environments where the user runs natureco alongside other
|
|
6
|
+
* services on that port, or wants to run multiple natureco dashboards
|
|
7
|
+
* (e.g. one per project worktree). This module is the single source of
|
|
8
|
+
* truth — set NATURECO_DASHBOARD_PORT / NATURECO_DASHBOARD_HOST once and
|
|
9
|
+
* all callers pick it up.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DEFAULT_DASHBOARD_PORT = 7421;
|
|
13
|
+
const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
|
|
14
|
+
|
|
15
|
+
function _parsePort(raw, fallback) {
|
|
16
|
+
if (raw === undefined || raw === null || raw === '') return fallback;
|
|
17
|
+
const n = parseInt(String(raw).trim(), 10);
|
|
18
|
+
// Bind to a reserved port (≤1024) usually fails and is almost never
|
|
19
|
+
// what the user meant; clamp out-of-range values to the default.
|
|
20
|
+
if (!Number.isFinite(n) || n < 1 || n > 65535) return fallback;
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getDashboardPort() {
|
|
25
|
+
return _parsePort(process.env.NATURECO_DASHBOARD_PORT, DEFAULT_DASHBOARD_PORT);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getDashboardHost() {
|
|
29
|
+
const raw = process.env.NATURECO_DASHBOARD_HOST;
|
|
30
|
+
return (raw && String(raw).trim()) || DEFAULT_DASHBOARD_HOST;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getDashboardUrl() {
|
|
34
|
+
return `http://${getDashboardHost()}:${getDashboardPort()}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
DEFAULT_DASHBOARD_PORT,
|
|
39
|
+
DEFAULT_DASHBOARD_HOST,
|
|
40
|
+
getDashboardPort,
|
|
41
|
+
getDashboardHost,
|
|
42
|
+
getDashboardUrl,
|
|
43
|
+
_internals: { _parsePort },
|
|
44
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level process error handlers.
|
|
3
|
+
*
|
|
4
|
+
* Node's default behavior for unhandled rejections is to print a deprecated
|
|
5
|
+
* warning and (since Node 15) exit with code 1. The default for uncaught
|
|
6
|
+
* exceptions is to print the stack and exit 1. Both bypass natureco's audit
|
|
7
|
+
* trail and dump raw Node output that's useless to a CLI user.
|
|
8
|
+
*
|
|
9
|
+
* `install({ audit, exit, stderr })` registers handlers that:
|
|
10
|
+
* 1. Append a structured entry to the audit log (synchronous — the
|
|
11
|
+
* process is about to die, async fire-and-forget would race),
|
|
12
|
+
* 2. Print a single friendly Turkish line + log path to stderr,
|
|
13
|
+
* 3. Exit with code 1 (configurable via `exit` for tests).
|
|
14
|
+
*
|
|
15
|
+
* Idempotent: a second call replaces the previous handlers (so the test
|
|
16
|
+
* suite can re-install with fresh spies).
|
|
17
|
+
*/
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
|
|
21
|
+
const ERROR_LOG_PATH = path.join(os.homedir(), '.natureco', 'logs', 'crash.log');
|
|
22
|
+
|
|
23
|
+
let _installed = false;
|
|
24
|
+
let _registered = { rejection: null, exception: null, warning: null };
|
|
25
|
+
|
|
26
|
+
function _defaultAudit() {
|
|
27
|
+
try {
|
|
28
|
+
return require('./audit');
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _serializeError(err) {
|
|
35
|
+
if (!err) return { type: 'unknown', message: 'null' };
|
|
36
|
+
if (err instanceof Error) {
|
|
37
|
+
return {
|
|
38
|
+
type: err.constructor.name,
|
|
39
|
+
message: err.message,
|
|
40
|
+
stack: err.stack ? err.stack.split('\n').slice(0, 20).join('\n') : null,
|
|
41
|
+
code: err.code,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (typeof err === 'object') {
|
|
45
|
+
try { return { type: 'object', message: JSON.stringify(err).slice(0, 1000) }; }
|
|
46
|
+
catch { return { type: 'object', message: String(err) }; }
|
|
47
|
+
}
|
|
48
|
+
return { type: typeof err, message: String(err) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Install global handlers. Returns an `uninstall()` function.
|
|
53
|
+
*
|
|
54
|
+
* @param {{
|
|
55
|
+
* audit?: { logSync: Function, ACTIONS?: Record<string,string> } | null,
|
|
56
|
+
* exit?: (code: number) => void,
|
|
57
|
+
* stderr?: (msg: string) => void,
|
|
58
|
+
* }} [opts]
|
|
59
|
+
*/
|
|
60
|
+
function install(opts = {}) {
|
|
61
|
+
const audit = opts.audit === undefined ? _defaultAudit() : opts.audit;
|
|
62
|
+
const exit = opts.exit || ((code) => process.exit(code));
|
|
63
|
+
const stderr = opts.stderr || ((msg) => process.stderr.write(msg));
|
|
64
|
+
|
|
65
|
+
// Replace any previous handlers (idempotency for tests).
|
|
66
|
+
if (_registered.rejection) process.off('unhandledRejection', _registered.rejection);
|
|
67
|
+
if (_registered.exception) process.off('uncaughtException', _registered.exception);
|
|
68
|
+
|
|
69
|
+
const onRejection = (reason) => {
|
|
70
|
+
const payload = { kind: 'unhandledRejection', error: _serializeError(reason) };
|
|
71
|
+
try { audit?.logSync('error', payload); } catch { /* ignore */ }
|
|
72
|
+
stderr(
|
|
73
|
+
`\n ✗ Beklenmedik bir hata oluştu (unhandled promise rejection).\n` +
|
|
74
|
+
` Detay: ${payload.error.message}\n` +
|
|
75
|
+
` Log: ${ERROR_LOG_PATH}\n` +
|
|
76
|
+
` Hata raporu için: github.com/natureco-official/natureco-cli/issues\n\n`,
|
|
77
|
+
);
|
|
78
|
+
exit(1);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const onException = (err) => {
|
|
82
|
+
const payload = { kind: 'uncaughtException', error: _serializeError(err) };
|
|
83
|
+
try { audit?.logSync('error', payload); } catch { /* ignore */ }
|
|
84
|
+
stderr(
|
|
85
|
+
`\n ✗ Beklenmedik bir hata oluştu (uncaught exception).\n` +
|
|
86
|
+
` Detay: ${payload.error.message}\n` +
|
|
87
|
+
` Log: ${ERROR_LOG_PATH}\n` +
|
|
88
|
+
` Hata raporu için: github.com/natureco-official/natureco-cli/issues\n\n`,
|
|
89
|
+
);
|
|
90
|
+
exit(1);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
process.on('unhandledRejection', onRejection);
|
|
94
|
+
process.on('uncaughtException', onException);
|
|
95
|
+
_registered = { rejection: onRejection, exception: onException, warning: null };
|
|
96
|
+
_installed = true;
|
|
97
|
+
|
|
98
|
+
return function uninstall() {
|
|
99
|
+
if (_registered.rejection) process.off('unhandledRejection', _registered.rejection);
|
|
100
|
+
if (_registered.exception) process.off('uncaughtException', _registered.exception);
|
|
101
|
+
_registered = { rejection: null, exception: null, warning: null };
|
|
102
|
+
_installed = false;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isInstalled() {
|
|
107
|
+
return _installed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
install,
|
|
112
|
+
isInstalled,
|
|
113
|
+
ERROR_LOG_PATH,
|
|
114
|
+
_internals: { _serializeError },
|
|
115
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider detection — single source of truth for "given a base URL
|
|
3
|
+
* (and optionally a model name) which provider family is this?"
|
|
4
|
+
*
|
|
5
|
+
* Previously this logic lived inline in three places:
|
|
6
|
+
* - src/utils/api.js (the canonical detectProvider, exported only
|
|
7
|
+
* informally via its module surface)
|
|
8
|
+
* - src/commands/setup.js (string contains checks against minimax /
|
|
9
|
+
* anthropic / groq, scattered through the OAuth + test flows)
|
|
10
|
+
* - src/utils/api.js again, with `const isMiniMax = baseUrl.includes(
|
|
11
|
+
* 'minimax.io') || baseUrl.includes('minimaxi.com') || baseUrl.includes(
|
|
12
|
+
* 'minimax.cn')` repeated twice (lines 560 + 1017)
|
|
13
|
+
*
|
|
14
|
+
* Refactoring opportunity from the v5.7 audit. This module preserves
|
|
15
|
+
* EXACTLY the same detection rules as the original detectProvider so
|
|
16
|
+
* the migration is a behavioral no-op verifiable by tests.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Family of an inference provider.
|
|
21
|
+
*
|
|
22
|
+
* @typedef {'openai'|'anthropic'|'groq'|'openrouter'|'deepseek'|'mistral'
|
|
23
|
+
* |'together'|'fireworks'|'perplexity'|'ollama'|'minimax'} ProviderName
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} providerUrl
|
|
28
|
+
* @param {string} [model]
|
|
29
|
+
* @returns {ProviderName}
|
|
30
|
+
*/
|
|
31
|
+
function detectProvider(providerUrl, model) {
|
|
32
|
+
const url = (providerUrl || '').toLowerCase();
|
|
33
|
+
const m = (model || '').toLowerCase();
|
|
34
|
+
if (url.includes('anthropic.com') || m.includes('claude')) return 'anthropic';
|
|
35
|
+
if (url.includes('groq.com') || m.includes('groq') || m.includes('llama-3') || m.includes('mixtral')) return 'groq';
|
|
36
|
+
if (url.includes('openrouter.ai')) return 'openrouter';
|
|
37
|
+
if (url.includes('api.deepseek.com') || m.includes('deepseek')) return 'deepseek';
|
|
38
|
+
if (url.includes('mistral.ai') || m.includes('mistral') || m.includes('codestral')) return 'mistral';
|
|
39
|
+
if (url.includes('together.xyz') || m.includes('together')) return 'together';
|
|
40
|
+
if (url.includes('fireworks.ai') || m.includes('fireworks')) return 'fireworks';
|
|
41
|
+
if (url.includes('perplexity.ai') || m.includes('pplx') || m.includes('sonar')) return 'perplexity';
|
|
42
|
+
if (url.includes('localhost') || url.includes('127.0.0.1') || url.includes('ollama')) return 'ollama';
|
|
43
|
+
if (url.includes('minimax.io') || url.includes('minimax')) return 'minimax';
|
|
44
|
+
return 'openai';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Convenience predicates — each one short-circuits on hostname only. */
|
|
48
|
+
function isAnthropic(url) {
|
|
49
|
+
return (url || '').toLowerCase().includes('anthropic.com');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isGroq(url) {
|
|
53
|
+
return (url || '').toLowerCase().includes('groq.com');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* MiniMax has three known production hosts:
|
|
58
|
+
* api.minimax.io — global English-facing
|
|
59
|
+
* api.minimaxi.com — China-facing
|
|
60
|
+
* api.minimax.cn — legacy CN domain seen in some auth tokens
|
|
61
|
+
*/
|
|
62
|
+
function isMiniMax(url) {
|
|
63
|
+
const u = (url || '').toLowerCase();
|
|
64
|
+
return u.includes('minimax.io') || u.includes('minimaxi.com') || u.includes('minimax.cn');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isOllama(url) {
|
|
68
|
+
const u = (url || '').toLowerCase();
|
|
69
|
+
return u.includes('localhost') || u.includes('127.0.0.1') || u.includes('ollama');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
detectProvider,
|
|
74
|
+
isAnthropic,
|
|
75
|
+
isGroq,
|
|
76
|
+
isMiniMax,
|
|
77
|
+
isOllama,
|
|
78
|
+
};
|
package/src/utils/sessions.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const os = require('os');
|
|
4
|
+
const { writeJsonAtomicSync, readJsonSafeSync } = require('./atomic-file');
|
|
4
5
|
|
|
5
6
|
// ── Legacy botId-based sessions ─────────────────────────────────────────────────
|
|
6
7
|
|
|
@@ -36,20 +37,12 @@ function createSession(botId, botName) {
|
|
|
36
37
|
function _saveBotSession(botId, session) {
|
|
37
38
|
ensureSessionsDir(botId);
|
|
38
39
|
const sessionFile = path.join(getSessionsDir(botId), `${session.id}.json`);
|
|
39
|
-
|
|
40
|
+
writeJsonAtomicSync(sessionFile, session);
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
function loadSession(botId, sessionId) {
|
|
43
44
|
const sessionFile = path.join(getSessionsDir(botId), `${sessionId}.json`);
|
|
44
|
-
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
try {
|
|
48
|
-
const data = fs.readFileSync(sessionFile, 'utf-8');
|
|
49
|
-
return JSON.parse(data);
|
|
50
|
-
} catch {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
45
|
+
return readJsonSafeSync(sessionFile, null);
|
|
53
46
|
}
|
|
54
47
|
|
|
55
48
|
function getLatestSession(botId) {
|
|
@@ -110,10 +103,10 @@ function saveSession(commandName, messages, metadata = {}) {
|
|
|
110
103
|
}
|
|
111
104
|
const id = Date.now().toString(36);
|
|
112
105
|
const filename = path.join(SESSIONS_DIR, `${commandName}-${id}.json`);
|
|
113
|
-
|
|
106
|
+
writeJsonAtomicSync(filename, {
|
|
114
107
|
id, commandName, messages, metadata,
|
|
115
|
-
savedAt: new Date().toISOString()
|
|
116
|
-
}
|
|
108
|
+
savedAt: new Date().toISOString(),
|
|
109
|
+
});
|
|
117
110
|
return filename;
|
|
118
111
|
}
|
|
119
112
|
|