natureco-cli 5.7.0 → 5.7.2
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/bin/natureco.js +0 -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 +19 -20
- package/src/commands/setup.js +7 -6
- package/src/tools/dashboard.js +2 -1
- package/src/tools/edit_file.js +143 -0
- package/src/tools/memory_write.js +70 -16
- package/src/tools/read_file.js +122 -63
- package/src/tools/todo_write.js +219 -52
- 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/paste-safe-input.js +173 -0
- 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,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paste-safe-input — Terminal'e uzun/çok satırlı metin yapıştırılınca
|
|
3
|
+
* her satırın ayrı bir "Enter" gibi gönderilip anında submit edilmesini önler.
|
|
4
|
+
*
|
|
5
|
+
* Sorun: Node'un düz `readline` arayüzü, terminalden gelen her "\n" karakterini
|
|
6
|
+
* bir 'line' event'i olarak görür. Terminal "bracketed paste mode" açık değilse
|
|
7
|
+
* (veya açık olsa da bu karakterler ayıklanmazsa), kullanıcı 10 satırlık bir metni
|
|
8
|
+
* yapıştırdığında readline bunu 10 ayrı mesaj gibi okur ve her birini hemen
|
|
9
|
+
* gönderir — kullanıcı paste'i bitirmeden cevaplar gelmeye başlar.
|
|
10
|
+
*
|
|
11
|
+
* Çözüm: Terminalin "bracketed paste" (ESC[200~ ... ESC[201~) işaretleyicilerini
|
|
12
|
+
* dinleyip, bu işaretler arasındaki tüm veriyi tek parça olarak topluyoruz.
|
|
13
|
+
* İçindeki gerçek satır sonlarını geçici bir placeholder ile değiştirip
|
|
14
|
+
* readline'a TEK satır gibi besliyoruz. Kullanıcı gerçekten Enter'a basana kadar
|
|
15
|
+
* hiçbir 'line' event'i tetiklenmez. Enter'a basıldığında, alınan satırdaki
|
|
16
|
+
* placeholder'lar gerçek "\n" karakterine geri çevrilip mesaj olarak kullanılır.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const { PassThrough } = require('stream');
|
|
22
|
+
|
|
23
|
+
const PASTE_START = '\x1b[200~';
|
|
24
|
+
const PASTE_END = '\x1b[201~';
|
|
25
|
+
|
|
26
|
+
// Yapıştırılan içindeki satır sonlarını gizlemek için kullanılan placeholder.
|
|
27
|
+
// Gerçek kullanıcı girdisinde pratikte hiç geçmeyecek bir dizi.
|
|
28
|
+
const NEWLINE_PLACEHOLDER = '\u2424\u2424LINEBREAK\u2424\u2424';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Bracketed paste'i destekleyen bir PassThrough stream döner.
|
|
32
|
+
* `source` (genellikle process.stdin) üzerinden gelen veriyi işler.
|
|
33
|
+
*
|
|
34
|
+
* readline.createInterface({ input: createPasteSafeInput(process.stdin), ... })
|
|
35
|
+
* şeklinde process.stdin yerine kullanılmalı.
|
|
36
|
+
*/
|
|
37
|
+
// str'nin sonunda PASTE_START veya PASTE_END marker'ının bir ön-eki (prefix)
|
|
38
|
+
// olarak duran kısmın uzunluğunu döner (0 = eşleşme yok). Sadece gerçekten
|
|
39
|
+
// marker olabilecek bir kuyruk varsa bekletiyoruz; aksi halde tek tuş
|
|
40
|
+
// yazımında karakterler bir sonraki tuşa kadar ekranda gecikir.
|
|
41
|
+
function trailingMarkerPrefixLen(str, marker) {
|
|
42
|
+
const maxLen = Math.min(str.length, marker.length - 1);
|
|
43
|
+
for (let len = maxLen; len > 0; len--) {
|
|
44
|
+
if (str.slice(str.length - len) === marker.slice(0, len)) return len;
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// TERMINAL-AGNOSTIK PASTE TESPİTİ
|
|
50
|
+
// ---------------------------------
|
|
51
|
+
// Birçok terminal (örn. macOS'un yerleşik Terminal.app'i) bracketed-paste
|
|
52
|
+
// (ESC[200~/[201~) modunu desteklemez/etkinleştirmez. Bu yüzden bracket
|
|
53
|
+
// marker'larına güvenmek yetersiz kalıyor. Daha güvenilir, terminalden
|
|
54
|
+
// bağımsız bir sinyal var: bir gerçek Enter tuşuna basış, işletim sisteminden
|
|
55
|
+
// tek başına ve tek bir karakter ('\r' veya '\n') olarak gelir — başka hiçbir
|
|
56
|
+
// karakterle birlikte değil. Ama bir yapıştırma (paste), terminal emülatörü
|
|
57
|
+
// tarafından TEK SEFERDE (çok karakterli, içinde satır sonları barındıran bir
|
|
58
|
+
// "data" event'i olarak) pty'ye yazılır. Yani: içinde satır sonu BARINDIRAN
|
|
59
|
+
// ama SADECE tek bir satır sonu karakterinden İBARET OLMAYAN bir chunk,
|
|
60
|
+
// hemen hemen kesin olarak bir paste'tir — bracket marker olsun olmasın.
|
|
61
|
+
function isLoneEnterKeystroke(str) {
|
|
62
|
+
return str === '\r' || str === '\n' || str === '\r\n';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function escapeEmbeddedNewlines(str) {
|
|
66
|
+
if (!str || isLoneEnterKeystroke(str)) return str;
|
|
67
|
+
const newlineCount = (str.match(/\r\n|\r|\n/g) || []).length;
|
|
68
|
+
// Terminal-agnostik paste tespiti: sadece içinde birden çok satır
|
|
69
|
+
// barındıran chunk'lar paste kabul edilir. Tek satır + \n normal
|
|
70
|
+
// yazım olabilir (test/paste-safe-input.test.js "normal yazılan
|
|
71
|
+
// satırlar ayrı ayrı submit edilir" senaryosu).
|
|
72
|
+
if (newlineCount === 0) return str;
|
|
73
|
+
if (newlineCount === 1 && str.length < 100) return str;
|
|
74
|
+
return str.replace(/\r\n|\r|\n/g, NEWLINE_PLACEHOLDER);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createPasteSafeInput(source = process.stdin) {
|
|
78
|
+
const proxy = new PassThrough();
|
|
79
|
+
|
|
80
|
+
let inPaste = false;
|
|
81
|
+
let carry = ''; // marker'ın chunk sınırında bölünmesine karşı tampon
|
|
82
|
+
|
|
83
|
+
const onData = (chunk) => {
|
|
84
|
+
let str = carry + chunk.toString('utf8');
|
|
85
|
+
carry = '';
|
|
86
|
+
let out = '';
|
|
87
|
+
|
|
88
|
+
while (str.length) {
|
|
89
|
+
if (inPaste) {
|
|
90
|
+
const endIdx = str.indexOf(PASTE_END);
|
|
91
|
+
if (endIdx === -1) {
|
|
92
|
+
const keepLen = trailingMarkerPrefixLen(str, PASTE_END);
|
|
93
|
+
const flushLen = str.length - keepLen;
|
|
94
|
+
out += str.slice(0, flushLen).replace(/\r\n|\r|\n/g, NEWLINE_PLACEHOLDER);
|
|
95
|
+
carry = str.slice(flushLen);
|
|
96
|
+
str = '';
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
const pasted = str.slice(0, endIdx);
|
|
100
|
+
out += pasted.replace(/\r\n|\r|\n/g, NEWLINE_PLACEHOLDER);
|
|
101
|
+
inPaste = false;
|
|
102
|
+
str = str.slice(endIdx + PASTE_END.length);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const startIdx = str.indexOf(PASTE_START);
|
|
107
|
+
if (startIdx === -1) {
|
|
108
|
+
const keepLen = trailingMarkerPrefixLen(str, PASTE_START);
|
|
109
|
+
const flushLen = str.length - keepLen;
|
|
110
|
+
// Bracket marker'ı olmayan terminallerde de paste'i yakala: bu segment
|
|
111
|
+
// tek başına bir Enter tuşu değilse ve içinde satır sonu varsa, o
|
|
112
|
+
// satır sonlarını gizle (terminal-agnostik heuristik).
|
|
113
|
+
out += escapeEmbeddedNewlines(str.slice(0, flushLen));
|
|
114
|
+
carry = str.slice(flushLen);
|
|
115
|
+
str = '';
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
out += escapeEmbeddedNewlines(str.slice(0, startIdx));
|
|
119
|
+
str = str.slice(startIdx + PASTE_START.length);
|
|
120
|
+
inPaste = true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (out) proxy.write(out);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
source.on('data', onData);
|
|
127
|
+
source.on('end', () => {
|
|
128
|
+
if (carry) {
|
|
129
|
+
proxy.write(inPaste ? carry.replace(/\r\n|\r|\n/g, NEWLINE_PLACEHOLDER) : carry);
|
|
130
|
+
carry = '';
|
|
131
|
+
}
|
|
132
|
+
proxy.end();
|
|
133
|
+
});
|
|
134
|
+
source.on('error', (e) => proxy.emit('error', e));
|
|
135
|
+
|
|
136
|
+
// process.stdin'in raw-mode/pause-resume API'lerini proxy üzerinden de eriştir,
|
|
137
|
+
// readline bunları çağırabiliyor.
|
|
138
|
+
proxy.isTTY = source.isTTY;
|
|
139
|
+
proxy.setRawMode = source.setRawMode ? source.setRawMode.bind(source) : undefined;
|
|
140
|
+
proxy.ref = source.ref ? source.ref.bind(source) : undefined;
|
|
141
|
+
proxy.unref = source.unref ? source.unref.bind(source) : undefined;
|
|
142
|
+
|
|
143
|
+
return proxy;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Terminalde bracketed paste mode'u açar. Çoğu modern terminal (iTerm2, Terminal.app,
|
|
148
|
+
* Windows Terminal, VS Code, gnome-terminal vb.) bunu destekler. Desteklemeyen
|
|
149
|
+
* terminallerde bu escape kodu sessizce yok sayılır, davranış eskisi gibi kalır.
|
|
150
|
+
*/
|
|
151
|
+
function enableBracketedPaste(out = process.stdout) {
|
|
152
|
+
if (out.isTTY) out.write('\x1b[?2004h');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function disableBracketedPaste(out = process.stdout) {
|
|
156
|
+
if (out.isTTY) out.write('\x1b[?2004l');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* readline'dan gelen satırı, paste placeholder'larını gerçek "\n" karakterine
|
|
161
|
+
* geri çevirerek normalize eder. Gönderilecek mesaj olarak bunu kullan.
|
|
162
|
+
*/
|
|
163
|
+
function restoreNewlines(line) {
|
|
164
|
+
return line.split(NEWLINE_PLACEHOLDER).join('\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
createPasteSafeInput,
|
|
169
|
+
enableBracketedPaste,
|
|
170
|
+
disableBracketedPaste,
|
|
171
|
+
restoreNewlines,
|
|
172
|
+
NEWLINE_PLACEHOLDER,
|
|
173
|
+
};
|
|
@@ -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
|
+
};
|