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 +3 -0
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/headless.js +4 -4
- package/dist/cli/index.js +18 -7
- package/dist/cli/telemetry.js +13 -0
- package/dist/cli/transcript.js +2 -1
- package/dist/cli/ui.js +4 -4
- package/dist/cli/width.js +102 -7
- package/dist/core/chat-search.js +172 -0
- package/dist/core/loop.js +20 -3
- package/dist/core/session.js +10 -0
- package/dist/core/types.js +1 -1
- package/dist/mcp/manager.js +199 -5
- package/dist/prompts/builder.js +5 -1
- package/dist/tools/chats.js +119 -0
- package/dist/tools/index.js +13 -2
- package/dist/tools/mcp-search.js +104 -0
- package/dist/tools/mcp.js +2 -2
- package/dist/tools/plan.js +43 -6
- package/package.json +1 -1
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.
|
|
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>
|
package/assets/preview.svg
CHANGED
|
@@ -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.
|
|
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>
|
package/dist/cli/headless.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
}
|
package/dist/cli/telemetry.js
CHANGED
|
@@ -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);
|
package/dist/cli/transcript.js
CHANGED
|
@@ -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 =
|
|
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
|
-
/**
|
|
135
|
-
export function
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
177
|
-
|
|
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.
|
|
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
|
-
|
|
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).
|
package/dist/core/session.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const DEVX_VERSION = '1.9.
|
|
1
|
+
export const DEVX_VERSION = '1.9.5';
|