blyuecode 0.3.5 → 0.3.7

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.
@@ -21,20 +21,89 @@ export function loadSession(cwd = process.cwd()) {
21
21
  return null;
22
22
  }
23
23
  }
24
+ const MAX_SESSION_BYTES = Number(process.env.BLYUE_MAX_SESSION_BYTES) || 5 * 1024 * 1024; // 5 MB per session
25
+ const MAX_DIR_BYTES = Number(process.env.BLYUE_MAX_SESSIONS_DIR_BYTES) || 50 * 1024 * 1024; // 50 MB total
26
+ /**
27
+ * Trim the in-memory messages array so a session file never grows past
28
+ * MAX_SESSION_BYTES. Drops oldest non-system messages first, keeping a
29
+ * marker so the user (and the model) knows history was clipped.
30
+ */
31
+ function trimToBudget(messages) {
32
+ const size = (arr) => Buffer.byteLength(JSON.stringify(arr, null, 2), 'utf8');
33
+ if (size(messages) <= MAX_SESSION_BYTES)
34
+ return messages;
35
+ // Keep the most recent N messages, dropping from the front. Stop as
36
+ // soon as we're under budget; always keep at least the last 6.
37
+ let arr = messages.slice();
38
+ while (arr.length > 6 && size(arr) > MAX_SESSION_BYTES) {
39
+ arr.shift();
40
+ }
41
+ if (size(arr) > MAX_SESSION_BYTES) {
42
+ // Even the tail is too big — hard-truncate the largest text fields.
43
+ arr = arr.map((m) => {
44
+ const c = m.content;
45
+ if (typeof c === 'string' && c.length > 8000) {
46
+ return { ...m, content: c.slice(0, 2000) + '\n[truncated]\n' + c.slice(-4000) };
47
+ }
48
+ return m;
49
+ });
50
+ }
51
+ return [
52
+ { role: 'system', content: '[session trimmed by BLYUE to fit size limit]' },
53
+ ...arr,
54
+ ];
55
+ }
56
+ /**
57
+ * Evict oldest session files (never the active one) until the sessions
58
+ * directory is under MAX_DIR_BYTES.
59
+ */
60
+ function pruneDirectory(activeFile) {
61
+ if (!fs.existsSync(SESSIONS_DIR))
62
+ return;
63
+ const entries = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.json'));
64
+ let total = 0;
65
+ const stats = [];
66
+ for (const f of entries) {
67
+ const full = path.join(SESSIONS_DIR, f);
68
+ try {
69
+ const st = fs.statSync(full);
70
+ total += st.size;
71
+ stats.push({ file: f, size: st.size, mtime: st.mtimeMs });
72
+ }
73
+ catch { }
74
+ }
75
+ if (total <= MAX_DIR_BYTES)
76
+ return;
77
+ stats.sort((a, b) => a.mtime - b.mtime); // oldest first
78
+ for (const { file, size } of stats) {
79
+ if (total <= MAX_DIR_BYTES)
80
+ break;
81
+ if (path.join(SESSIONS_DIR, file) === activeFile)
82
+ continue;
83
+ try {
84
+ fs.unlinkSync(path.join(SESSIONS_DIR, file));
85
+ total -= size;
86
+ }
87
+ catch { }
88
+ }
89
+ }
24
90
  export function saveSession(messages, cwd = process.cwd()) {
25
91
  const file = sessionFile(cwd);
92
+ const trimmed = trimToBudget(messages);
26
93
  const data = {
27
94
  project: cwd,
28
95
  updatedAt: Date.now(),
29
- messages,
96
+ messages: trimmed,
30
97
  };
31
98
  fs.writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 });
99
+ pruneDirectory(file);
32
100
  }
33
101
  export function clearSession(cwd = process.cwd()) {
34
102
  const file = sessionFile(cwd);
35
103
  if (fs.existsSync(file))
36
104
  fs.unlinkSync(file);
37
105
  }
106
+ export { MAX_SESSION_BYTES, MAX_DIR_BYTES };
38
107
  export function listSessions() {
39
108
  if (!fs.existsSync(SESSIONS_DIR))
40
109
  return [];
@@ -4,6 +4,8 @@ import { Box, Text, useInput, useStdout } from 'ink';
4
4
  import { KeyInput } from './KeyInput.js';
5
5
  import { getConfig, saveConfig, applyConfig } from '../core/config.js';
6
6
  import { detectOllama } from '../core/localCheck.js';
7
+ import { loadMcpConfig, saveMcpConfig } from '../core/mcp.js';
8
+ import { initMcpRegistry, closeAllMcp, listMcpServers, } from '../core/mcpRegistry.js';
7
9
  import { theme } from './theme.js';
8
10
  export const Settings = ({ onClose, onModelChange }) => {
9
11
  const { stdout } = useStdout();
@@ -15,6 +17,15 @@ export const Settings = ({ onClose, onModelChange }) => {
15
17
  const [testResult, setTestResult] = useState(null);
16
18
  const [ollamaModels, setOllamaModels] = useState([]);
17
19
  const [ollamaChecked, setOllamaChecked] = useState(false);
20
+ // MCP draft state
21
+ const [mcpCfg, setMcpCfg] = useState(() => loadMcpConfig());
22
+ const [mcpCursor, setMcpCursor] = useState(0);
23
+ const [mcpDraftName, setMcpDraftName] = useState('');
24
+ const [mcpDraftCommand, setMcpDraftCommand] = useState('');
25
+ const [mcpDraftArgs, setMcpDraftArgs] = useState('');
26
+ const [mcpDraftAutoAllow, setMcpDraftAutoAllow] = useState(false);
27
+ const [mcpConfirmDel, setMcpConfirmDel] = useState(null);
28
+ const [mcpStatus, setMcpStatus] = useState(null);
18
29
  useEffect(() => {
19
30
  (async () => {
20
31
  const models = await detectOllama();
@@ -27,6 +38,22 @@ export const Settings = ({ onClose, onModelChange }) => {
27
38
  applyConfig(next);
28
39
  setCfg(next);
29
40
  };
41
+ const persistMcp = (next) => {
42
+ saveMcpConfig(next);
43
+ setMcpCfg(next);
44
+ };
45
+ const reloadMcp = async (msg) => {
46
+ setMcpStatus('reloading…');
47
+ try {
48
+ await closeAllMcp();
49
+ await initMcpRegistry();
50
+ setMcpStatus(msg);
51
+ setTimeout(() => setMcpStatus(null), 2500);
52
+ }
53
+ catch (err) {
54
+ setMcpStatus('✗ ' + err.message);
55
+ }
56
+ };
30
57
  const mainItems = [
31
58
  { label: 'API key', hint: cfg.apiKey ? '•••' + cfg.apiKey.slice(-4) : 'not set', action: () => setScreen('apikey') },
32
59
  { label: 'Base URL', hint: cfg.baseUrl || 'default (OpenAI)', action: () => setScreen('baseurl') },
@@ -38,9 +65,17 @@ export const Settings = ({ onClose, onModelChange }) => {
38
65
  : 'not configured',
39
66
  action: () => setScreen('n8n'),
40
67
  },
68
+ {
69
+ label: 'MCP servers',
70
+ hint: (() => {
71
+ const n = Object.keys(mcpCfg.servers || {}).length;
72
+ return n === 0 ? 'none configured' : `${n} server${n === 1 ? '' : 's'}`;
73
+ })(),
74
+ action: () => { setMcpCursor(0); setScreen('mcp'); },
75
+ },
41
76
  { label: 'Close', hint: 'esc', action: onClose },
42
77
  ];
43
- // ---- Main menu navigation ----
78
+ // ---- Main menu ----
44
79
  useInput((_, key) => {
45
80
  if (key.escape)
46
81
  return onClose();
@@ -86,14 +121,112 @@ export const Settings = ({ onClose, onModelChange }) => {
86
121
  if (key.return)
87
122
  n8nItems[n8nCursor].action();
88
123
  }, { isActive: screen === 'n8n' });
89
- // ---- n8n test ----
90
124
  useInput(() => setScreen('n8n'), { isActive: screen === 'n8n-test' });
125
+ // ---- MCP submenu ----
126
+ const serverNames = Object.keys(mcpCfg.servers || {});
127
+ const mcpItems = [
128
+ { label: '+ Add server', hint: 'new MCP server', action: () => {
129
+ setMcpDraftName('');
130
+ setMcpDraftCommand('');
131
+ setMcpDraftArgs('');
132
+ setMcpDraftAutoAllow(false);
133
+ setScreen('mcp-add-name');
134
+ } },
135
+ ...serverNames.map((name) => {
136
+ const s = mcpCfg.servers[name];
137
+ const status = listMcpServers().find((x) => x.name === name);
138
+ const connected = status && !status.error;
139
+ const toolCount = status?.toolCount ?? 0;
140
+ const cmd = 'command' in s ? `${s.command} ${(s.args || []).join(' ')}` : s.url;
141
+ const autoTag = s.autoAllow ? ' [auto]' : '';
142
+ return {
143
+ label: name,
144
+ hint: connected
145
+ ? `● ${toolCount} tools${autoTag} · delete with [d]`
146
+ : `✗ ${cmd.slice(0, 30)}`,
147
+ action: () => {
148
+ setMcpConfirmDel(name);
149
+ setScreen('mcp-confirm-del');
150
+ },
151
+ };
152
+ }),
153
+ { label: 'Reload registry', hint: 'reconnect all servers', action: () => { void reloadMcp('✓ reloaded'); } },
154
+ { label: 'Back', hint: 'esc', action: () => setScreen('main') },
155
+ ];
156
+ useInput((input, key) => {
157
+ if (key.escape)
158
+ return setScreen('main');
159
+ if (key.upArrow)
160
+ setMcpCursor((c) => (c - 1 + mcpItems.length) % mcpItems.length);
161
+ if (key.downArrow)
162
+ setMcpCursor((c) => (c + 1) % mcpItems.length);
163
+ if (input === 'd' || input === 'D') {
164
+ const item = mcpItems[mcpCursor];
165
+ if (item && serverNames.includes(item.label)) {
166
+ setMcpConfirmDel(item.label);
167
+ setScreen('mcp-confirm-del');
168
+ }
169
+ }
170
+ if (key.return)
171
+ mcpItems[mcpCursor].action();
172
+ }, { isActive: screen === 'mcp' });
173
+ // ---- MCP confirm delete ----
174
+ useInput((input, key) => {
175
+ if (input === 'y' || input === 'Y') {
176
+ if (mcpConfirmDel) {
177
+ const next = { ...mcpCfg, servers: { ...mcpCfg.servers } };
178
+ delete next.servers[mcpConfirmDel];
179
+ persistMcp(next);
180
+ void reloadMcp(`✓ removed ${mcpConfirmDel}`);
181
+ }
182
+ setMcpConfirmDel(null);
183
+ setScreen('mcp');
184
+ return;
185
+ }
186
+ if (input === 'n' || input === 'N' || key.escape) {
187
+ setMcpConfirmDel(null);
188
+ setScreen('mcp');
189
+ }
190
+ }, { isActive: screen === 'mcp-confirm-del' });
191
+ // ---- MCP add wizard: autoAllow toggle ----
192
+ useInput((input, key) => {
193
+ if (key.escape)
194
+ return setScreen('mcp');
195
+ if (input === 'y' || input === 'Y') {
196
+ finishAdd(true);
197
+ return;
198
+ }
199
+ if (input === 'n' || input === 'N' || key.return) {
200
+ finishAdd(false);
201
+ }
202
+ }, { isActive: screen === 'mcp-add-autollw' });
203
+ const finishAdd = (autoAllow) => {
204
+ if (!mcpDraftName.trim() || !mcpDraftCommand.trim()) {
205
+ setScreen('mcp');
206
+ return;
207
+ }
208
+ const args = mcpDraftArgs
209
+ .split(/\s+/)
210
+ .map((s) => s.trim())
211
+ .filter(Boolean);
212
+ const next = {
213
+ servers: {
214
+ ...(mcpCfg.servers || {}),
215
+ [mcpDraftName.trim()]: {
216
+ command: mcpDraftCommand.trim(),
217
+ args,
218
+ autoAllow,
219
+ },
220
+ },
221
+ };
222
+ persistMcp(next);
223
+ void reloadMcp(`✓ added ${mcpDraftName.trim()}`);
224
+ setScreen('mcp');
225
+ };
91
226
  // ---- Frame helpers ----
92
227
  const inner = width - 4;
93
228
  const top = (label) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(Math.max(1, inner - label.length - 3)) + '╮' })] }));
94
229
  const bot = (_jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }));
95
- // ⚠️ Changed from <Text> to <Box flexDirection="row"> — callers pass a
96
- // <Box> containing a prompt + <KeyInput>, and Ink forbids Box-in-Text.
97
230
  const row = (content) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.muted, children: '│ ' }), content] }));
98
231
  // ---- Main screen ----
99
232
  if (screen === 'main') {
@@ -104,7 +237,7 @@ export const Settings = ({ onClose, onModelChange }) => {
104
237
  return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: it.label.padEnd(20) }), _jsx(Text, { color: theme.muted, children: it.hint })] }, it.label));
105
238
  }), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 esc close" })] }));
106
239
  }
107
- // ---- API key / Base URL editors ----
240
+ // ---- API key / Base URL ----
108
241
  if (screen === 'apikey' || screen === 'baseurl') {
109
242
  const isKey = screen === 'apikey';
110
243
  const title = isKey ? 'api key' : 'base url';
@@ -115,7 +248,7 @@ export const Settings = ({ onClose, onModelChange }) => {
115
248
  };
116
249
  return (_jsxs(Box, { flexDirection: "column", children: [top(title), row(_jsx(Text, { color: theme.muted, children: isKey ? 'Paste your new API key' : 'e.g. http://localhost:11434/v1' })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: value, onChange: setValue, placeholder: isKey ? 'sk-...' : 'https://...', masked: isKey, onSubmit: () => setScreen('main') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 backspace \u00B7 \u21B5 save \u00B7 esc back" })] }));
117
250
  }
118
- // ---- Model editor ----
251
+ // ---- Model ----
119
252
  if (screen === 'model') {
120
253
  const isLocal = (cfg.baseUrl || '').includes('localhost');
121
254
  return (_jsxs(Box, { flexDirection: "column", children: [top('model'), row(_jsx(Text, { color: theme.muted, children: isLocal && ollamaModels.length > 0
@@ -132,7 +265,7 @@ export const Settings = ({ onClose, onModelChange }) => {
132
265
  return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: it.label.padEnd(20) }), _jsx(Text, { color: theme.muted, children: it.hint })] }, it.label));
133
266
  }), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 esc back" })] }));
134
267
  }
135
- // ---- n8n URL / key editors ----
268
+ // ---- n8n url/key ----
136
269
  if (screen === 'n8n-url' || screen === 'n8n-key') {
137
270
  const isKey = screen === 'n8n-key';
138
271
  const title = isKey ? 'n8n api key' : 'n8n url';
@@ -143,9 +276,36 @@ export const Settings = ({ onClose, onModelChange }) => {
143
276
  };
144
277
  return (_jsxs(Box, { flexDirection: "column", children: [top(title), row(_jsx(Text, { color: theme.muted, children: isKey ? 'Settings → n8n API → create key' : 'e.g. http://localhost:5678' })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: value, onChange: setValue, placeholder: isKey ? 'n8n_api_...' : 'http://localhost:5678', masked: isKey, onSubmit: () => setScreen('n8n') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 backspace \u00B7 \u21B5 save \u00B7 esc back" })] }));
145
278
  }
146
- // ---- n8n test result ----
279
+ // ---- n8n test ----
147
280
  if (screen === 'n8n-test') {
148
281
  return (_jsxs(Box, { flexDirection: "column", children: [top('n8n test'), row(_jsx(Text, { color: testResult?.startsWith('✓') ? theme.success : theme.muted, children: testResult || 'testing…' })), bot, _jsx(Text, { color: theme.muted, children: "press any key to go back" })] }));
149
282
  }
283
+ // ---- MCP submenu ----
284
+ if (screen === 'mcp') {
285
+ return (_jsxs(Box, { flexDirection: "column", children: [top('mcp servers'), row(_jsx(Text, { color: theme.muted, children: "config: ~/.blyue/mcp.json" })), row(_jsxs(Text, { color: theme.muted, children: [serverNames.length, " server", serverNames.length === 1 ? '' : 's', mcpStatus ? ' · ' : '', mcpStatus && (_jsx(Text, { color: mcpStatus.startsWith('✗') ? theme.error : theme.success, children: mcpStatus }))] })), _jsx(Text, { color: theme.muted, children: '│ ' + '─'.repeat(Math.max(1, inner - 2)) }), mcpItems.map((it, i) => {
286
+ const active = i === mcpCursor;
287
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: it.label.padEnd(22) }), _jsx(Text, { color: theme.muted, children: it.hint })] }, it.label));
288
+ }), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 [d] delete \u00B7 esc back" })] }));
289
+ }
290
+ // ---- MCP confirm delete ----
291
+ if (screen === 'mcp-confirm-del') {
292
+ return (_jsxs(Box, { flexDirection: "column", children: [top('delete server'), row(_jsxs(Text, { color: theme.warning, children: ["Delete MCP server \"", mcpConfirmDel, "\"?"] })), row(_jsx(Text, { color: theme.muted, children: "This removes it from mcp.json." })), bot, _jsxs(Text, { children: [_jsx(Text, { color: theme.success, bold: true, children: " [y]" }), _jsx(Text, { color: theme.muted, children: " yes " }), _jsx(Text, { color: theme.error, bold: true, children: "[n]" }), _jsx(Text, { color: theme.muted, children: " no" })] })] }));
293
+ }
294
+ // ---- MCP add wizard: name ----
295
+ if (screen === 'mcp-add-name') {
296
+ return (_jsxs(Box, { flexDirection: "column", children: [top('add mcp · step 1/4'), row(_jsx(Text, { color: theme.muted, children: "Name (unique, e.g. \"filesystem\")" })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: mcpDraftName, onChange: setMcpDraftName, placeholder: "filesystem", onSubmit: () => setScreen('mcp-add-command') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 next \u00B7 esc cancel" })] }));
297
+ }
298
+ // ---- MCP add wizard: command ----
299
+ if (screen === 'mcp-add-command') {
300
+ return (_jsxs(Box, { flexDirection: "column", children: [top('add mcp · step 2/4'), row(_jsx(Text, { color: theme.muted, children: "Command (binary to run)" })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: mcpDraftCommand, onChange: setMcpDraftCommand, placeholder: "npx", onSubmit: () => setScreen('mcp-add-args') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 next \u00B7 esc cancel" })] }));
301
+ }
302
+ // ---- MCP add wizard: args ----
303
+ if (screen === 'mcp-add-args') {
304
+ return (_jsxs(Box, { flexDirection: "column", children: [top('add mcp · step 3/4'), row(_jsx(Text, { color: theme.muted, children: "Args (space-separated, can be empty)" })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: mcpDraftArgs, onChange: setMcpDraftArgs, placeholder: "-y @modelcontextprotocol/server-filesystem /home/user", onSubmit: () => setScreen('mcp-add-autollw') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 next \u00B7 esc cancel" })] }));
305
+ }
306
+ // ---- MCP add wizard: autoAllow ----
307
+ if (screen === 'mcp-add-autollw') {
308
+ return (_jsxs(Box, { flexDirection: "column", children: [top('add mcp · step 4/4'), row(_jsx(Text, { color: theme.muted, children: "Auto-allow calls to this server (skip permission prompts)?" })), row(_jsx(Text, { color: theme.warning, children: "Only enable for servers you fully trust." })), bot, _jsxs(Text, { children: [_jsx(Text, { color: theme.success, bold: true, children: " [y]" }), _jsx(Text, { color: theme.muted, children: " yes " }), _jsx(Text, { color: theme.error, bold: true, children: "[n]" }), _jsx(Text, { color: theme.muted, children: " no (recommended)" })] })] }));
309
+ }
150
310
  return null;
151
311
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",