termux-dev 1.9.2 → 1.9.5

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/README.md CHANGED
@@ -67,6 +67,8 @@ The terminal interface is built around a framed prompt that carries the status i
67
67
  Built-in support for TypeScript (`tsc`), JavaScript (`node --check`), Python (`py_compile`), and Rust (`cargo check`). The AI automatically finds and fixes errors before finishing turns.
68
68
  - **Project Memory Bank (`.devx/memory.md`):**
69
69
  Persistent project context across sessions with `/memory add <rule>`.
70
+ - **Past chats (`search_chats`):**
71
+ When you say "as we decided last time" or "in that chat about the parser", the agent can look through your saved chats by keywords (any language, other word forms match: page / pages) and read the one it needs, a few messages at a time. It is read-only and local, so it asks nothing; the chat that is open is not searched; tool output is not indexed; and what it reads is handed to the model as data, not as instructions. The agent does not use it on a normal task.
70
72
  - **18 Colour Themes on a Pure Black Background (`#0a0a0c`):**
71
73
  Deep OLED black for distraction-free CLI vibe-coding, with full palettes instead of a single accent: Cyan Cyber, Synthwave Purple, Matrix Hacker, Solar Amber, Ruby Crimson, Pure Monochrome, Nord Frost, Dracula, Tokyo Night, Gruvbox, Catppuccin Mocha, Solarized Dark, Rose Pine, Monokai, GitHub Dark, Deep Ocean, Sunset Glow and Forest Moss. See [Themes](#themes).
72
74
  - **Smart Session History (`/resume` & `/session`):**
@@ -374,6 +376,7 @@ Colours are exact on terminals with truecolor support (`COLORTERM=truecolor`); o
374
376
  - **Undo for everything.** With git, `/undo` puts the whole folder back to how it was before the last turn, including what shell commands did (files in `.gitignore` are not touched).
375
377
  - **The approved plan** is saved in `.devx/plan.md` and given to the agent for 24 hours. **Project memory** longer than ~4000 characters is cut to the newest facts for the model.
376
378
  - **MCP.** `/mcp add <name> <command> [args]` or `/mcp add <name> <https-url>` (a remote server over HTTP; put a token in `headers` as `"Bearer ${TOKEN}"`), `/mcp remove <name>`, `/mcp import` (from Claude Code).
379
+ - **MCP tools on demand.** Every MCP tool schema is sent with every request, and a big server (Notion has ~80 tools) can cost 100k tokens before you type anything. So devx sends the tools of small servers at once and keeps the big ones back: the agent sees the list of servers in its `mcp_search` tool, finds and loads only the tools a task needs (`mcp_search` with keywords, tool names, or just a server name), and calls them from its next step. Loaded tools stay for the chat (a resumed chat gets back the ones it used). Which servers wait is decided by size (all tools sent together stay within ~4k tokens); force it per server with `"lazy": true` or `"lazy": false` in `mcp.json`.
377
380
 
378
381
  ## Security Model
379
382
 
package/assets/banner.svg CHANGED
@@ -16,7 +16,7 @@
16
16
  <rect width="1000" height="280" fill="url(#bg)"/>
17
17
  </g>
18
18
  <rect x="1.5" y="1.5" width="997" height="277" rx="18" fill="none" stroke="#23252c" stroke-width="1.5"/>
19
- <text x="500.0" y="70" text-anchor="middle" font-family="'Inter',system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif" font-size="13" font-weight="600" letter-spacing="5" fill="#7d8896">THE TERMINAL-NATIVE AI CODING AGENT<tspan dx="16" fill="#2fe3ee" font-weight="700">v1.9.2</tspan></text>
19
+ <text x="500.0" y="70" text-anchor="middle" font-family="'Inter',system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif" font-size="13" font-weight="600" letter-spacing="5" fill="#7d8896">THE TERMINAL-NATIVE AI CODING AGENT<tspan dx="16" fill="#2fe3ee" font-weight="700">v1.9.5</tspan></text>
20
20
  <g>
21
21
  <text x="500.0" y="178" text-anchor="middle" font-family="'JetBrains Mono','Fira Code','SF Mono',ui-monospace,Menlo,Consolas,'DejaVu Sans Mono',monospace" font-size="112" font-weight="800" letter-spacing="14" fill="#2fe3ee">DEVX</text>
22
22
  </g>
@@ -32,7 +32,7 @@
32
32
  <rect x="595.2" y="574.0" width="243.2" height="19" fill="#000000"/>
33
33
  <text x="44.8" y="62.0" class="t" fill="#b794f6">▌</text>
34
34
  <text x="61.6" y="62.0" class="t b" fill="#f4f7fb">devx</text>
35
- <text x="112.0" y="62.0" class="t" fill="#5f6b7a">v1.9.2</text>
35
+ <text x="112.0" y="62.0" class="t" fill="#5f6b7a">v1.9.5</text>
36
36
  <text x="61.6" y="106.0" class="t" fill="#5f6b7a">model</text>
37
37
  <text x="128.8" y="106.0" class="t" fill="#e5eaf1">deepseek-v4.1-flash · think high</text>
38
38
  <text x="406.0" y="106.0" class="t" fill="#5f6b7a">/model</text>
@@ -5,7 +5,7 @@ import { Agent } from '../core/loop.js';
5
5
  import { buildSystemPrompt } from '../prompts/builder.js';
6
6
  import { createProvider } from '../providers/index.js';
7
7
  import { refreshFormats } from '../core/formats.js';
8
- import { getTools } from '../tools/index.js';
8
+ import { getToolSource } from '../tools/index.js';
9
9
  import { CLIConsoleGuard } from '../permissions/guard.js';
10
10
  import { globalSnapshotManager } from '../core/snapshot.js';
11
11
  import { UsageTracker } from '../core/usage.js';
@@ -18,8 +18,6 @@ export async function runHeadlessMode(userPrompt, config, options = {}) {
18
18
  const isQuiet = !!options.quiet;
19
19
  const isJson = !!options.json;
20
20
  let history = new History();
21
- const sysPrompt = await buildSystemPrompt(planMode);
22
- history.updateSystemPrompt(sysPrompt);
23
21
  history.addMessage({ role: 'user', content: userPrompt });
24
22
  let executedTools = [];
25
23
  let accumulatedText = '';
@@ -41,9 +39,11 @@ export async function runHeadlessMode(userPrompt, config, options = {}) {
41
39
  onAbort();
42
40
  try {
43
41
  await MCPManager.getInstance().init(undefined, config.projectMcpTrusted === true);
42
+ // Written once the servers are up: it tells how to reach the tools that wait to be loaded.
43
+ history.updateSystemPrompt(await buildSystemPrompt(planMode));
44
44
  await refreshFormats(config).catch(() => null);
45
45
  const provider = createProvider(config);
46
- const tools = getTools(planMode);
46
+ const tools = getToolSource(planMode);
47
47
  const guard = new CLIConsoleGuard(isYolo, [], { extra: isExtra });
48
48
  const agent = new Agent(config, provider, tools, history, guard);
49
49
  if (!isQuiet && !isJson) {
package/dist/cli/index.js CHANGED
@@ -11,7 +11,7 @@ import { History } from '../core/history.js';
11
11
  import { Agent } from '../core/loop.js';
12
12
  import { buildSystemPrompt } from '../prompts/builder.js';
13
13
  import { createProvider } from '../providers/index.js';
14
- import { getTools, lastPlanReady, resetPlanReady } from '../tools/index.js';
14
+ import { getTools, getToolSource, lastPlanReady, resetPlanReady } from '../tools/index.js';
15
15
  import { CLIConsoleGuard, newPermissionMemory } from '../permissions/guard.js';
16
16
  import { globalSnapshotManager } from '../core/snapshot.js';
17
17
  import { MemoryManager, memoryForPrompt } from '../core/memory.js';
@@ -930,12 +930,12 @@ export async function main() {
930
930
  enableDarkTheme(config.pureBlackTheme !== false);
931
931
  let history = new History();
932
932
  MemoryManager.enabled = config.enableMemory !== false;
933
- const sysPrompt = await buildSystemPrompt(planMode);
934
- history.addMessage({ role: 'system', content: sysPrompt });
935
933
  const sessionManager = new SessionManager(config.model, planMode);
936
934
  drawLogo(config.banner || 'full');
937
935
  await runStartupUpdateCheck(config, { save: saveConfig });
938
936
  await MCPManager.getInstance().init(undefined, config.projectMcpTrusted === true);
937
+ // After the servers are up: the prompt says how the agent reaches the tools it has to load.
938
+ history.addMessage({ role: 'system', content: await buildSystemPrompt(planMode) });
939
939
  // Ask a custom endpoint what the current model can do (cached for a day; never blocks the prompt).
940
940
  void refreshEndpoint(config).catch(() => null);
941
941
  const sessionCost = new CostLedger();
@@ -1018,6 +1018,7 @@ export async function main() {
1018
1018
  if (process.stdout.isTTY)
1019
1019
  process.stdout.write(RESTORE_TERMINAL_TITLE + SAVE_TERMINAL_TITLE);
1020
1020
  history = new History();
1021
+ MCPManager.getInstance().resetLoaded();
1021
1022
  history.addMessage({ role: 'system', content: await buildSystemPrompt(planMode) });
1022
1023
  sessionCost.reset();
1023
1024
  costLimitRaised = null;
@@ -1336,6 +1337,8 @@ export async function main() {
1336
1337
  for (const m of loaded.messages) {
1337
1338
  history.addMessage(m);
1338
1339
  }
1340
+ MCPManager.getInstance().resetLoaded();
1341
+ MCPManager.getInstance().restoreLoaded(loaded.messages);
1339
1342
  if (loaded.model) {
1340
1343
  config.model = loaded.model;
1341
1344
  config.maxContextTokens = resolveContextLimit({ ...config, maxContextTokens: 0 }).limit;
@@ -2107,7 +2110,7 @@ export async function main() {
2107
2110
  // The format of the gateway is learned once (cached); the first request waits for it.
2108
2111
  await refreshFormats(config).catch(() => null);
2109
2112
  const provider = createProvider(config);
2110
- const tools = getTools(planMode);
2113
+ const tools = getToolSource(planMode);
2111
2114
  const guard = new CLIConsoleGuard(config.autoApprove, [...(config.bashAllowlist || [])], {
2112
2115
  extra: !!config.autoApproveAll,
2113
2116
  memory: permissionMemory,
@@ -2135,6 +2138,10 @@ export async function main() {
2135
2138
  let spinnerActive = false;
2136
2139
  let thinkingActive = false;
2137
2140
  let thinkingStartTime = 0;
2141
+ // What one thought amounts to (estimated tokens, first and last piece), for the speed on its line.
2142
+ let thoughtTokens = 0;
2143
+ let thoughtFirstAt = 0;
2144
+ let thoughtLastAt = 0;
2138
2145
  // Thinking is collapsed by default: only a live line shows the newest words. ctrl+p shows it in full.
2139
2146
  let thinkingStreaming = false;
2140
2147
  let thoughtBuffer = '';
@@ -2247,9 +2254,7 @@ export async function main() {
2247
2254
  thoughtGutter.flush();
2248
2255
  }
2249
2256
  const elapsedSec = thinkingStartTime > 0 ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : '0.0';
2250
- const teleStats = config.ui?.liveTelemetry && telemetry.getTotalTokens() > 0
2251
- ? ` · ${LiveTelemetryTracker.formatTokenCount(telemetry.getTotalTokens())} tok · ${telemetry.getTokensPerSecond()} tok/s`
2252
- : '';
2257
+ const teleStats = LiveTelemetryTracker.thoughtStats(thoughtTokens, thoughtLastAt - thoughtFirstAt);
2253
2258
  if (cleanUi && !thinkingActive) {
2254
2259
  // Folded: one quiet line instead of the whole reasoning.
2255
2260
  if (spacerBeforeText)
@@ -2280,6 +2285,8 @@ export async function main() {
2280
2285
  thinkingActive = false;
2281
2286
  thinkingStreaming = false;
2282
2287
  thinkingStartTime = 0;
2288
+ thoughtTokens = 0;
2289
+ thoughtFirstAt = thoughtLastAt = 0;
2283
2290
  runPendingRedraw();
2284
2291
  };
2285
2292
  /** Prints the header of the reasoning block (once) and what has not been written of it yet. */
@@ -2497,12 +2504,16 @@ export async function main() {
2497
2504
  if (!thinkingStreaming) {
2498
2505
  thinkingStreaming = true;
2499
2506
  thinkingStartTime = Date.now();
2507
+ thoughtTokens = 0;
2508
+ thoughtFirstAt = thinkingStartTime;
2500
2509
  thoughtBuffer = '';
2501
2510
  thoughtPrinted = 0;
2502
2511
  liveShown = 0;
2503
2512
  liveAt = Date.now();
2504
2513
  }
2505
2514
  thoughtBuffer += event.delta;
2515
+ thoughtTokens += telemetry.estimateTokensFromText(event.delta);
2516
+ thoughtLastAt = Date.now();
2506
2517
  if (!cleanUi || thinkingExpanded)
2507
2518
  showThought();
2508
2519
  }
@@ -75,6 +75,19 @@ export class LiveTelemetryTracker {
75
75
  return `${(n / 1_000).toFixed(1)}k`;
76
76
  return `${Math.round(n)}`;
77
77
  }
78
+ /**
79
+ * ` · 812 tok · 74 tok/s` for a finished thought: its own tokens and the speed it ran at, from the
80
+ * first to the last piece (not the whole wait). The speed is left out when the thought was so short
81
+ * that it would mean nothing (a few tokens in a fraction of a second).
82
+ */
83
+ static thoughtStats(tokens, spanMs) {
84
+ if (tokens <= 0)
85
+ return '';
86
+ const count = ` · ${LiveTelemetryTracker.formatTokenCount(tokens)} tok`;
87
+ if (tokens < 20 || spanMs < 1000)
88
+ return count;
89
+ return `${count} · ${Math.round(tokens / (spanMs / 1000))} tok/s`;
90
+ }
78
91
  formatLabel() {
79
92
  const secs = `${this.getElapsedSeconds()}s`;
80
93
  const tokens = LiveTelemetryTracker.formatTokenCount(this.totalTokens);
@@ -4,6 +4,7 @@ import { renderMarkdown } from './markdown.js';
4
4
  import { palette, renderPlanCard, renderResultLine, renderTaskCard, renderToolLine, renderUserEcho, summarizeToolResult } from './ui.js';
5
5
  import { displayWidth, truncateWidth, wrapText } from './width.js';
6
6
  import { t } from '../core/i18n.js';
7
+ import { toStringList } from '../tools/plan.js';
7
8
  const MAX_ECHO_LINES = 300;
8
9
  /** What /compact puts in front of the summary it keeps as the first message. */
9
10
  export const COMPACT_MARK = '[CONVERSATION COMPACTED]';
@@ -16,7 +17,7 @@ export function toolResultRows(name, result, opts) {
16
17
  if (parsed.type === 'todo_list' && Array.isArray(parsed.tasks) && parsed.tasks.length > 0)
17
18
  return renderTaskCard(parsed.tasks, cols);
18
19
  if (parsed.type === 'plan_ready' && typeof parsed.summary === 'string')
19
- return renderPlanCard({ summary: parsed.summary, files: parsed.files, steps: parsed.steps }, cols);
20
+ return renderPlanCard({ summary: parsed.summary, files: toStringList(parsed.files), steps: toStringList(parsed.steps) }, cols);
20
21
  if (parsed.action === 'edit' && parsed.diffLines) {
21
22
  const box = formatDiffBox(parsed.path, parsed.diffLines, Math.min(cols - 4, 80), undefined, !!opts.diffColors);
22
23
  const rows = box.split('\n').map((l) => ' ' + l);
package/dist/cli/ui.js CHANGED
@@ -4,7 +4,7 @@ import path from 'path';
4
4
  import { getCurrentTheme } from './theme.js';
5
5
  import { t } from '../core/i18n.js';
6
6
  import { formatMoney } from '../core/cost.js';
7
- import { codePointWidth, displayWidth, padEndWidth, stripAnsi, truncateWidth, wrapText, wrapWidth, wrapWords, } from './width.js';
7
+ import { codePointWidth, displayWidth, padEndWidth, stripAnsi, truncateWidth, wrapKeepSpaces, wrapText, wrapWidth, wrapWords, } from './width.js';
8
8
  /**
9
9
  * devx visual language.
10
10
  *
@@ -519,7 +519,7 @@ function hintsRow(cols, mode) {
519
519
  export function renderUserEcho(text, cols) {
520
520
  const W = frameWidth(cols);
521
521
  const cw = W - 4;
522
- const rows = wrapWidth(text.trimEnd(), cw);
522
+ const rows = wrapKeepSpaces(text.trimEnd(), cw);
523
523
  const shaded = rows.map((line, i) => {
524
524
  const marker = i === 0 ? palette.accent('❯') : ' ';
525
525
  return palette.shade(' ' + marker + ' ' + padEndWidth(palette.text(line), cw) + ' ');
@@ -760,7 +760,7 @@ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
760
760
  }
761
761
  if (expanded && t !== 'bash' && !(t === 'edit_file' || t === 'write_file')) {
762
762
  // For these the first line is part of the output, not a summary of it.
763
- const own = t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url' || t === 'background';
763
+ const own = t.startsWith('mcp__') || t === 'mcp_search' || t === 'search_chats' || t === 'web_search' || t === 'fetch_url' || t === 'background';
764
764
  const first = own ? [] : summarizeToolResult(name, result, cols, false);
765
765
  return [...first, ...fullRows(text, cols)];
766
766
  }
@@ -806,7 +806,7 @@ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
806
806
  return rest > 0 ? [row, moreHint(rest)] : [row];
807
807
  }
808
808
  // Output of an MCP tool or a web fetch: the first line and how many more there are.
809
- if (t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url') {
809
+ if (t.startsWith('mcp__') || t === 'mcp_search' || t === 'search_chats' || t === 'web_search' || t === 'fetch_url') {
810
810
  const nonEmpty = outputRows(text).filter((l) => l.trim());
811
811
  if (nonEmpty.length === 0)
812
812
  return [renderResultLine('(no output)', 'muted', cols)];
package/dist/cli/width.js CHANGED
@@ -131,16 +131,48 @@ export function wrapWidth(text, width) {
131
131
  }
132
132
  return lines;
133
133
  }
134
- /** Wraps text at word boundaries (a word longer than the line is cut). */
135
- export function wrapWords(text, width) {
134
+ /** Cuts a word into pieces of at most `width` cells, never splitting a character. */
135
+ export function splitWord(word, width) {
136
+ if (width <= 0)
137
+ return [word];
138
+ const pieces = [];
139
+ let current = '';
140
+ let cells = 0;
141
+ for (const ch of word) {
142
+ const w = codePointWidth(ch.codePointAt(0));
143
+ if (cells + w > width && current !== '') {
144
+ pieces.push(current);
145
+ current = '';
146
+ cells = 0;
147
+ }
148
+ current += ch;
149
+ cells += w;
150
+ }
151
+ if (current !== '')
152
+ pieces.push(current);
153
+ return pieces;
154
+ }
155
+ /**
156
+ * Words are put on lines one after another. A word longer than a whole line is dealt with by
157
+ * `cutLong`: false shortens it with an ellipsis, true cuts it into pieces (the text after it goes on
158
+ * right behind its last piece). A word that fits on a line is never cut.
159
+ */
160
+ function wrapWordList(text, width, cutLong) {
136
161
  const lines = [];
137
162
  let current = '';
138
163
  for (const word of text.split(/\s+/).filter(Boolean)) {
139
164
  if (displayWidth(word) > width) {
140
165
  if (current)
141
166
  lines.push(current);
142
- lines.push(truncateWidth(word, width));
143
- current = '';
167
+ if (cutLong) {
168
+ const pieces = splitWord(word, width);
169
+ lines.push(...pieces.slice(0, -1));
170
+ current = pieces[pieces.length - 1] ?? '';
171
+ }
172
+ else {
173
+ lines.push(truncateWidth(word, width));
174
+ current = '';
175
+ }
144
176
  }
145
177
  else if (current === '') {
146
178
  current = word;
@@ -157,6 +189,10 @@ export function wrapWords(text, width) {
157
189
  lines.push(current);
158
190
  return lines;
159
191
  }
192
+ /** Wraps text at word boundaries (a word longer than the line is cut). */
193
+ export function wrapWords(text, width) {
194
+ return wrapWordList(text, width, false);
195
+ }
160
196
  /** The last `maxCells` cells of a text (what a running line shows: the newest words). */
161
197
  export function tailWidth(text, maxCells) {
162
198
  const chars = Array.from(text);
@@ -171,10 +207,69 @@ export function tailWidth(text, maxCells) {
171
207
  }
172
208
  return chars.slice(start).join('');
173
209
  }
174
- /** Wraps at word boundaries; text without spaces (Chinese, Japanese) is wrapped anywhere. */
210
+ /**
211
+ * Wraps at word boundaries. Only a word that is longer than the line is cut (Chinese and Japanese
212
+ * have no spaces, so a whole sentence is such a word); the words around it stay whole.
213
+ */
175
214
  export function wrapText(text, width) {
176
- const unbreakable = text.split(/\s+/).some((word) => displayWidth(word) > width);
177
- return unbreakable ? wrapWidth(text, width) : wrapWords(text, width);
215
+ return wrapWordList(text, width, true);
216
+ }
217
+ /**
218
+ * Wraps text for showing it back (the message of the user): explicit newlines, blank lines, the
219
+ * indentation and the spaces inside a line stay as they were. A line is broken at a space, where the
220
+ * space is dropped; a word longer than a whole line goes on a row of its own and is cut by cells.
221
+ */
222
+ export function wrapKeepSpaces(text, width) {
223
+ const rows = [];
224
+ for (const raw of text.split(/\r\n|\r|\n/)) {
225
+ const line = raw.replace(/\s+$/, '');
226
+ if (line === '') {
227
+ rows.push('');
228
+ continue;
229
+ }
230
+ let current = '';
231
+ let cells = 0;
232
+ let gap = '';
233
+ for (const token of line.match(/\S+|\s+/g) ?? []) {
234
+ if (/^\s/.test(token)) {
235
+ // Indentation stays; the space between two words waits to see whether the next word fits.
236
+ if (current === '') {
237
+ current = token;
238
+ cells = displayWidth(token);
239
+ }
240
+ else
241
+ gap = token;
242
+ continue;
243
+ }
244
+ const w = displayWidth(token);
245
+ const gapCells = displayWidth(gap);
246
+ if (current.trim() !== '' && cells + gapCells + w <= width) {
247
+ current += gap + token;
248
+ cells += gapCells + w;
249
+ }
250
+ else if (current.trim() === '' && cells + w <= width) {
251
+ current += token;
252
+ cells += w;
253
+ }
254
+ else if (w <= width) {
255
+ if (current.trim() !== '')
256
+ rows.push(current);
257
+ current = token;
258
+ cells = w;
259
+ }
260
+ else {
261
+ if (current.trim() !== '')
262
+ rows.push(current);
263
+ const pieces = splitWord(token, width);
264
+ rows.push(...pieces.slice(0, -1));
265
+ current = pieces[pieces.length - 1];
266
+ cells = displayWidth(current);
267
+ }
268
+ gap = '';
269
+ }
270
+ rows.push(current);
271
+ }
272
+ return rows;
178
273
  }
179
274
  /**
180
275
  * How many rows up the top of a block is from the cursor, once the terminal is `cols` wide.
@@ -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.2';
1
+ export const DEVX_VERSION = '1.9.5';