agen-vektor 0.3.11 → 0.3.13
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/agent/agent.js +61 -1
- package/dist/agent/loop.js +5 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/cli/index.js +35 -20
- package/dist/cli/keyboard.js +56 -3
- package/dist/config/config.js +26 -1
- package/dist/config/free-tier.js +230 -0
- 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 +171 -31
- 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/tools/search.js +62 -12
- package/dist/tui/app.js +489 -101
- package/dist/tui/chat.js +119 -43
- package/dist/tui/commands.js +34 -21
- package/dist/tui/components.js +143 -68
- package/dist/tui/input.js +24 -0
- package/dist/tui/statusbar.js +25 -11
- package/dist/tui/suggest.js +27 -9
- package/dist/utils/terminal.js +64 -0
- package/package.json +4 -1
|
@@ -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,12 +2,69 @@
|
|
|
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.
|
|
8
9
|
*/
|
|
9
10
|
const provider_1 = require("./provider");
|
|
10
11
|
const credentials_1 = require("../config/credentials");
|
|
12
|
+
/**
|
|
13
|
+
* Stream delta-collector for tool calls.
|
|
14
|
+
*
|
|
15
|
+
* WHY A MAP, NOT AN ARRAY INDEXED BY `part.index`:
|
|
16
|
+
* OpenAI spec says tool_calls deltas carry a 0-based `index` per tool call,
|
|
17
|
+
* and arguments arrive in fragments. Most gateways comply. But some gateways
|
|
18
|
+
* proxy Anthropic models and leak the UPSTREAM content-block positions as
|
|
19
|
+
* `index` (thinking=0, text=1, tool_use=2, ...) — with one tool call the
|
|
20
|
+
* chunks arrive with `index:2` (observed live on api.justwoker.icu, 2026-09-04).
|
|
21
|
+
* Indexing a plain array by that value produces a SPARSE array
|
|
22
|
+
* ([<hole>, <hole>, call]) which silently breaks `for..of` iteration in the
|
|
23
|
+
* agent loop (holes are skipped) and corrupts the follow-up request history.
|
|
24
|
+
* Collect per index in a Map, emit a DENSE array in arrival order, deduped
|
|
25
|
+
* by id (Anthropic ids are unique per call; OpenAI reuses one id across
|
|
26
|
+
* argument fragments).
|
|
27
|
+
*/
|
|
28
|
+
class StreamToolCallCollector {
|
|
29
|
+
byIndex = new Map();
|
|
30
|
+
/** Feed one delta.tool_calls part; returns the latest complete call, if any. */
|
|
31
|
+
add(part) {
|
|
32
|
+
const idx = Number(part.index || 0);
|
|
33
|
+
const fn = (part.function || {});
|
|
34
|
+
const existing = this.byIndex.get(idx);
|
|
35
|
+
if (!existing) {
|
|
36
|
+
this.byIndex.set(idx, {
|
|
37
|
+
id: String(part.id || `call_${idx}_${Math.random().toString(36).slice(2)}`),
|
|
38
|
+
name: String(fn.name || ''),
|
|
39
|
+
arguments: String(fn.arguments || ''),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
if (part.id)
|
|
44
|
+
existing.id = String(part.id);
|
|
45
|
+
if (fn.name)
|
|
46
|
+
existing.name = String(fn.name);
|
|
47
|
+
if (fn.arguments)
|
|
48
|
+
existing.arguments += String(fn.arguments);
|
|
49
|
+
}
|
|
50
|
+
const finished = this.finished();
|
|
51
|
+
return finished.length > 0 ? finished[finished.length - 1] : null;
|
|
52
|
+
}
|
|
53
|
+
/** All complete calls so far, in arrival order, DENSE (no holes/nulls). */
|
|
54
|
+
finished() {
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const call of this.byIndex.values()) {
|
|
58
|
+
if (!call.name)
|
|
59
|
+
continue; // incomplete: no function name yet
|
|
60
|
+
if (seen.has(call.id))
|
|
61
|
+
continue; // fragment of an already-collected call
|
|
62
|
+
seen.add(call.id);
|
|
63
|
+
out.push({ id: call.id, name: call.name, arguments: call.arguments || '{}' });
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
11
68
|
function toOpenAIMessages(messages) {
|
|
12
69
|
const out = [];
|
|
13
70
|
for (const m of messages) {
|
|
@@ -59,6 +116,81 @@ function extractReasoning(msg) {
|
|
|
59
116
|
function stripThinkTags(text) {
|
|
60
117
|
return text.replace(/<think>[\s\S]*?<\/think>/g, '').replace(/^\s*<think>[\s\S]*$/, '').trim();
|
|
61
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Incremental `<think>…</think>` tag splitter for STREAMING: OpenAI-compat
|
|
121
|
+
* gateways often proxy reasoning models (DeepSeek-R1, QwQ, Qwen3 …) that
|
|
122
|
+
* inline their thinking into `delta.content` instead of a separate
|
|
123
|
+
* `reasoning_content` field. Non-streaming already strips via
|
|
124
|
+
* stripThinkTags — the streaming path needs the same treatment or the
|
|
125
|
+
* • Thinking card never appears for those models (raw <think> text used to
|
|
126
|
+
* leak into the visible answer too).
|
|
127
|
+
*/
|
|
128
|
+
function createThinkTagStreamSplitter() {
|
|
129
|
+
let inside = false;
|
|
130
|
+
let tail = ''; // partial tag spanning chunk boundaries
|
|
131
|
+
const flushTail = (emit) => {
|
|
132
|
+
// Find the highest safe split point: a '<' that MIGHT begin a tag when
|
|
133
|
+
// more text arrives is kept buffered; everything before it is emitted.
|
|
134
|
+
const lt = tail.indexOf('<');
|
|
135
|
+
if (lt === -1) {
|
|
136
|
+
emit(tail, inside);
|
|
137
|
+
tail = '';
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (lt > 0) {
|
|
141
|
+
emit(tail.slice(0, lt), inside);
|
|
142
|
+
tail = tail.slice(lt);
|
|
143
|
+
}
|
|
144
|
+
// tail now starts with '<' — wait for more data.
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
/** Feed one content delta; returns {content, reasoning} pieces. */
|
|
148
|
+
push(chunk) {
|
|
149
|
+
const content = [];
|
|
150
|
+
const reasoning = [];
|
|
151
|
+
const emit = (s, isReasoning) => {
|
|
152
|
+
if (s)
|
|
153
|
+
(isReasoning ? reasoning : content).push(s);
|
|
154
|
+
};
|
|
155
|
+
tail += chunk;
|
|
156
|
+
for (;;) {
|
|
157
|
+
if (!inside) {
|
|
158
|
+
const open = tail.indexOf('<think>');
|
|
159
|
+
if (open !== -1) {
|
|
160
|
+
emit(tail.slice(0, open), false);
|
|
161
|
+
tail = tail.slice(open + 7);
|
|
162
|
+
inside = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
flushTail(emit);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const close = tail.indexOf('</think>');
|
|
170
|
+
if (close !== -1) {
|
|
171
|
+
emit(tail.slice(0, close), true);
|
|
172
|
+
tail = tail.slice(close + 8);
|
|
173
|
+
inside = false;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
flushTail(emit);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { content, reasoning };
|
|
181
|
+
},
|
|
182
|
+
/** End of stream: flush any buffered remainder (an unterminated
|
|
183
|
+
* <think> block counts as reasoning). */
|
|
184
|
+
finish() {
|
|
185
|
+
const content = [];
|
|
186
|
+
const reasoning = [];
|
|
187
|
+
if (tail)
|
|
188
|
+
(inside ? reasoning : content).push(tail);
|
|
189
|
+
tail = '';
|
|
190
|
+
return { content, reasoning };
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
62
194
|
function parseToolCalls(raw) {
|
|
63
195
|
const calls = [];
|
|
64
196
|
if (!Array.isArray(raw))
|
|
@@ -232,7 +364,14 @@ class OpenAICompatProvider {
|
|
|
232
364
|
let buffer = '';
|
|
233
365
|
let content = '';
|
|
234
366
|
let reasoning = '';
|
|
235
|
-
|
|
367
|
+
// Dense collector — see StreamToolCallCollector: some gateways send
|
|
368
|
+
// non-0-based tool_calls indices (Anthropic block positions), which
|
|
369
|
+
// used to produce a sparse toolCalls array the agent loop skipped.
|
|
370
|
+
const toolCollector = new StreamToolCallCollector();
|
|
371
|
+
// Reasoning models behind OpenAI-compat gateways may inline thinking
|
|
372
|
+
// into delta.content as <think>…</think> (DeepSeek-R1/QwQ/Qwen3).
|
|
373
|
+
// Split it out so the • Thinking card works on EVERY model.
|
|
374
|
+
const thinkSplit = createThinkTagStreamSplitter();
|
|
236
375
|
for (;;) {
|
|
237
376
|
const { done, value } = await reader.read();
|
|
238
377
|
if (done)
|
|
@@ -246,13 +385,20 @@ class OpenAICompatProvider {
|
|
|
246
385
|
continue;
|
|
247
386
|
const payload = trimmed.slice(5).trim();
|
|
248
387
|
if (payload === '[DONE]') {
|
|
388
|
+
// Flush any <think> splitter remainder before finishing.
|
|
389
|
+
const tail = thinkSplit.finish();
|
|
390
|
+
for (const r of tail.reasoning)
|
|
391
|
+
reasoning += r;
|
|
392
|
+
for (const c of tail.content)
|
|
393
|
+
content += c;
|
|
394
|
+
const calls = toolCollector.finished();
|
|
249
395
|
yield {
|
|
250
396
|
type: 'done',
|
|
251
397
|
result: {
|
|
252
398
|
content,
|
|
253
399
|
reasoning: reasoning || undefined,
|
|
254
|
-
toolCalls,
|
|
255
|
-
stopReason:
|
|
400
|
+
toolCalls: calls,
|
|
401
|
+
stopReason: calls.length > 0 ? 'tool_calls' : 'stop',
|
|
256
402
|
model: params.model,
|
|
257
403
|
},
|
|
258
404
|
};
|
|
@@ -276,48 +422,42 @@ class OpenAICompatProvider {
|
|
|
276
422
|
yield { type: 'delta', reasoning: rc };
|
|
277
423
|
}
|
|
278
424
|
if (typeof delta.content === 'string' && delta.content) {
|
|
279
|
-
|
|
280
|
-
|
|
425
|
+
const pieces = thinkSplit.push(delta.content);
|
|
426
|
+
for (const r of pieces.reasoning) {
|
|
427
|
+
reasoning += r;
|
|
428
|
+
yield { type: 'delta', reasoning: r };
|
|
429
|
+
}
|
|
430
|
+
for (const c of pieces.content) {
|
|
431
|
+
content += c;
|
|
432
|
+
yield { type: 'delta', delta: c };
|
|
433
|
+
}
|
|
281
434
|
}
|
|
282
435
|
const tc = delta.tool_calls;
|
|
283
436
|
if (Array.isArray(tc)) {
|
|
437
|
+
let latest = null;
|
|
284
438
|
for (const part of tc) {
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (!existing) {
|
|
289
|
-
toolCalls[idx] = {
|
|
290
|
-
id: String(part.id || `call_${idx}_${Math.random().toString(36).slice(2)}`),
|
|
291
|
-
name: String(fn.name || ''),
|
|
292
|
-
arguments: String(fn.arguments || ''),
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
else {
|
|
296
|
-
if (part.id)
|
|
297
|
-
existing.id = String(part.id);
|
|
298
|
-
if (fn.name)
|
|
299
|
-
existing.name = String(fn.name);
|
|
300
|
-
if (fn.arguments)
|
|
301
|
-
existing.arguments += String(fn.arguments);
|
|
302
|
-
}
|
|
439
|
+
const call = toolCollector.add(part);
|
|
440
|
+
if (call)
|
|
441
|
+
latest = call;
|
|
303
442
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
arguments: t.arguments || '{}',
|
|
307
|
-
}));
|
|
308
|
-
if (finished.length > 0) {
|
|
309
|
-
yield { type: 'tool_call', toolCall: finished[finished.length - 1] };
|
|
443
|
+
if (latest) {
|
|
444
|
+
yield { type: 'tool_call', toolCall: latest };
|
|
310
445
|
}
|
|
311
446
|
}
|
|
312
447
|
}
|
|
313
448
|
}
|
|
314
|
-
// Stream ended without [DONE]
|
|
449
|
+
// Stream ended without [DONE] — flush the <think> splitter tail.
|
|
450
|
+
const tail = thinkSplit.finish();
|
|
451
|
+
for (const r of tail.reasoning)
|
|
452
|
+
reasoning += r;
|
|
453
|
+
for (const c of tail.content)
|
|
454
|
+
content += c;
|
|
315
455
|
yield {
|
|
316
456
|
type: 'done',
|
|
317
457
|
result: {
|
|
318
458
|
content,
|
|
319
459
|
reasoning: reasoning || undefined,
|
|
320
|
-
toolCalls:
|
|
460
|
+
toolCalls: toolCollector.finished(),
|
|
321
461
|
stopReason: 'stop',
|
|
322
462
|
model: params.model,
|
|
323
463
|
},
|
|
@@ -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}` };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.isSensitiveEnvFilePath = isSensitiveEnvFilePath;
|
|
37
|
+
exports.isSensitiveFile = isSensitiveFile;
|
|
38
|
+
exports.sensitiveFileGuard = sensitiveFileGuard;
|
|
39
|
+
/**
|
|
40
|
+
* Sensitive-file guard — port of Freebuff's fileFilter
|
|
41
|
+
* (CodebuffAI/freebuff cli/src/utils/create-run-config.ts: isSensitiveFile +
|
|
42
|
+
* SENSITIVE_EXTENSIONS/BASENAMES/PATTERNS, plus @codebuff/common's
|
|
43
|
+
* isSensitiveEnvFilePath).
|
|
44
|
+
*
|
|
45
|
+
* Freebuff applies this as a hard `fileFilter` on every SDK file operation:
|
|
46
|
+
* reads AND writes of secrets (.env, private keys, credentials, kubeconfig…)
|
|
47
|
+
* are blocked outright regardless of yolo/ask mode — the model never sees
|
|
48
|
+
* their contents. This module gives vector-agent the same layer: the fs tools
|
|
49
|
+
* call `sensitiveFileGuard()` before touching a path, and shell commands that
|
|
50
|
+
* reference a sensitive basename are escalated to 'dangerous' by the command
|
|
51
|
+
* policy (prompted even in yolo mode).
|
|
52
|
+
*
|
|
53
|
+
* Escape hatch for power users: VECTOR_ALLOW_SENSITIVE=1 disables the guard
|
|
54
|
+
* (documented in HANDOFF.md — off by default, like Freebuff).
|
|
55
|
+
*/
|
|
56
|
+
const fs = __importStar(require("node:fs"));
|
|
57
|
+
const path = __importStar(require("node:path"));
|
|
58
|
+
/** File extensions that are always secrets (Freebuff SENSITIVE_EXTENSIONS). */
|
|
59
|
+
const SENSITIVE_EXTENSIONS = new Set([
|
|
60
|
+
'.pem', '.key', '.p12', '.pfx', '.jks', '.keystore', '.crt', '.cer',
|
|
61
|
+
]);
|
|
62
|
+
/** Exact basenames that are always secrets (Freebuff SENSITIVE_BASENAMES,
|
|
63
|
+
* plus credentials.json — vector-agent's own key store). */
|
|
64
|
+
const SENSITIVE_BASENAMES = new Set([
|
|
65
|
+
'.htpasswd', '.netrc', 'credentials', 'credentials.json',
|
|
66
|
+
'.npmrc', '.yarnrc', '.yarnrc.yml', 'auth.json', '.pypirc',
|
|
67
|
+
'terraform.tfvars', '.terraformrc', '.vector-credentials',
|
|
68
|
+
]);
|
|
69
|
+
/** SSH / private-key prefixes (Freebuff SENSITIVE_PATTERNS.prefix; the .pub
|
|
70
|
+
* counterpart is explicitly allowed). */
|
|
71
|
+
const SENSITIVE_KEY_PREFIXES = ['id_rsa', 'id_ed25519', 'id_dsa', 'id_ecdsa'];
|
|
72
|
+
/** Substring hits (Freebuff SENSITIVE_PATTERNS.substring). */
|
|
73
|
+
const SENSITIVE_SUBSTRINGS = ['kubeconfig', '.tfstate'];
|
|
74
|
+
/** Suffixes that mark a TEMPLATE — safe to read so agents can learn which
|
|
75
|
+
* env vars exist (.env.example etc). Not in Freebuff, but our listing tools
|
|
76
|
+
* already surface them as visible files. */
|
|
77
|
+
const TEMPLATE_SUFFIXES = ['.example', '.sample', '.template'];
|
|
78
|
+
/** Port of @codebuff/common isSensitiveEnvFilePath: basename is `.env` or a
|
|
79
|
+
* `.env.<anything>` variant (.env.local, .env.production, …), or `.envrc`. */
|
|
80
|
+
function isSensitiveEnvFilePath(filePath) {
|
|
81
|
+
const base = path.basename(filePath).toLowerCase();
|
|
82
|
+
return base === '.env' || base === '.envrc' || base.startsWith('.env.');
|
|
83
|
+
}
|
|
84
|
+
/** Check if a file is sensitive and must be blocked from tool access. */
|
|
85
|
+
function isSensitiveFile(filePath) {
|
|
86
|
+
const base = path.basename(filePath);
|
|
87
|
+
const lower = base.toLowerCase();
|
|
88
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
89
|
+
if (TEMPLATE_SUFFIXES.some((s) => lower.endsWith(s)))
|
|
90
|
+
return false;
|
|
91
|
+
if (isSensitiveEnvFilePath(filePath))
|
|
92
|
+
return true;
|
|
93
|
+
if (SENSITIVE_EXTENSIONS.has(ext))
|
|
94
|
+
return true;
|
|
95
|
+
if (SENSITIVE_BASENAMES.has(lower))
|
|
96
|
+
return true;
|
|
97
|
+
if (SENSITIVE_KEY_PREFIXES.some((p) => lower.startsWith(p)) &&
|
|
98
|
+
!lower.endsWith('.pub')) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (SENSITIVE_SUBSTRINGS.some((s) => lower.includes(s)))
|
|
102
|
+
return true;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Guard for fs tools: returns an ERROR output string when the path (lexical
|
|
107
|
+
* or, when it exists on disk, its real path — catches symlink + `../`
|
|
108
|
+
* escapes) is sensitive, null when the operation may proceed.
|
|
109
|
+
* VECTOR_ALLOW_SENSITIVE=1 disables the guard entirely.
|
|
110
|
+
*/
|
|
111
|
+
function sensitiveFileGuard(absPath, displayPath) {
|
|
112
|
+
if (process.env.VECTOR_ALLOW_SENSITIVE === '1')
|
|
113
|
+
return null;
|
|
114
|
+
const check = (p) => {
|
|
115
|
+
try {
|
|
116
|
+
return isSensitiveFile(p) || isSensitiveFile(fs.realpathSync(p));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return isSensitiveFile(p);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
if (check(absPath)) {
|
|
123
|
+
return `ERROR: blocked "${displayPath}" — sensitive file (secrets are never read/written; Freebuff fileFilter port). Use VECTOR_ALLOW_SENSITIVE=1 to override.`;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|