agen-vektor 0.3.11 → 0.3.12
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 +30 -13
- package/dist/cli/index.js +13 -2
- package/dist/config/config.js +26 -1
- package/dist/providers/anthropic.js +34 -1
- package/dist/providers/gemini.js +19 -5
- package/dist/providers/ollama.js +13 -1
- package/dist/providers/openai-compat.js +101 -3
- package/dist/security/command-policy.js +59 -0
- package/dist/security/sensitive-files.js +126 -0
- package/dist/tools/filesystem.js +16 -0
- package/dist/tui/app.js +191 -26
- package/dist/tui/chat.js +119 -43
- package/dist/tui/commands.js +33 -21
- package/dist/tui/statusbar.js +25 -11
- package/dist/utils/terminal.js +58 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
```
|
|
2
|
+
████████████████████████████████████████████████
|
|
3
|
+
█ █
|
|
4
|
+
█ V E C T O R A G E N T █
|
|
5
|
+
█ █
|
|
6
|
+
████████████████████████████████████████████████
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
<div align="center">
|
|
2
10
|
|
|
3
11
|
**AI Coding Agent for Linux & Termux** — a terminal-native coding agent that inspects your project, plans, edits code, runs commands and tests, and iterates until the task is done.
|
|
4
12
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+

|
|
14
|
+

|
|
15
|
+

|
|
16
|
+
[](https://github.com/clickmamaheti-prog/vector-agent/actions/workflows/ci.yml)
|
|
17
|
+
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
---
|
|
13
21
|
|
|
14
22
|
```
|
|
15
23
|
VectorHead ● Ready deepseek-ai/DeepSeek-V4-Flash
|
|
@@ -39,6 +47,12 @@ theme-system.ts + markdown-renderer.tsx), a compact agent-activity strip
|
|
|
39
47
|
(⚙ tool calls), and a rounded input box with a block cursor — all rendered
|
|
40
48
|
incrementally with no flicker.
|
|
41
49
|
|
|
50
|
+
## Contents
|
|
51
|
+
|
|
52
|
+
- [Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Command line](#command-line) · [TUI commands](#tui-commands)
|
|
53
|
+
- [Provider configuration](#provider-configuration) · [Themes](#themes) · [Security model](#security-model) · [Architecture](#architecture) · [Development](#development)
|
|
54
|
+
- [Testing](#testing) · [Troubleshooting](#troubleshooting) · [Roadmap](#roadmap) · [License](#license)
|
|
55
|
+
|
|
42
56
|
## Features
|
|
43
57
|
|
|
44
58
|
- **Interactive TUI** — chat, agent status, tool execution, diff viewer, input. Keyboard driven, mouse-aware, works on 80×24 terminals and survives resize. Scroll the chat with PgUp/PgDn, the mouse wheel, or ↑/↓ while the input line is empty.
|
|
@@ -55,10 +69,13 @@ Requirements: **Node.js 20+** and **npm** (Termux: `pkg install nodejs`).
|
|
|
55
69
|
|
|
56
70
|
### From source
|
|
57
71
|
|
|
58
|
-
|
|
59
|
-
(membuat `vector` tersedia di PATH).
|
|
72
|
+
Clone this repo, then:
|
|
60
73
|
|
|
61
|
-
|
|
74
|
+
```bash
|
|
75
|
+
npm install
|
|
76
|
+
npm run build
|
|
77
|
+
npm link # makes `vector` available on your PATH
|
|
78
|
+
```
|
|
62
79
|
|
|
63
80
|
```bash
|
|
64
81
|
vector
|
|
@@ -80,7 +97,7 @@ npm install -g agen-vektor
|
|
|
80
97
|
vector
|
|
81
98
|
```
|
|
82
99
|
|
|
83
|
-
>
|
|
100
|
+
> Vector Agent has **zero runtime dependencies** and does not assume `systemd`, `sudo`, `apt`, or a desktop GUI — everything runs in a plain terminal.
|
|
84
101
|
|
|
85
102
|
## Quick start
|
|
86
103
|
|
package/dist/cli/index.js
CHANGED
|
@@ -23,6 +23,12 @@ const paths_1 = require("../utils/paths");
|
|
|
23
23
|
const logger_1 = require("../utils/logger");
|
|
24
24
|
const keyboard_1 = require("./keyboard");
|
|
25
25
|
const terminal_1 = require("../utils/terminal");
|
|
26
|
+
/** Disable auto-wrap margins (DECAWM off): a full-width row then CANNOT
|
|
27
|
+
* push the cursor into the pending-wrap state, so the next write can never
|
|
28
|
+
* flush a phantom newline and scroll the screen (the "dot keluar layar"/
|
|
29
|
+
* jitter bug). Restored after every paint so scrollback stays intact. */
|
|
30
|
+
const DECAWM_OFF = '\x1b[?7l';
|
|
31
|
+
const DECAWM_ON = '\x1b[?7h';
|
|
26
32
|
const themes_1 = require("../tui/themes");
|
|
27
33
|
const session_1 = require("../agent/session");
|
|
28
34
|
const paths_2 = require("../utils/paths");
|
|
@@ -318,7 +324,7 @@ async function runTui(opts) {
|
|
|
318
324
|
// the chat to flicker during multi-tool turns.
|
|
319
325
|
if (app.isDirty()) {
|
|
320
326
|
lastFrameRows = [];
|
|
321
|
-
process.stdout.write(terminal_1.ANSI.hideCursor + app.render());
|
|
327
|
+
process.stdout.write(terminal_1.ANSI.hideCursor + DECAWM_OFF + (0, terminal_1.toAsciiSafe)(app.render()) + DECAWM_ON);
|
|
322
328
|
app.consumeRender();
|
|
323
329
|
}
|
|
324
330
|
return;
|
|
@@ -337,7 +343,12 @@ async function runTui(opts) {
|
|
|
337
343
|
out.push(cursor);
|
|
338
344
|
lastCursorPos = cursor;
|
|
339
345
|
out.push(terminal_1.ANSI.showCursor);
|
|
340
|
-
|
|
346
|
+
// DECAWM guard: even one exactly-`cols`-wide row (input-box borders,
|
|
347
|
+
// header at the old width) leaves the cursor in the pending-wrap state;
|
|
348
|
+
// the NEXT repaint — e.g. the ~480ms blink dot — then auto-wraps first
|
|
349
|
+
// and the whole screen scrolls up one line. With auto-wrap disabled for
|
|
350
|
+
// the duration of the paint, that phantom newline is impossible.
|
|
351
|
+
process.stdout.write(terminal_1.ANSI.hideCursor + DECAWM_OFF + (0, terminal_1.toAsciiSafe)(out.join('')) + DECAWM_ON);
|
|
341
352
|
};
|
|
342
353
|
const onResize = () => {
|
|
343
354
|
app.markDirty();
|
package/dist/config/config.js
CHANGED
|
@@ -44,6 +44,7 @@ exports.effectiveConfig = effectiveConfig;
|
|
|
44
44
|
* credentials.json (0600) or environment variables, never in config.json.
|
|
45
45
|
*/
|
|
46
46
|
const fs = __importStar(require("node:fs"));
|
|
47
|
+
const path = __importStar(require("node:path"));
|
|
47
48
|
const paths_1 = require("../utils/paths");
|
|
48
49
|
const logger_1 = require("../utils/logger");
|
|
49
50
|
const providers_1 = require("./providers");
|
|
@@ -51,7 +52,12 @@ exports.DEFAULT_CONFIG = {
|
|
|
51
52
|
provider: 'openrouter',
|
|
52
53
|
model: 'deepseek/deepseek-chat',
|
|
53
54
|
protocol: 'openai',
|
|
54
|
-
|
|
55
|
+
// User request (2026-09-04): the assistant must ACT without permission
|
|
56
|
+
// prompts — yolo is the default. Dangerous commands (rm, git push, chmod…)
|
|
57
|
+
// still prompt and blocked ones are always refused (see command-policy),
|
|
58
|
+
// so the guardrail floor stays intact. Opt back into prompting via
|
|
59
|
+
// VECTOR_ASK=1 or the /mode toggle in the TUI.
|
|
60
|
+
permissionMode: 'yolo',
|
|
55
61
|
maxIterations: 25,
|
|
56
62
|
maxRetries: 3,
|
|
57
63
|
requestTimeoutMs: 120_000,
|
|
@@ -66,6 +72,22 @@ function loadConfig() {
|
|
|
66
72
|
if (fs.existsSync(p)) {
|
|
67
73
|
const raw = fs.readFileSync(p, 'utf8');
|
|
68
74
|
const parsed = JSON.parse(raw);
|
|
75
|
+
// One-time mode migration (2026-09-04): permissionMode became 'yolo'
|
|
76
|
+
// by default — the assistant must act without prompting. Configs that
|
|
77
|
+
// still carry the OLD default ('ask') are flipped once; an explicit
|
|
78
|
+
// /mode toggle afterwards re-persists and is then respected forever.
|
|
79
|
+
if (parsed.permissionMode === 'ask') {
|
|
80
|
+
const marker = path.join((0, paths_1.getVectorDir)(), '.mode-migrated');
|
|
81
|
+
if (!fs.existsSync(marker)) {
|
|
82
|
+
parsed.permissionMode = 'yolo';
|
|
83
|
+
try {
|
|
84
|
+
fs.writeFileSync(marker, new Date().toISOString() + '\n');
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* best-effort */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
69
91
|
return { ...exports.DEFAULT_CONFIG, ...parsed };
|
|
70
92
|
}
|
|
71
93
|
}
|
|
@@ -92,6 +114,9 @@ function effectiveConfig(base) {
|
|
|
92
114
|
out.theme = process.env.VECTOR_THEME;
|
|
93
115
|
if (process.env.VECTOR_YOLO === '1')
|
|
94
116
|
out.permissionMode = 'yolo';
|
|
117
|
+
// Opt back into per-action confirmation prompts.
|
|
118
|
+
if (process.env.VECTOR_ASK === '1')
|
|
119
|
+
out.permissionMode = 'ask';
|
|
95
120
|
// Per-provider default model: when the provider comes from the environment
|
|
96
121
|
// and no explicit model env was given, follow the provider definition's
|
|
97
122
|
// declared default (config.json providers.<id>.model).
|
|
@@ -96,6 +96,19 @@ class AnthropicProvider {
|
|
|
96
96
|
body.tools = toToolDefs(params.tools);
|
|
97
97
|
if (params.temperature !== undefined)
|
|
98
98
|
body.temperature = params.temperature;
|
|
99
|
+
// Extended thinking (Anthropic): ask for a reasoning budget so thinking
|
|
100
|
+
// models (Claude 3.7+/4 family) stream thinking_delta blocks — rendered
|
|
101
|
+
// as the • Thinking card. Harmless for non-thinking models? No — the API
|
|
102
|
+
// REJECTS thinking for models without extended-thinking support, so it
|
|
103
|
+
// is opt-in via VECTOR_ANTHROPIC_THINKING=1 (or a budget explicitly set
|
|
104
|
+
// here), keeping every other model working unchanged.
|
|
105
|
+
const wantThinking = process.env.VECTOR_ANTHROPIC_THINKING === '1';
|
|
106
|
+
const budget = Number(process.env.VECTOR_ANTHROPIC_THINKING_BUDGET || 0);
|
|
107
|
+
if (wantThinking || budget > 0) {
|
|
108
|
+
body.thinking = { type: 'enabled', budget_tokens: budget > 0 ? budget : 8_000 };
|
|
109
|
+
// Anthropic requires temperature unset (or 1) when thinking is on.
|
|
110
|
+
delete body.temperature;
|
|
111
|
+
}
|
|
99
112
|
return body;
|
|
100
113
|
}
|
|
101
114
|
async post(body, signal) {
|
|
@@ -133,10 +146,13 @@ class AnthropicProvider {
|
|
|
133
146
|
const data = await this.post(this.buildBody(params, false), params.signal);
|
|
134
147
|
const content = data.content || [];
|
|
135
148
|
let text = '';
|
|
149
|
+
let thinking = '';
|
|
136
150
|
const toolCalls = [];
|
|
137
151
|
for (const block of content) {
|
|
138
152
|
if (block.type === 'text')
|
|
139
153
|
text += String(block.text || '');
|
|
154
|
+
if (block.type === 'thinking')
|
|
155
|
+
thinking += String(block.thinking || '');
|
|
140
156
|
if (block.type === 'tool_use') {
|
|
141
157
|
toolCalls.push({
|
|
142
158
|
id: String(block.id),
|
|
@@ -148,6 +164,7 @@ class AnthropicProvider {
|
|
|
148
164
|
const usage = data.usage;
|
|
149
165
|
return {
|
|
150
166
|
content: text,
|
|
167
|
+
reasoning: thinking || undefined,
|
|
151
168
|
toolCalls,
|
|
152
169
|
stopReason: String(data.stop_reason || 'end_turn'),
|
|
153
170
|
usage: usage
|
|
@@ -181,6 +198,7 @@ class AnthropicProvider {
|
|
|
181
198
|
const decoder = new TextDecoder();
|
|
182
199
|
let buffer = '';
|
|
183
200
|
let content = '';
|
|
201
|
+
let thinking = '';
|
|
184
202
|
const toolCalls = [];
|
|
185
203
|
let currentTool = null;
|
|
186
204
|
for (;;) {
|
|
@@ -210,12 +228,21 @@ class AnthropicProvider {
|
|
|
210
228
|
arguments: '',
|
|
211
229
|
};
|
|
212
230
|
}
|
|
231
|
+
else if (evtName === 'content_block_start' && contentBlock?.type === 'thinking') {
|
|
232
|
+
// Thinking block starts — its deltas carry type thinking_delta.
|
|
233
|
+
}
|
|
213
234
|
else if (evtName === 'content_block_delta') {
|
|
214
235
|
const delta = json.delta;
|
|
215
236
|
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
|
216
237
|
content += delta.text;
|
|
217
238
|
yield { type: 'delta', delta: delta.text };
|
|
218
239
|
}
|
|
240
|
+
else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') {
|
|
241
|
+
// Extended thinking (Claude 3.7+/4): surface as reasoning so
|
|
242
|
+
// the • Thinking card renders for these models too.
|
|
243
|
+
thinking += delta.thinking;
|
|
244
|
+
yield { type: 'delta', reasoning: delta.thinking };
|
|
245
|
+
}
|
|
219
246
|
else if (delta?.type === 'input_json_delta' &&
|
|
220
247
|
currentTool &&
|
|
221
248
|
typeof delta.partial_json === 'string') {
|
|
@@ -252,7 +279,13 @@ class AnthropicProvider {
|
|
|
252
279
|
toolCalls.push(currentTool);
|
|
253
280
|
yield {
|
|
254
281
|
type: 'done',
|
|
255
|
-
result: {
|
|
282
|
+
result: {
|
|
283
|
+
content,
|
|
284
|
+
reasoning: thinking || undefined,
|
|
285
|
+
toolCalls,
|
|
286
|
+
stopReason: 'end_turn',
|
|
287
|
+
model: params.model,
|
|
288
|
+
},
|
|
256
289
|
};
|
|
257
290
|
}
|
|
258
291
|
finally {
|
package/dist/providers/gemini.js
CHANGED
|
@@ -71,10 +71,17 @@ function extractResult(data) {
|
|
|
71
71
|
const content0 = candidates[0]?.content || {};
|
|
72
72
|
const parts = content0.parts || [];
|
|
73
73
|
let content = '';
|
|
74
|
+
let reasoning = '';
|
|
74
75
|
const toolCalls = [];
|
|
75
76
|
for (const part of parts) {
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
// Thinking models (Gemini 2.5/3 family) mark reasoning parts with
|
|
78
|
+
// thought: true — surface them as reasoning, never as answer text.
|
|
79
|
+
if (typeof part.text === 'string') {
|
|
80
|
+
if (part.thought === true)
|
|
81
|
+
reasoning += part.text;
|
|
82
|
+
else
|
|
83
|
+
content += part.text;
|
|
84
|
+
}
|
|
78
85
|
if (part.functionCall) {
|
|
79
86
|
const raw = part.functionCall;
|
|
80
87
|
const fcs = Array.isArray(raw) ? raw : [raw];
|
|
@@ -87,7 +94,7 @@ function extractResult(data) {
|
|
|
87
94
|
}
|
|
88
95
|
}
|
|
89
96
|
}
|
|
90
|
-
return { content, toolCalls };
|
|
97
|
+
return { content, reasoning, toolCalls };
|
|
91
98
|
}
|
|
92
99
|
class GeminiProvider {
|
|
93
100
|
id = 'gemini';
|
|
@@ -151,9 +158,10 @@ class GeminiProvider {
|
|
|
151
158
|
throw new provider_1.ProviderError(`Gemini HTTP ${res.status}: ${text.slice(0, 300)}`, res.status, res.status >= 500);
|
|
152
159
|
}
|
|
153
160
|
const data = JSON.parse(text);
|
|
154
|
-
const { content, toolCalls } = extractResult(data);
|
|
161
|
+
const { content, reasoning, toolCalls } = extractResult(data);
|
|
155
162
|
return {
|
|
156
163
|
content,
|
|
164
|
+
reasoning: reasoning || undefined,
|
|
157
165
|
toolCalls,
|
|
158
166
|
stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
|
|
159
167
|
model: params.model,
|
|
@@ -169,6 +177,7 @@ class GeminiProvider {
|
|
|
169
177
|
const decoder = new TextDecoder();
|
|
170
178
|
let buffer = '';
|
|
171
179
|
let content = '';
|
|
180
|
+
let reasoning = '';
|
|
172
181
|
const toolCalls = [];
|
|
173
182
|
for (;;) {
|
|
174
183
|
const { done, value } = await reader.read();
|
|
@@ -187,7 +196,11 @@ class GeminiProvider {
|
|
|
187
196
|
const json = safeJson(payload);
|
|
188
197
|
if (!json)
|
|
189
198
|
continue;
|
|
190
|
-
const { content: deltaText, toolCalls: newCalls } = extractResult(json);
|
|
199
|
+
const { content: deltaText, reasoning: deltaThought, toolCalls: newCalls } = extractResult(json);
|
|
200
|
+
if (deltaThought) {
|
|
201
|
+
reasoning += deltaThought;
|
|
202
|
+
yield { type: 'delta', reasoning: deltaThought };
|
|
203
|
+
}
|
|
191
204
|
if (deltaText) {
|
|
192
205
|
content += deltaText;
|
|
193
206
|
yield { type: 'delta', delta: deltaText };
|
|
@@ -202,6 +215,7 @@ class GeminiProvider {
|
|
|
202
215
|
type: 'done',
|
|
203
216
|
result: {
|
|
204
217
|
content,
|
|
218
|
+
reasoning: reasoning || undefined,
|
|
205
219
|
toolCalls,
|
|
206
220
|
stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
|
|
207
221
|
model: params.model,
|
package/dist/providers/ollama.js
CHANGED
|
@@ -94,8 +94,12 @@ class OllamaProvider {
|
|
|
94
94
|
arguments: typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments ?? {}),
|
|
95
95
|
};
|
|
96
96
|
});
|
|
97
|
+
// Thinking models (Ollama ≥0.9: DeepSeek-R1, Qwen3 …) return the
|
|
98
|
+
// reasoning in message.thinking when the model supports it.
|
|
99
|
+
const thinking = typeof message.thinking === 'string' ? message.thinking : '';
|
|
97
100
|
return {
|
|
98
101
|
content: String(message.content || ''),
|
|
102
|
+
reasoning: thinking || undefined,
|
|
99
103
|
toolCalls,
|
|
100
104
|
stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
|
|
101
105
|
model: String(data.model || params.model),
|
|
@@ -118,6 +122,7 @@ class OllamaProvider {
|
|
|
118
122
|
const decoder = new TextDecoder();
|
|
119
123
|
let buffer = '';
|
|
120
124
|
let content = '';
|
|
125
|
+
let reasoning = '';
|
|
121
126
|
const toolCalls = [];
|
|
122
127
|
for (;;) {
|
|
123
128
|
const { done, value } = await reader.read();
|
|
@@ -138,6 +143,7 @@ class OllamaProvider {
|
|
|
138
143
|
type: 'done',
|
|
139
144
|
result: {
|
|
140
145
|
content,
|
|
146
|
+
reasoning: reasoning || undefined,
|
|
141
147
|
toolCalls,
|
|
142
148
|
stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
|
|
143
149
|
model: params.model,
|
|
@@ -146,6 +152,12 @@ class OllamaProvider {
|
|
|
146
152
|
return;
|
|
147
153
|
}
|
|
148
154
|
const message = (json.message || {});
|
|
155
|
+
// Thinking models stream reasoning in message.thinking (Ollama ≥0.9,
|
|
156
|
+
// DeepSeek-R1/Qwen3) — surface it so the • Thinking card renders.
|
|
157
|
+
if (typeof message.thinking === 'string' && message.thinking) {
|
|
158
|
+
reasoning += message.thinking;
|
|
159
|
+
yield { type: 'delta', reasoning: message.thinking };
|
|
160
|
+
}
|
|
149
161
|
if (typeof message.content === 'string' && message.content) {
|
|
150
162
|
content += message.content;
|
|
151
163
|
yield { type: 'delta', delta: message.content };
|
|
@@ -165,7 +177,7 @@ class OllamaProvider {
|
|
|
165
177
|
}
|
|
166
178
|
yield {
|
|
167
179
|
type: 'done',
|
|
168
|
-
result: { content, toolCalls, stopReason: 'stop', model: params.model },
|
|
180
|
+
result: { content, reasoning: reasoning || undefined, toolCalls, stopReason: 'stop', model: params.model },
|
|
169
181
|
};
|
|
170
182
|
}
|
|
171
183
|
async models() {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.OpenAICompatProvider = void 0;
|
|
4
4
|
exports.stripThinkTags = stripThinkTags;
|
|
5
|
+
exports.createThinkTagStreamSplitter = createThinkTagStreamSplitter;
|
|
5
6
|
/**
|
|
6
7
|
* OpenAI-compatible provider — used by OpenAI, OpenRouter and Custom.
|
|
7
8
|
* Speaks /v1/chat/completions (and /models) with tool calling + streaming.
|
|
@@ -59,6 +60,81 @@ function extractReasoning(msg) {
|
|
|
59
60
|
function stripThinkTags(text) {
|
|
60
61
|
return text.replace(/<think>[\s\S]*?<\/think>/g, '').replace(/^\s*<think>[\s\S]*$/, '').trim();
|
|
61
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Incremental `<think>…</think>` tag splitter for STREAMING: OpenAI-compat
|
|
65
|
+
* gateways often proxy reasoning models (DeepSeek-R1, QwQ, Qwen3 …) that
|
|
66
|
+
* inline their thinking into `delta.content` instead of a separate
|
|
67
|
+
* `reasoning_content` field. Non-streaming already strips via
|
|
68
|
+
* stripThinkTags — the streaming path needs the same treatment or the
|
|
69
|
+
* • Thinking card never appears for those models (raw <think> text used to
|
|
70
|
+
* leak into the visible answer too).
|
|
71
|
+
*/
|
|
72
|
+
function createThinkTagStreamSplitter() {
|
|
73
|
+
let inside = false;
|
|
74
|
+
let tail = ''; // partial tag spanning chunk boundaries
|
|
75
|
+
const flushTail = (emit) => {
|
|
76
|
+
// Find the highest safe split point: a '<' that MIGHT begin a tag when
|
|
77
|
+
// more text arrives is kept buffered; everything before it is emitted.
|
|
78
|
+
const lt = tail.indexOf('<');
|
|
79
|
+
if (lt === -1) {
|
|
80
|
+
emit(tail, inside);
|
|
81
|
+
tail = '';
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (lt > 0) {
|
|
85
|
+
emit(tail.slice(0, lt), inside);
|
|
86
|
+
tail = tail.slice(lt);
|
|
87
|
+
}
|
|
88
|
+
// tail now starts with '<' — wait for more data.
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
/** Feed one content delta; returns {content, reasoning} pieces. */
|
|
92
|
+
push(chunk) {
|
|
93
|
+
const content = [];
|
|
94
|
+
const reasoning = [];
|
|
95
|
+
const emit = (s, isReasoning) => {
|
|
96
|
+
if (s)
|
|
97
|
+
(isReasoning ? reasoning : content).push(s);
|
|
98
|
+
};
|
|
99
|
+
tail += chunk;
|
|
100
|
+
for (;;) {
|
|
101
|
+
if (!inside) {
|
|
102
|
+
const open = tail.indexOf('<think>');
|
|
103
|
+
if (open !== -1) {
|
|
104
|
+
emit(tail.slice(0, open), false);
|
|
105
|
+
tail = tail.slice(open + 7);
|
|
106
|
+
inside = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
flushTail(emit);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const close = tail.indexOf('</think>');
|
|
114
|
+
if (close !== -1) {
|
|
115
|
+
emit(tail.slice(0, close), true);
|
|
116
|
+
tail = tail.slice(close + 8);
|
|
117
|
+
inside = false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
flushTail(emit);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { content, reasoning };
|
|
125
|
+
},
|
|
126
|
+
/** End of stream: flush any buffered remainder (an unterminated
|
|
127
|
+
* <think> block counts as reasoning). */
|
|
128
|
+
finish() {
|
|
129
|
+
const content = [];
|
|
130
|
+
const reasoning = [];
|
|
131
|
+
if (tail)
|
|
132
|
+
(inside ? reasoning : content).push(tail);
|
|
133
|
+
tail = '';
|
|
134
|
+
return { content, reasoning };
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
62
138
|
function parseToolCalls(raw) {
|
|
63
139
|
const calls = [];
|
|
64
140
|
if (!Array.isArray(raw))
|
|
@@ -233,6 +309,10 @@ class OpenAICompatProvider {
|
|
|
233
309
|
let content = '';
|
|
234
310
|
let reasoning = '';
|
|
235
311
|
const toolCalls = [];
|
|
312
|
+
// Reasoning models behind OpenAI-compat gateways may inline thinking
|
|
313
|
+
// into delta.content as <think>…</think> (DeepSeek-R1/QwQ/Qwen3).
|
|
314
|
+
// Split it out so the • Thinking card works on EVERY model.
|
|
315
|
+
const thinkSplit = createThinkTagStreamSplitter();
|
|
236
316
|
for (;;) {
|
|
237
317
|
const { done, value } = await reader.read();
|
|
238
318
|
if (done)
|
|
@@ -246,6 +326,12 @@ class OpenAICompatProvider {
|
|
|
246
326
|
continue;
|
|
247
327
|
const payload = trimmed.slice(5).trim();
|
|
248
328
|
if (payload === '[DONE]') {
|
|
329
|
+
// Flush any <think> splitter remainder before finishing.
|
|
330
|
+
const tail = thinkSplit.finish();
|
|
331
|
+
for (const r of tail.reasoning)
|
|
332
|
+
reasoning += r;
|
|
333
|
+
for (const c of tail.content)
|
|
334
|
+
content += c;
|
|
249
335
|
yield {
|
|
250
336
|
type: 'done',
|
|
251
337
|
result: {
|
|
@@ -276,8 +362,15 @@ class OpenAICompatProvider {
|
|
|
276
362
|
yield { type: 'delta', reasoning: rc };
|
|
277
363
|
}
|
|
278
364
|
if (typeof delta.content === 'string' && delta.content) {
|
|
279
|
-
|
|
280
|
-
|
|
365
|
+
const pieces = thinkSplit.push(delta.content);
|
|
366
|
+
for (const r of pieces.reasoning) {
|
|
367
|
+
reasoning += r;
|
|
368
|
+
yield { type: 'delta', reasoning: r };
|
|
369
|
+
}
|
|
370
|
+
for (const c of pieces.content) {
|
|
371
|
+
content += c;
|
|
372
|
+
yield { type: 'delta', delta: c };
|
|
373
|
+
}
|
|
281
374
|
}
|
|
282
375
|
const tc = delta.tool_calls;
|
|
283
376
|
if (Array.isArray(tc)) {
|
|
@@ -311,7 +404,12 @@ class OpenAICompatProvider {
|
|
|
311
404
|
}
|
|
312
405
|
}
|
|
313
406
|
}
|
|
314
|
-
// Stream ended without [DONE]
|
|
407
|
+
// Stream ended without [DONE] — flush the <think> splitter tail.
|
|
408
|
+
const tail = thinkSplit.finish();
|
|
409
|
+
for (const r of tail.reasoning)
|
|
410
|
+
reasoning += r;
|
|
411
|
+
for (const c of tail.content)
|
|
412
|
+
content += c;
|
|
315
413
|
yield {
|
|
316
414
|
type: 'done',
|
|
317
415
|
result: {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.classifyCommand = classifyCommand;
|
|
4
|
+
const sensitive_files_1 = require("./sensitive-files");
|
|
4
5
|
const BLOCKED_PATTERNS = [
|
|
5
6
|
/(^|[;&|]\s*)\s*rm\s+(-[a-z]*[rf][a-z]*\s+)*\/\s*($|[^a-zA-Z])/,
|
|
6
7
|
/(^|[;&|]\s*)\s*rm\s+(-[a-z]*[rf][a-z]*\s+)*~(\/|$)/,
|
|
@@ -59,6 +60,12 @@ const ASK_PATTERNS = [
|
|
|
59
60
|
/** Commands that are safe to run without asking. */
|
|
60
61
|
const SAFE_PATTERNS = [
|
|
61
62
|
/(^|[;&|]\s*)\s*(ls|cat|head|tail|less|more|wc|grep|find|locate|which|type|file|stat|du|df|tree)\s/,
|
|
63
|
+
// Local dev-server plumbing (compound verification commands like
|
|
64
|
+
// `cd web && python3 -m http.server 8917 & sleep 1; curl localhost; kill %1`)
|
|
65
|
+
/(^|[;&|]\s*)\s*cd\s+\S+/,
|
|
66
|
+
/(^|[;&|]\s*)\s*sleep\s+\d/,
|
|
67
|
+
/(^|[;&|]\s*)\s*python3?\s+-m\s+http\.server\b/,
|
|
68
|
+
/(^|[;&|]\s*)\s*kill\s+%\d+/,
|
|
62
69
|
/(^|[;&|]\s*)\s*echo\s/,
|
|
63
70
|
/(^|[;&|]\s*)\s*node\s+(-v|--version)/,
|
|
64
71
|
/(^|[;&|]\s*)\s*npm\s+(-v|--version|ls|list)\s*/,
|
|
@@ -78,11 +85,63 @@ function classifyCommand(command) {
|
|
|
78
85
|
return { level: 'blocked', reason: `command matches blocked pattern: ${re.source}` };
|
|
79
86
|
}
|
|
80
87
|
}
|
|
88
|
+
// Sensitive-file escalation (Freebuff fileFilter spirit): a shell command
|
|
89
|
+
// that references a secret (.env, private keys, credentials.json, …) must
|
|
90
|
+
// be PROMPTED even in yolo mode — `cat .env` / `cp id_rsa /tmp` / piping
|
|
91
|
+
// secrets into the network is the classic exfiltration path around the
|
|
92
|
+
// fs-tool guard. Only `cat *.example` template references stay exempt so
|
|
93
|
+
// agents can still learn the config surface.
|
|
94
|
+
const tokens = command.split(/\s+/).map((t) => t.replace(/['"`]/g, ''));
|
|
95
|
+
const touchesSensitive = tokens.some((raw) => {
|
|
96
|
+
// curl-style --data=@file and @file forms: keep only the path part.
|
|
97
|
+
const t = raw.split('=').pop() ?? raw;
|
|
98
|
+
const base = (t.startsWith('@') ? t.slice(1) : t).split('/').pop() ?? '';
|
|
99
|
+
if (!base)
|
|
100
|
+
return false;
|
|
101
|
+
if (base.endsWith('.example') || base.endsWith('.sample'))
|
|
102
|
+
return false;
|
|
103
|
+
return (0, sensitive_files_1.isSensitiveFile)(base) || (0, sensitive_files_1.isSensitiveEnvFilePath)(base);
|
|
104
|
+
});
|
|
105
|
+
if (touchesSensitive) {
|
|
106
|
+
return { level: 'dangerous', reason: 'command references a sensitive file (secrets must be prompted even in yolo)' };
|
|
107
|
+
}
|
|
81
108
|
for (const re of DANGEROUS_PATTERNS) {
|
|
82
109
|
if (re.test(command)) {
|
|
83
110
|
return { level: 'dangerous', reason: `command matches dangerous pattern: ${re.source}` };
|
|
84
111
|
}
|
|
85
112
|
}
|
|
113
|
+
// Loopback exemption (Freebuff-style smooth local dev): curl/wget whose
|
|
114
|
+
// EVERY network target is localhost/127.0.0.1 cannot exfiltrate anything
|
|
115
|
+
// off the machine, so testing a local dev server should not prompt. Runs
|
|
116
|
+
// AFTER the dangerous/sensitive checks so `curl | bash` and
|
|
117
|
+
// `curl --data @secrets http://localhost` still escalate. Any remote host
|
|
118
|
+
// in the same command (or an extra URL) falls through to the ASK pattern.
|
|
119
|
+
if (/(^|[;&|]\s*)\s*(curl|wget)\s/.test(command)) {
|
|
120
|
+
const targets = [];
|
|
121
|
+
for (const seg of command.split(/[;&|]+/)) {
|
|
122
|
+
const m = seg.trim().match(/^(curl|wget)\s+(.*)$/);
|
|
123
|
+
if (!m)
|
|
124
|
+
continue;
|
|
125
|
+
for (const raw of m[2].split(/\s+/)) {
|
|
126
|
+
const t = raw.replace(/['"`]/g, '').split('=').pop() ?? '';
|
|
127
|
+
if (t.startsWith('-'))
|
|
128
|
+
continue;
|
|
129
|
+
// URL/host-shaped token: scheme://host, host:port, or host.tld
|
|
130
|
+
if (/^https?:\/\//i.test(t) || (/^[\w.\-\[%]+(:\d+)?(\/|$|\?)/.test(t) && /[.:\[%]|localhost/.test(t))) {
|
|
131
|
+
targets.push(t);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const isLoopback = (tok) => {
|
|
136
|
+
let s = tok.replace(/^https?:\/\//i, '').toLowerCase();
|
|
137
|
+
s = (s.split('/')[0] ?? '').split(':')[0] ?? '';
|
|
138
|
+
return (s === 'localhost' || s === '127.0.0.1' || s === '::1' ||
|
|
139
|
+
s === '[::1]' || s === '[::]' || s.endsWith('.localhost'));
|
|
140
|
+
};
|
|
141
|
+
if (targets.length > 0 && targets.every(isLoopback)) {
|
|
142
|
+
return { level: 'safe', reason: 'curl/wget targets loopback only (local dev server test, no remote transfer)' };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
86
145
|
for (const re of ASK_PATTERNS) {
|
|
87
146
|
if (re.test(command)) {
|
|
88
147
|
return { level: 'ask', reason: `command matches ask pattern: ${re.source}` };
|