termux-dev 1.9.3 → 1.9.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.
@@ -0,0 +1,172 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { SessionManager, sessionsDir } from './session.js';
4
+ /**
5
+ * Search in the chats devx saved (`~/.devx/sessions`). Only what people said is indexed: the messages
6
+ * of the user and the answers of the model. Tool output (file contents, command output) is left out:
7
+ * it is huge, it is noise for a search, and it can be read with the chat when it is needed.
8
+ */
9
+ const MAX_TEXT = 6000;
10
+ const STOPWORDS = new Set(('the a an and or of to in on at is are was were be it this that for with as by from not do does did have has had i you we they he she me my our your ' +
11
+ 'как что это в на и не с по для из к у о за от до так вот или но же ли бы мы вы я он она они мой наш ваш там тут все всё был была было были').split(' '));
12
+ export function wordsOf(text) {
13
+ return Array.from(new Set((text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter((w) => w.length >= 2)));
14
+ }
15
+ /** Words of a query: the meaningful ones (small filler words are dropped unless nothing else is left). */
16
+ export function queryWords(query) {
17
+ const all = wordsOf(query);
18
+ const meaningful = all.filter((w) => !STOPWORDS.has(w));
19
+ return (meaningful.length > 0 ? meaningful : all).slice(0, 8);
20
+ }
21
+ /**
22
+ * How well a word of the text fits a word of the query: 2 the same, 1 the same start (other endings:
23
+ * page/pages, прошлый/прошлом), 0 not at all.
24
+ */
25
+ function strength(token, q) {
26
+ if (token === q)
27
+ return 2;
28
+ if (token.charCodeAt(0) !== q.charCodeAt(0))
29
+ return 0;
30
+ const min = Math.min(token.length, q.length);
31
+ if (min < 4)
32
+ return 0;
33
+ let k = 1;
34
+ while (k < min && token[k] === q[k])
35
+ k++;
36
+ return k >= Math.max(4, min - 2) ? 1 : 0;
37
+ }
38
+ function scoreWords(words, qs) {
39
+ let score = 0;
40
+ let matched = 0;
41
+ let at;
42
+ for (const q of qs) {
43
+ let best = 0;
44
+ let bestWord;
45
+ for (const w of words) {
46
+ const s = strength(w, q);
47
+ if (s > best) {
48
+ best = s;
49
+ bestWord = w;
50
+ if (s === 2)
51
+ break;
52
+ }
53
+ }
54
+ if (best > 0) {
55
+ score += best;
56
+ matched++;
57
+ at ??= bestWord;
58
+ }
59
+ }
60
+ return { score, matched, at };
61
+ }
62
+ export function indexSession(session) {
63
+ const messages = [];
64
+ session.messages.forEach((m, i) => {
65
+ if (m.role !== 'user' && m.role !== 'assistant')
66
+ return;
67
+ const text = (m.content || '').trim().slice(0, MAX_TEXT);
68
+ if (!text)
69
+ return;
70
+ messages.push({ i, role: m.role, text, words: wordsOf(text) });
71
+ });
72
+ return {
73
+ id: session.id,
74
+ short: session.id.split('_').pop() || session.id,
75
+ title: session.title,
76
+ cwd: session.cwd,
77
+ model: session.model,
78
+ createdAt: session.createdAt,
79
+ updatedAt: session.updatedAt,
80
+ messageCount: session.messages.filter((m) => m.role !== 'system').length,
81
+ titleWords: wordsOf(session.title || ''),
82
+ messages,
83
+ };
84
+ }
85
+ const cache = new Map();
86
+ /** All saved chats, the newest first. A chat that did not change since the last search is not read again. */
87
+ export async function loadChatIndex() {
88
+ const dir = sessionsDir();
89
+ let files;
90
+ try {
91
+ files = (await fs.readdir(dir)).filter((f) => f.endsWith('.json'));
92
+ }
93
+ catch {
94
+ return [];
95
+ }
96
+ const seen = new Set();
97
+ const entries = [];
98
+ for (const file of files) {
99
+ const full = path.join(dir, file);
100
+ seen.add(full);
101
+ try {
102
+ const st = await fs.stat(full);
103
+ let hit = cache.get(full);
104
+ if (!hit || hit.mtimeMs !== st.mtimeMs || hit.size !== st.size) {
105
+ const parsed = JSON.parse(await fs.readFile(full, 'utf8'));
106
+ hit = { mtimeMs: st.mtimeMs, size: st.size, entry: parsed && parsed.id && Array.isArray(parsed.messages) ? indexSession(parsed) : null };
107
+ cache.set(full, hit);
108
+ }
109
+ if (hit.entry && hit.entry.messageCount > 0)
110
+ entries.push(hit.entry);
111
+ }
112
+ catch { }
113
+ }
114
+ for (const key of cache.keys())
115
+ if (!seen.has(key))
116
+ cache.delete(key);
117
+ return entries.sort((a, b) => b.updatedAt - a.updatedAt);
118
+ }
119
+ function snippetAround(text, word) {
120
+ const flat = text.replace(/\s+/g, ' ').trim();
121
+ const pos = word ? flat.toLowerCase().indexOf(word) : -1;
122
+ const start = Math.max(0, (pos < 0 ? 0 : pos) - 80);
123
+ const end = Math.min(flat.length, start + 260);
124
+ return `${start > 0 ? '…' : ''}${flat.slice(start, end)}${end < flat.length ? '…' : ''}`;
125
+ }
126
+ /** The chats that talk about the query, the best first; each with the messages that match best. */
127
+ export function searchEntries(entries, query, opts = {}) {
128
+ const qs = queryWords(query);
129
+ if (qs.length === 0)
130
+ return [];
131
+ const need = Math.ceil(qs.length / 2);
132
+ const limit = Math.min(10, Math.max(1, opts.limit ?? 5));
133
+ const hits = [];
134
+ for (const entry of entries) {
135
+ if (entry.id === opts.excludeId)
136
+ continue;
137
+ if (opts.cwd && entry.cwd !== opts.cwd)
138
+ continue;
139
+ const title = scoreWords(entry.titleWords, qs);
140
+ const scored = entry.messages
141
+ .map((m) => ({ m, s: scoreWords(m.words, qs) }))
142
+ .filter((x) => x.s.matched > 0)
143
+ .sort((a, b) => b.s.score - a.s.score);
144
+ const best = scored[0]?.s.matched ?? 0;
145
+ if (best < need && title.matched < need)
146
+ continue;
147
+ const rest = scored.slice(1).reduce((n, x) => n + x.s.score, 0);
148
+ const score = (scored[0]?.s.score ?? 0) * 3 + title.score * 2 + Math.min(rest, 10) * 0.2;
149
+ hits.push({
150
+ entry,
151
+ score,
152
+ snippets: scored
153
+ .filter((x) => x.s.matched >= Math.min(need, best))
154
+ .slice(0, 3)
155
+ .sort((a, b) => a.m.i - b.m.i)
156
+ .map((x) => ({ i: x.m.i, role: x.m.role, text: snippetAround(x.m.text, x.s.at) })),
157
+ });
158
+ }
159
+ return hits.sort((a, b) => b.score - a.score || b.entry.updatedAt - a.entry.updatedAt).slice(0, limit);
160
+ }
161
+ /** The chat a reference points to: the full id, or the short form (`47unz`, `#47unz`). */
162
+ export function findEntry(entries, ref) {
163
+ const r = ref.trim().replace(/^#/, '');
164
+ return entries.find((e) => e.id === r || e.short === r);
165
+ }
166
+ export function activeChatId() {
167
+ return SessionManager.activeId;
168
+ }
169
+ /** For tests: forget what was read. */
170
+ export function clearChatIndexCache() {
171
+ cache.clear();
172
+ }
package/dist/core/loop.js CHANGED
@@ -46,6 +46,13 @@ export function getActionDescription(toolName, args) {
46
46
  if (t === 'todo_list' || t === 'update_todos') {
47
47
  return `→ Update Plan & Tasks`;
48
48
  }
49
+ if (t === 'search_chats') {
50
+ return args?.id ? `→ Chats: read #${String(args.id).replace(/^#/, '')}${args.from ? ` from ${args.from}` : ''}` : args?.query ? `→ Chats: search "${args.query}"` : `→ Chats: recent`;
51
+ }
52
+ if (t === 'mcp_search') {
53
+ const what = Array.isArray(args?.names) && args.names.length ? `load ${args.names.join(', ')}` : args?.query ? `find "${args.query}"` : args?.server ? `tools of ${args.server}` : 'servers';
54
+ return `→ MCP: ${what.length > 120 ? what.slice(0, 117) + '...' : what}`;
55
+ }
49
56
  if (t.startsWith('mcp__')) {
50
57
  const [, server = 'mcp', ...rest] = toolName.split('__');
51
58
  let brief = '';
@@ -80,12 +87,14 @@ export class Agent {
80
87
  config;
81
88
  provider;
82
89
  tools;
90
+ source;
83
91
  history;
84
92
  guard;
85
93
  constructor(config, provider, tools, history, guard) {
86
94
  this.config = config;
87
95
  this.provider = provider;
88
- this.tools = new Map(tools.map((t) => [t.name, t]));
96
+ this.source = Array.isArray(tools) ? undefined : tools;
97
+ this.tools = new Map((Array.isArray(tools) ? tools : tools.current()).map((t) => [t.name, t]));
89
98
  this.history = history;
90
99
  this.guard = guard;
91
100
  }
@@ -109,6 +118,9 @@ export class Agent {
109
118
  this.history.addMessage({ role: 'user', content: news });
110
119
  yield { type: 'background_note', content: news };
111
120
  }
121
+ // A tool source is asked again for every request: tools loaded in the previous step are there now.
122
+ if (this.source)
123
+ this.tools = new Map(this.source.current().map((t) => [t.name, t]));
112
124
  const toolDefinitions = Array.from(this.tools.values()).map((t) => t.definition);
113
125
  // Tool schemas are sent with every request and count against the context window.
114
126
  const schemaTokens = toolSchemaTokens(toolDefinitions);
@@ -270,9 +282,14 @@ export class Agent {
270
282
  let result = '';
271
283
  // Pictures a tool wants the model to see (read_file on an image) travel with its result.
272
284
  const pictures = [];
273
- const tool = this.tools.get(call.name);
285
+ let tool = this.tools.get(call.name);
286
+ // An earlier call of this same reply (mcp_search) may have just loaded it.
287
+ if (!tool && this.source) {
288
+ this.tools = new Map(this.source.current().map((t) => [t.name, t]));
289
+ tool = this.tools.get(call.name);
290
+ }
274
291
  if (!tool) {
275
- result = `Error: Tool '${call.name}' not found.`;
292
+ result = this.source?.explainMissing?.(call.name) ?? `Error: Tool '${call.name}' not found.`;
276
293
  }
277
294
  else if (call.argsError) {
278
295
  // Never run a tool with arguments we could not parse ({} could mean something else).
@@ -5,12 +5,19 @@ import os from 'os';
5
5
  import { ensureSecureDir, writeSecureFile } from './secure-file.js';
6
6
  import { externalizeImages, imagesDirFor, internalizeImages, pruneImages } from './session-media.js';
7
7
  const SESSIONS_DIR = path.join(os.homedir(), '.devx', 'sessions');
8
+ /** Where the chats are saved. */
9
+ export function sessionsDir() {
10
+ return SESSIONS_DIR;
11
+ }
8
12
  export class SessionManager {
13
+ /** The id of the chat that is open now (it is in the context already, so a search of earlier chats leaves it out). */
14
+ static activeId = '';
9
15
  currentSession;
10
16
  /** The files of the pictures already seen (see session-media.ts). */
11
17
  media = new Map();
12
18
  constructor(model, planMode) {
13
19
  this.currentSession = this.createEmptySession(model, planMode);
20
+ SessionManager.activeId = this.currentSession.id;
14
21
  }
15
22
  createEmptySession(model, planMode) {
16
23
  const now = Date.now();
@@ -23,6 +30,7 @@ export class SessionManager {
23
30
  model,
24
31
  planMode,
25
32
  messages: [],
33
+ cwd: process.cwd(),
26
34
  totalCost: 0
27
35
  };
28
36
  }
@@ -39,6 +47,7 @@ export class SessionManager {
39
47
  }
40
48
  startNewSession(model, planMode) {
41
49
  this.currentSession = this.createEmptySession(model, planMode);
50
+ SessionManager.activeId = this.currentSession.id;
42
51
  this.media = new Map();
43
52
  return this.currentSession;
44
53
  }
@@ -151,5 +160,6 @@ export class SessionManager {
151
160
  }
152
161
  setLoadedSession(session) {
153
162
  this.currentSession = session;
163
+ SessionManager.activeId = session.id;
154
164
  }
155
165
  }
@@ -1 +1 @@
1
- export const DEVX_VERSION = '1.9.3';
1
+ export const DEVX_VERSION = '1.9.7';
@@ -5,6 +5,7 @@ import { createHash } from 'crypto';
5
5
  import pc from 'picocolors';
6
6
  import { MCPClient } from './client.js';
7
7
  import { HttpMCPClient } from './http-client.js';
8
+ import { toolSchemaTokens } from '../core/context-stats.js';
8
9
  import { getCurrentTheme } from '../cli/theme.js';
9
10
  function stripAnsi(str) {
10
11
  return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
@@ -211,11 +212,26 @@ export function removeUserServer(name) {
211
212
  return false;
212
213
  }
213
214
  }
215
+ /** Tool schemas of the servers that are not lazy by choice stay within this (estimated tokens); the rest waits for the agent. */
216
+ export const EAGER_BUDGET_TOKENS = 4000;
217
+ /** One tool for the agent: its name, what it does and its parameters (required ones marked with *). */
218
+ export function describeTool(tool) {
219
+ const desc = tool.definition.description.replace(/^\[MCP: [^\]]*\]\s*/, '').replace(/\s+/g, ' ').trim();
220
+ const required = new Set(tool.definition.parameters.required ?? []);
221
+ const params = Object.keys(tool.definition.parameters.properties ?? {}).map((p) => (required.has(p) ? `${p}*` : p));
222
+ return `- ${tool.name}: ${desc.length > 240 ? desc.slice(0, 237) + '...' : desc}${params.length ? ` (params: ${params.join(', ')})` : ''}`;
223
+ }
214
224
  export class MCPManager {
215
225
  static instance;
216
226
  clients = new Map();
217
227
  entries = new Map();
218
228
  tools = [];
229
+ /** Which server a tool belongs to, and its name there (the tool names the agent sees are prefixed). */
230
+ meta = new Map();
231
+ /** What the tool schemas of a server weigh (estimated tokens). */
232
+ weights = new Map();
233
+ /** Tools of lazy servers the agent has loaded, in the order it loaded them. Survives reload() and reconnects. */
234
+ loaded = new Set();
219
235
  serverStatuses = new Map();
220
236
  isInitialized = false;
221
237
  trustProject = false;
@@ -298,12 +314,17 @@ export class MCPManager {
298
314
  },
299
315
  },
300
316
  validateArgs(_args) { },
301
- async execute(args) {
317
+ execute: async (args) => {
318
+ // What the agent has used stays in its requests, also when the server later turns lazy.
319
+ this.loaded.add(namespacedName);
302
320
  return await client.callTool(mt.name, args);
303
321
  },
304
322
  };
305
323
  this.tools.push(devxTool);
324
+ this.meta.set(namespacedName, { server: name, bare: mt.name });
306
325
  }
326
+ // What the schemas of this server cost in every request (decides whether it is sent or waits to be loaded).
327
+ this.weights.set(name, toolSchemaTokens(this.tools.filter((t) => this.meta.get(t.name)?.server === name).map((t) => t.definition)));
307
328
  }
308
329
  catch (err) {
309
330
  this.clients.delete(name);
@@ -317,16 +338,186 @@ export class MCPManager {
317
338
  }
318
339
  catch { }
319
340
  this.clients.delete(name);
320
- this.tools = this.tools.filter((t) => !t.name.startsWith(mcpToolName(name, '')));
341
+ for (const t of this.tools)
342
+ if (this.meta.get(t.name)?.server === name)
343
+ this.meta.delete(t.name);
344
+ this.tools = this.tools.filter((t) => this.meta.has(t.name));
345
+ this.weights.delete(name);
321
346
  }
347
+ /**
348
+ * The servers whose tools are not sent until the agent loads them. A server says so itself
349
+ * (`"lazy": true|false`); otherwise the small ones are sent (loading them would only cost a step) and
350
+ * the big ones wait, so that all the tools sent together stay within EAGER_BUDGET_TOKENS.
351
+ */
352
+ lazyServers() {
353
+ const lazy = new Set();
354
+ const withTools = Array.from(this.serverStatuses.values()).filter((s) => s.status === 'connected' && s.toolsCount > 0);
355
+ const weight = (n) => this.weights.get(n) ?? 0;
356
+ let budget = EAGER_BUDGET_TOKENS;
357
+ const auto = [];
358
+ for (const s of withTools) {
359
+ const explicit = this.entries.get(s.name)?.config.lazy;
360
+ if (explicit === true)
361
+ lazy.add(s.name);
362
+ else if (explicit === false)
363
+ budget -= weight(s.name);
364
+ else
365
+ auto.push(s.name);
366
+ }
367
+ auto.sort((a, b) => weight(a) - weight(b) || a.localeCompare(b));
368
+ for (const n of auto) {
369
+ if (weight(n) <= budget)
370
+ budget -= weight(n);
371
+ else
372
+ lazy.add(n);
373
+ }
374
+ return lazy;
375
+ }
376
+ /** The tools the agent gets in its requests: those of the servers that are not lazy, then the ones it loaded. */
322
377
  getTools() {
378
+ const lazy = this.lazyServers();
379
+ if (lazy.size === 0)
380
+ return this.tools;
381
+ const inLazy = (t) => lazy.has(this.meta.get(t.name)?.server ?? '');
382
+ const byName = new Map(this.tools.map((t) => [t.name, t]));
383
+ const loaded = Array.from(this.loaded).map((n) => byName.get(n)).filter((t) => !!t && inLazy(t));
384
+ return [...this.tools.filter((t) => !inLazy(t)), ...loaded];
385
+ }
386
+ /** Every tool of every connected server, loaded or not. */
387
+ getAllTools() {
323
388
  return this.tools;
324
389
  }
390
+ /** The tools of one server (case does not matter in the name), loaded or not. */
391
+ toolsOf(server) {
392
+ const want = server.toLowerCase();
393
+ return this.tools.filter((t) => this.meta.get(t.name)?.server.toLowerCase() === want);
394
+ }
395
+ /** True when some server keeps its tools back: the agent then needs `mcp_search`. */
396
+ hasLazy() {
397
+ return this.lazyServers().size > 0;
398
+ }
399
+ /** The servers with what the agent needs to know about them to decide whether to look inside. */
400
+ catalog() {
401
+ const lazy = this.lazyServers();
402
+ return Array.from(this.serverStatuses.values())
403
+ .filter((s) => s.status === 'connected' && s.toolsCount > 0)
404
+ .map((s) => {
405
+ const mine = this.tools.filter((t) => this.meta.get(t.name)?.server === s.name);
406
+ const isLazy = lazy.has(s.name);
407
+ return {
408
+ name: s.name,
409
+ lazy: isLazy,
410
+ total: mine.length,
411
+ loaded: isLazy ? mine.filter((t) => this.loaded.has(t.name)).length : mine.length,
412
+ tools: mine.map((t) => this.meta.get(t.name).bare),
413
+ };
414
+ });
415
+ }
416
+ /** The tool a reference points to: the full name, `server/tool`, or the tool's own name when only one has it. */
417
+ findTool(ref) {
418
+ const r = ref.trim();
419
+ const exact = this.tools.find((t) => t.name === r);
420
+ if (exact)
421
+ return exact;
422
+ const lower = r.toLowerCase();
423
+ const whole = this.tools.filter((t) => this.meta.get(t.name).bare.toLowerCase() === lower || t.name.toLowerCase() === lower);
424
+ if (whole.length > 0)
425
+ return whole.length === 1 ? whole[0] : undefined;
426
+ // `server/tool` or `server:tool`: the first separator ends the server's name (the tool's own name may have one too).
427
+ const cut = lower.search(/[/:]/);
428
+ if (cut < 0)
429
+ return undefined;
430
+ const server = lower.slice(0, cut);
431
+ const bare = lower.slice(cut + 1);
432
+ const hits = this.tools.filter((t) => {
433
+ const m = this.meta.get(t.name);
434
+ return m.server.toLowerCase() === server && m.bare.toLowerCase() === bare;
435
+ });
436
+ return hits.length === 1 ? hits[0] : undefined;
437
+ }
438
+ /** Puts tools into the agent's requests (from its next step). Tools of servers that are not lazy are there anyway. */
439
+ load(tools) {
440
+ for (const t of tools)
441
+ if (this.meta.has(t.name))
442
+ this.loaded.add(t.name);
443
+ }
444
+ isLoaded(tool) {
445
+ const server = this.meta.get(tool.name)?.server ?? '';
446
+ return !this.lazyServers().has(server) || this.loaded.has(tool.name);
447
+ }
448
+ /** Ranks the tools of the connected servers for a few keywords (best first). Only tools that match at all. */
449
+ search(query, server) {
450
+ const stem = (w) => (w.length > 3 ? w.replace(/(es|s)$/, '') : w);
451
+ const words = query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean).map(stem);
452
+ if (words.length === 0)
453
+ return [];
454
+ const scored = [];
455
+ this.tools.forEach((tool, order) => {
456
+ const m = this.meta.get(tool.name);
457
+ if (server && m.server.toLowerCase() !== server.toLowerCase())
458
+ return;
459
+ const name = m.bare.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ');
460
+ const nameWords = name.split(' ').map(stem);
461
+ const desc = tool.definition.description.replace(/^\[MCP: [^\]]*\]\s*/, '').toLowerCase();
462
+ const srv = m.server.toLowerCase();
463
+ let score = 0;
464
+ let hits = 0;
465
+ for (const w of words) {
466
+ let hit = 0;
467
+ if (nameWords.includes(w))
468
+ hit = 4;
469
+ else if (name.includes(w))
470
+ hit = 3;
471
+ if (desc.includes(w))
472
+ hit += 1;
473
+ if (srv.includes(w))
474
+ hit += 1;
475
+ if (hit > 0)
476
+ hits++;
477
+ score += hit;
478
+ }
479
+ if (score > 0)
480
+ scored.push({ tool, score: score + (hits === words.length ? 3 : 0), order });
481
+ });
482
+ return scored.sort((a, b) => b.score - a.score || a.order - b.order).map((x) => x.tool);
483
+ }
484
+ /**
485
+ * The agent called a tool that exists but was never loaded (it knew the name from the list, but has
486
+ * not seen the schema). It is loaded now and the agent is told to call it again with the right arguments.
487
+ */
488
+ loadOnMiss(name) {
489
+ const tool = this.tools.find((t) => t.name === name);
490
+ if (!tool || this.isLoaded(tool))
491
+ return undefined;
492
+ this.load([tool]);
493
+ return `Tool '${name}' was not loaded, so its arguments were unknown. It is loaded now: call it again.\n${describeTool(tool)}`;
494
+ }
495
+ /** A new chat starts from nothing: what was loaded in the old one is not carried over. */
496
+ resetLoaded() {
497
+ this.loaded.clear();
498
+ }
499
+ /** A resumed chat keeps working with the MCP tools it used before. */
500
+ restoreLoaded(messages) {
501
+ for (const m of messages) {
502
+ for (const call of m.tool_calls ?? []) {
503
+ const tool = this.tools.find((t) => t.name === call.name);
504
+ if (tool)
505
+ this.loaded.add(tool.name);
506
+ }
507
+ }
508
+ }
325
509
  getStatuses() {
326
- return Array.from(this.serverStatuses.values());
510
+ return Array.from(this.serverStatuses.values()).map((s) => this.withLoad(s));
327
511
  }
328
512
  getStatus(name) {
329
- return this.serverStatuses.get(name);
513
+ const s = this.serverStatuses.get(name);
514
+ return s ? this.withLoad(s) : undefined;
515
+ }
516
+ withLoad(s) {
517
+ if (s.status !== 'connected')
518
+ return s;
519
+ const entry = this.catalog().find((c) => c.name === s.name);
520
+ return entry ? { ...s, lazy: entry.lazy, loadedCount: entry.loaded, schemaTokens: this.weights.get(s.name) } : s;
330
521
  }
331
522
  /** Starts a server again (also after it failed). The tools of the agent follow at once. */
332
523
  async reconnect(name) {
@@ -353,6 +544,8 @@ export class MCPManager {
353
544
  this.isInitialized = false;
354
545
  this.clients.clear();
355
546
  this.tools = [];
547
+ this.meta.clear();
548
+ this.weights.clear();
356
549
  this.serverStatuses.clear();
357
550
  return await this.init(undefined, this.trustProject);
358
551
  }
@@ -398,7 +591,8 @@ export class MCPManager {
398
591
  if (s.status === 'disabled')
399
592
  statusBadge = pc.dim('○ Disabled');
400
593
  lines.push(padRow(pc.bold(pc.white(`Server: ${s.name}`)), statusBadge));
401
- lines.push(padRow(pc.dim(` Command: ${s.command.slice(0, 36)}${s.command.length > 36 ? '...' : ''}`), pc.cyan(`${s.toolsCount} tools`)));
594
+ const load = s.lazy ? `${s.toolsCount} tools · ${s.loadedCount ?? 0} loaded` : `${s.toolsCount} tools`;
595
+ lines.push(padRow(pc.dim(` Command: ${s.command.slice(0, 36)}${s.command.length > 36 ? '...' : ''}`), pc.cyan(load)));
402
596
  if (s.tools.length > 0) {
403
597
  const toolsListStr = s.tools.slice(0, 3).map(t => pc.dim(`• ${t}`)).join(' ');
404
598
  const extra = s.tools.length > 3 ? pc.dim(` +${s.tools.length - 3} more`) : '';
@@ -1,3 +1,4 @@
1
+ import { approvalNotice, notifyAttention } from '../core/attention.js';
1
2
  import pc from 'picocolors';
2
3
  import path from 'path';
3
4
  import os from 'os';
@@ -377,6 +378,9 @@ export class CLIConsoleGuard {
377
378
  return false;
378
379
  }
379
380
  const request = buildPermissionRequest(toolName, args);
381
+ // The work stops here until somebody answers: if the terminal is not in front, a notification says so.
382
+ const notice = approvalNotice(request.title, request.lines, request.danger);
383
+ notifyAttention(notice.title, notice.body);
380
384
  // Only requests that are safe to remember offer "Session" and "Always".
381
385
  const remember = request.danger ? null : rememberKeyFor(toolName, args);
382
386
  const offer = remember ? { session: true, always: true } : {};
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import fs from 'fs/promises';
4
4
  import { MemoryManager, memoryForPrompt } from '../core/memory.js';
5
5
  import { loadPlanForPrompt } from '../core/planfile.js';
6
+ import { MCPManager } from '../mcp/manager.js';
6
7
  export async function buildSystemPrompt(planMode) {
7
8
  const isTermux = process.env.PREFIX?.includes('com.termux') || false;
8
9
  const envDesc = isTermux ? 'Android (Termux, ARM64)' : `${os.platform()} (${os.arch()})`;
@@ -44,8 +45,11 @@ export async function buildSystemPrompt(planMode) {
44
45
  }
45
46
  prompt += `\nMCP (external tools)\n`;
46
47
  prompt += `- Servers live in ~/.devx/mcp.json (the user's, started at once) and .devx/mcp.json (this project, only after the user approved it). Their tools are named mcp__<server>__<tool>. Local programs only (command + args); a server that has just an address is reached with command 'npx', args ['-y','mcp-remote','<url>'].\n`;
48
+ if (MCPManager.getInstance().hasLazy()) {
49
+ prompt += `- The tools of some MCP servers are not loaded, to save context. When a task needs an external service (the servers are listed in the 'mcp_search' tool), load its tools first with 'mcp_search' (keywords in 'query'), then call them. Never guess a tool name or its arguments. Tools you loaded stay available.\n`;
50
+ }
47
51
  if (!planMode)
48
- prompt += `- If the user asks to connect a service over MCP, use 'mcp_manage': 'list' first, then 'add' with the real command (find it in the service's docs or with the web search tool, do not guess; ask with 'ask_questions' if you need a path or a choice). Secrets go into env, not args. Check the result: if the server did not connect, tell the user the error. 'import_claude' takes the servers from Claude Code if the user has it. The new tools work from the user's next message.\n`;
52
+ prompt += `- If the user asks to connect a service over MCP, use 'mcp_manage': 'list' first, then 'add' with the real command (find it in the service's docs or with the web search tool, do not guess; ask with 'ask_questions' if you need a path or a choice). Secrets go into env, not args. Check the result: if the server did not connect, tell the user the error. 'import_claude' takes the servers from Claude Code if the user has it. The new tools work at once.\n`;
49
53
  // Load Project Memory Bank
50
54
  try {
51
55
  const memory = MemoryManager.enabled ? memoryForPrompt(await MemoryManager.loadMemory()).text : '';