@princetheprogrammerbtw/husk 0.1.0
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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1146 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +702 -0
- package/dist/index.js +1110 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
import { promisify, parseArgs } from 'util';
|
|
2
|
+
import { promises } from 'fs';
|
|
3
|
+
import { resolve, dirname, join } from 'path';
|
|
4
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
5
|
+
import OpenAI from 'openai';
|
|
6
|
+
import { exec } from 'child_process';
|
|
7
|
+
|
|
8
|
+
// src/cli/index.ts
|
|
9
|
+
|
|
10
|
+
// src/core/events.ts
|
|
11
|
+
var AgentEventEmitter = class {
|
|
12
|
+
handlers = /* @__PURE__ */ new Map();
|
|
13
|
+
wildcardHandlers = [];
|
|
14
|
+
/**
|
|
15
|
+
* Subscribe to a specific event type. The handler receives only
|
|
16
|
+
* events of that type with the correct payload shape.
|
|
17
|
+
*/
|
|
18
|
+
on(type, handler) {
|
|
19
|
+
const list = this.handlers.get(type) ?? [];
|
|
20
|
+
list.push(handler);
|
|
21
|
+
this.handlers.set(type, list);
|
|
22
|
+
return () => this.off(type, handler);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Subscribe to all events. Useful for loggers and tracers.
|
|
26
|
+
*/
|
|
27
|
+
onAny(handler) {
|
|
28
|
+
this.wildcardHandlers.push(handler);
|
|
29
|
+
return () => {
|
|
30
|
+
const idx = this.wildcardHandlers.indexOf(handler);
|
|
31
|
+
if (idx >= 0) this.wildcardHandlers.splice(idx, 1);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
off(type, handler) {
|
|
35
|
+
const list = this.handlers.get(type);
|
|
36
|
+
if (!list) return;
|
|
37
|
+
const idx = list.indexOf(handler);
|
|
38
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Emit an event. Handlers are awaited sequentially; an async handler
|
|
42
|
+
* that throws is logged but doesn't stop subsequent handlers.
|
|
43
|
+
*/
|
|
44
|
+
async emit(event) {
|
|
45
|
+
const typed = this.handlers.get(event.type) ?? [];
|
|
46
|
+
for (const handler of typed) {
|
|
47
|
+
try {
|
|
48
|
+
await handler(event);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error("[husk] event handler threw:", err);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const handler of this.wildcardHandlers) {
|
|
54
|
+
try {
|
|
55
|
+
await handler(event);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error("[husk] wildcard event handler threw:", err);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
var ConsoleLogger = class {
|
|
63
|
+
debug(message, fields) {
|
|
64
|
+
console.debug(this.format("debug", message, fields));
|
|
65
|
+
}
|
|
66
|
+
info(message, fields) {
|
|
67
|
+
console.info(this.format("info", message, fields));
|
|
68
|
+
}
|
|
69
|
+
warn(message, fields) {
|
|
70
|
+
console.warn(this.format("warn", message, fields));
|
|
71
|
+
}
|
|
72
|
+
error(message, fields) {
|
|
73
|
+
console.error(this.format("error", message, fields));
|
|
74
|
+
}
|
|
75
|
+
format(level, message, fields) {
|
|
76
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
77
|
+
const fieldsStr = fields && Object.keys(fields).length > 0 ? ` ${JSON.stringify(fields)}` : "";
|
|
78
|
+
return `${ts} [${level}] ${message}${fieldsStr}`;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
var InMemoryStore = class {
|
|
82
|
+
sessions = /* @__PURE__ */ new Map();
|
|
83
|
+
async read(sessionId) {
|
|
84
|
+
return [...this.sessions.get(sessionId) ?? []];
|
|
85
|
+
}
|
|
86
|
+
async append(sessionId, message) {
|
|
87
|
+
const list = this.sessions.get(sessionId) ?? [];
|
|
88
|
+
list.push(message);
|
|
89
|
+
this.sessions.set(sessionId, list);
|
|
90
|
+
}
|
|
91
|
+
async clear(sessionId) {
|
|
92
|
+
this.sessions.delete(sessionId);
|
|
93
|
+
}
|
|
94
|
+
async listSessions() {
|
|
95
|
+
return [...this.sessions.keys()];
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
var FileStore = class {
|
|
99
|
+
rootDir;
|
|
100
|
+
unified;
|
|
101
|
+
writeLocks = /* @__PURE__ */ new Map();
|
|
102
|
+
constructor(options = {}) {
|
|
103
|
+
this.rootDir = options.path ?? "./.husk/memory";
|
|
104
|
+
this.unified = options.unified ?? false;
|
|
105
|
+
}
|
|
106
|
+
async read(sessionId) {
|
|
107
|
+
const file = this.fileFor(sessionId);
|
|
108
|
+
try {
|
|
109
|
+
const text = await promises.readFile(file, "utf-8");
|
|
110
|
+
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
111
|
+
const messages = [];
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(line);
|
|
115
|
+
if (this.unified && parsed.session && parsed.session !== sessionId) continue;
|
|
116
|
+
messages.push(parsed.message);
|
|
117
|
+
} catch {
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return messages;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (isNoEnt(err)) return [];
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async append(sessionId, message) {
|
|
127
|
+
const previous = this.writeLocks.get(sessionId) ?? Promise.resolve();
|
|
128
|
+
const next = previous.then(() => this.doAppend(sessionId, message));
|
|
129
|
+
this.writeLocks.set(
|
|
130
|
+
sessionId,
|
|
131
|
+
next.catch(() => void 0)
|
|
132
|
+
);
|
|
133
|
+
return next;
|
|
134
|
+
}
|
|
135
|
+
async doAppend(sessionId, message) {
|
|
136
|
+
await promises.mkdir(this.rootDir, { recursive: true });
|
|
137
|
+
const file = this.fileFor(sessionId);
|
|
138
|
+
const entry = this.unified ? JSON.stringify({ session: sessionId, message }) : JSON.stringify({ message });
|
|
139
|
+
await promises.appendFile(file, `${entry}
|
|
140
|
+
`, "utf-8");
|
|
141
|
+
}
|
|
142
|
+
async clear(sessionId) {
|
|
143
|
+
const file = this.fileFor(sessionId);
|
|
144
|
+
try {
|
|
145
|
+
await promises.unlink(file);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (!isNoEnt(err)) throw err;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async listSessions() {
|
|
151
|
+
if (this.unified) {
|
|
152
|
+
const file = join(this.rootDir, "unified.jsonl");
|
|
153
|
+
try {
|
|
154
|
+
const text = await promises.readFile(file, "utf-8");
|
|
155
|
+
const ids = /* @__PURE__ */ new Set();
|
|
156
|
+
for (const line of text.split("\n")) {
|
|
157
|
+
if (!line.trim()) continue;
|
|
158
|
+
try {
|
|
159
|
+
const parsed = JSON.parse(line);
|
|
160
|
+
if (parsed.session) ids.add(parsed.session);
|
|
161
|
+
} catch {
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return [...ids];
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (isNoEnt(err)) return [];
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const entries = await promises.readdir(this.rootDir);
|
|
172
|
+
return entries.filter((e) => e.endsWith(".jsonl")).map((e) => e.replace(/\.jsonl$/, ""));
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (isNoEnt(err)) return [];
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
fileFor(sessionId) {
|
|
179
|
+
if (this.unified) return join(this.rootDir, "unified.jsonl");
|
|
180
|
+
return join(this.rootDir, `${sanitize(sessionId)}.jsonl`);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
function isNoEnt(err) {
|
|
184
|
+
return Boolean(
|
|
185
|
+
err && typeof err === "object" && "code" in err && err.code === "ENOENT"
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
function sanitize(sessionId) {
|
|
189
|
+
return sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/core/steering.ts
|
|
193
|
+
function buildSystemPrompt(steering) {
|
|
194
|
+
const parts = [];
|
|
195
|
+
if (steering.systemPrompt && steering.systemPrompt.trim().length > 0) {
|
|
196
|
+
parts.push(steering.systemPrompt.trim());
|
|
197
|
+
}
|
|
198
|
+
if (steering.rules && steering.rules.length > 0) {
|
|
199
|
+
const numbered = steering.rules.map((rule, i) => `${i + 1}. ${rule}`).join("\n");
|
|
200
|
+
parts.push(`## Rules
|
|
201
|
+
${numbered}`);
|
|
202
|
+
}
|
|
203
|
+
return parts.length > 0 ? parts.join("\n\n") : void 0;
|
|
204
|
+
}
|
|
205
|
+
function buildExampleMessages(examples) {
|
|
206
|
+
const messages = [];
|
|
207
|
+
for (const ex of examples) {
|
|
208
|
+
messages.push({ role: "user", content: ex.user });
|
|
209
|
+
messages.push({ role: "assistant", content: ex.assistant });
|
|
210
|
+
}
|
|
211
|
+
return messages;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/core/agent.ts
|
|
215
|
+
var DEFAULTS = {
|
|
216
|
+
maxIterations: 25,
|
|
217
|
+
temperature: 0,
|
|
218
|
+
sessionId: "default"
|
|
219
|
+
};
|
|
220
|
+
var Agent = class {
|
|
221
|
+
events;
|
|
222
|
+
provider;
|
|
223
|
+
tools;
|
|
224
|
+
steering;
|
|
225
|
+
maxIterations;
|
|
226
|
+
temperature;
|
|
227
|
+
maxTokens;
|
|
228
|
+
signal;
|
|
229
|
+
sessionId;
|
|
230
|
+
memory;
|
|
231
|
+
logger;
|
|
232
|
+
constructor(config) {
|
|
233
|
+
this.events = new AgentEventEmitter();
|
|
234
|
+
this.provider = config.model;
|
|
235
|
+
this.tools = config.tools ?? [];
|
|
236
|
+
this.steering = config.steering;
|
|
237
|
+
this.maxIterations = config.maxIterations ?? DEFAULTS.maxIterations;
|
|
238
|
+
this.temperature = config.temperature ?? DEFAULTS.temperature;
|
|
239
|
+
this.maxTokens = config.maxTokens;
|
|
240
|
+
this.signal = config.signal;
|
|
241
|
+
this.sessionId = config.sessionId ?? DEFAULTS.sessionId;
|
|
242
|
+
this.memory = config.memory;
|
|
243
|
+
this.logger = new ConsoleLogger();
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Subscribe to a specific event type. Returns an unsubscribe fn.
|
|
247
|
+
*/
|
|
248
|
+
on = (type, handler) => this.events.on(type, handler);
|
|
249
|
+
/**
|
|
250
|
+
* Subscribe to all events. Returns an unsubscribe fn.
|
|
251
|
+
*/
|
|
252
|
+
onAny = (handler) => this.events.onAny(handler);
|
|
253
|
+
/**
|
|
254
|
+
* Run the agent loop to completion on the given input.
|
|
255
|
+
* Returns the final result with output text, full message history,
|
|
256
|
+
* token usage, and duration.
|
|
257
|
+
*/
|
|
258
|
+
async run(input) {
|
|
259
|
+
const start = Date.now();
|
|
260
|
+
this.signal?.throwIfAborted();
|
|
261
|
+
await this.events.emit({ type: "agent:start", input, sessionId: this.sessionId });
|
|
262
|
+
const messages = [];
|
|
263
|
+
if (this.steering?.examples) {
|
|
264
|
+
messages.push(...buildExampleMessages(this.steering.examples));
|
|
265
|
+
}
|
|
266
|
+
if (this.memory) {
|
|
267
|
+
const stored = await this.memory.read(this.sessionId);
|
|
268
|
+
messages.push(...stored);
|
|
269
|
+
}
|
|
270
|
+
const userMessage = { role: "user", content: input };
|
|
271
|
+
messages.push(userMessage);
|
|
272
|
+
await this.recordMessage(userMessage);
|
|
273
|
+
const system = this.steering ? buildSystemPrompt(this.steering) : void 0;
|
|
274
|
+
const tools = this.tools.length > 0 ? this.tools : void 0;
|
|
275
|
+
let totalInputTokens = 0;
|
|
276
|
+
let totalOutputTokens = 0;
|
|
277
|
+
let iterations = 0;
|
|
278
|
+
let finalOutput = "";
|
|
279
|
+
let hitMaxIterations = false;
|
|
280
|
+
while (iterations < this.maxIterations) {
|
|
281
|
+
this.signal?.throwIfAborted();
|
|
282
|
+
iterations += 1;
|
|
283
|
+
await this.events.emit({ type: "agent:iteration", iteration: iterations });
|
|
284
|
+
const request = {
|
|
285
|
+
model: this.provider.model,
|
|
286
|
+
messages,
|
|
287
|
+
...tools ? { tools } : {},
|
|
288
|
+
...system ? { system } : {},
|
|
289
|
+
temperature: this.temperature,
|
|
290
|
+
...this.maxTokens ? { maxTokens: this.maxTokens } : {}
|
|
291
|
+
};
|
|
292
|
+
await this.events.emit({ type: "provider:request", request });
|
|
293
|
+
const t0 = Date.now();
|
|
294
|
+
const response = await this.provider.chat(request);
|
|
295
|
+
const durationMs2 = Date.now() - t0;
|
|
296
|
+
await this.events.emit({ type: "provider:response", response, durationMs: durationMs2 });
|
|
297
|
+
totalInputTokens += response.usage.inputTokens;
|
|
298
|
+
totalOutputTokens += response.usage.outputTokens;
|
|
299
|
+
await this.recordMessage(response.message);
|
|
300
|
+
switch (response.stopReason) {
|
|
301
|
+
case "end_turn":
|
|
302
|
+
case "stop_sequence": {
|
|
303
|
+
finalOutput = extractText(response.message);
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
case "max_tokens": {
|
|
307
|
+
finalOutput = extractText(response.message);
|
|
308
|
+
this.logger.warn("Model hit max_tokens; output may be truncated", {
|
|
309
|
+
outputTokens: response.usage.outputTokens
|
|
310
|
+
});
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case "tool_use": {
|
|
314
|
+
const toolUses = extractToolUses(response.message);
|
|
315
|
+
if (toolUses.length === 0) {
|
|
316
|
+
finalOutput = extractText(response.message);
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
const results = await Promise.all(
|
|
320
|
+
toolUses.map(async (tu) => {
|
|
321
|
+
await this.events.emit({
|
|
322
|
+
type: "tool:call",
|
|
323
|
+
id: tu.id,
|
|
324
|
+
name: tu.name,
|
|
325
|
+
input: tu.input
|
|
326
|
+
});
|
|
327
|
+
const ts = Date.now();
|
|
328
|
+
const result2 = await this.executeTool(tu.name, tu.input);
|
|
329
|
+
const dur = Date.now() - ts;
|
|
330
|
+
await this.events.emit({
|
|
331
|
+
type: "tool:result",
|
|
332
|
+
id: tu.id,
|
|
333
|
+
name: tu.name,
|
|
334
|
+
result: result2,
|
|
335
|
+
durationMs: dur
|
|
336
|
+
});
|
|
337
|
+
return { tu, result: result2 };
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
const toolMessage = {
|
|
341
|
+
role: "user",
|
|
342
|
+
content: results.map(
|
|
343
|
+
({ tu, result: result2 }) => ({
|
|
344
|
+
type: "tool_result",
|
|
345
|
+
toolUseId: tu.id,
|
|
346
|
+
content: result2.output,
|
|
347
|
+
...result2.isError ? { isError: true } : {}
|
|
348
|
+
})
|
|
349
|
+
)
|
|
350
|
+
};
|
|
351
|
+
await this.recordMessage(toolMessage);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
case "error": {
|
|
355
|
+
throw new Error(`Provider returned error stop reason: ${extractText(response.message)}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (finalOutput !== "" || response.stopReason !== "tool_use") {
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (iterations >= this.maxIterations && finalOutput === "") {
|
|
363
|
+
hitMaxIterations = true;
|
|
364
|
+
this.logger.warn(`Agent hit max iterations (${this.maxIterations}) without end_turn`, {
|
|
365
|
+
sessionId: this.sessionId
|
|
366
|
+
});
|
|
367
|
+
const last = messages[messages.length - 1];
|
|
368
|
+
if (last) finalOutput = extractText(last);
|
|
369
|
+
}
|
|
370
|
+
const durationMs = Date.now() - start;
|
|
371
|
+
const result = {
|
|
372
|
+
output: finalOutput,
|
|
373
|
+
messages,
|
|
374
|
+
iterations,
|
|
375
|
+
usage: {
|
|
376
|
+
inputTokens: totalInputTokens,
|
|
377
|
+
outputTokens: totalOutputTokens
|
|
378
|
+
},
|
|
379
|
+
durationMs
|
|
380
|
+
};
|
|
381
|
+
await this.events.emit({
|
|
382
|
+
type: "agent:end",
|
|
383
|
+
output: finalOutput,
|
|
384
|
+
iterations,
|
|
385
|
+
durationMs
|
|
386
|
+
});
|
|
387
|
+
if (hitMaxIterations) {
|
|
388
|
+
this.logger.warn("Agent ended without clean termination", { hitMaxIterations: true });
|
|
389
|
+
}
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
// ── Internals ────────────────────────────────────────────────
|
|
393
|
+
async recordMessage(message) {
|
|
394
|
+
await this.events.emit({ type: "agent:message", message });
|
|
395
|
+
if (this.memory) {
|
|
396
|
+
await this.memory.append(this.sessionId, message);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async executeTool(name, input) {
|
|
400
|
+
const tool = this.tools.find((t) => t.name === name);
|
|
401
|
+
if (!tool) {
|
|
402
|
+
return {
|
|
403
|
+
output: `Error: tool '${name}' is not registered. Available tools: ${this.tools.map((t) => t.name).join(", ")}`,
|
|
404
|
+
isError: true
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
const validation = validateInput(input, tool.inputSchema);
|
|
408
|
+
if (!validation.valid) {
|
|
409
|
+
return {
|
|
410
|
+
output: `Error: invalid input for tool '${name}': ${validation.error}`,
|
|
411
|
+
isError: true
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
return await tool.execute(input, { signal: this.signal, logger: this.logger });
|
|
416
|
+
} catch (err) {
|
|
417
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
418
|
+
return { output: `Error executing tool '${name}': ${message}`, isError: true };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
function extractText(message) {
|
|
423
|
+
if (typeof message.content === "string") return message.content;
|
|
424
|
+
return message.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
425
|
+
}
|
|
426
|
+
function extractToolUses(message) {
|
|
427
|
+
if (typeof message.content === "string") return [];
|
|
428
|
+
return message.content.filter((b) => b.type === "tool_use");
|
|
429
|
+
}
|
|
430
|
+
function validateInput(input, schema) {
|
|
431
|
+
if (typeof input !== "object" || input === null) {
|
|
432
|
+
return { valid: false, error: "Input must be an object" };
|
|
433
|
+
}
|
|
434
|
+
if (schema.required) {
|
|
435
|
+
for (const key of schema.required) {
|
|
436
|
+
if (!(key in input)) {
|
|
437
|
+
return { valid: false, error: `Missing required field: ${key}` };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return { valid: true };
|
|
442
|
+
}
|
|
443
|
+
var AnthropicProvider = class {
|
|
444
|
+
name = "anthropic";
|
|
445
|
+
model;
|
|
446
|
+
client;
|
|
447
|
+
defaultMaxTokens;
|
|
448
|
+
constructor(options = {}) {
|
|
449
|
+
this.model = options.model ?? "claude-opus-4-6";
|
|
450
|
+
this.client = new Anthropic({
|
|
451
|
+
apiKey: options.apiKey ?? process.env.ANTHROPIC_API_KEY,
|
|
452
|
+
...options.baseURL ? { baseURL: options.baseURL } : {}
|
|
453
|
+
});
|
|
454
|
+
this.defaultMaxTokens = options.maxTokens ?? 8192;
|
|
455
|
+
}
|
|
456
|
+
async chat(request) {
|
|
457
|
+
const { system, messages } = splitSystemMessage(request.messages);
|
|
458
|
+
const anthropicTools = request.tools?.map(toHuskToolToAnthropic);
|
|
459
|
+
const response = await this.client.messages.create({
|
|
460
|
+
model: request.model || this.model,
|
|
461
|
+
...system ? { system } : {},
|
|
462
|
+
messages: messages.map(toAnthropicMessage),
|
|
463
|
+
...anthropicTools && anthropicTools.length > 0 ? { tools: anthropicTools } : {},
|
|
464
|
+
max_tokens: request.maxTokens ?? this.defaultMaxTokens,
|
|
465
|
+
...request.temperature !== void 0 ? { temperature: request.temperature } : {},
|
|
466
|
+
...request.stopSequences ? { stop_sequences: [...request.stopSequences] } : {}
|
|
467
|
+
});
|
|
468
|
+
return {
|
|
469
|
+
message: {
|
|
470
|
+
role: "assistant",
|
|
471
|
+
content: response.content.map(fromAnthropicBlock)
|
|
472
|
+
},
|
|
473
|
+
usage: {
|
|
474
|
+
inputTokens: response.usage.input_tokens,
|
|
475
|
+
outputTokens: response.usage.output_tokens
|
|
476
|
+
},
|
|
477
|
+
stopReason: mapStopReason(response.stop_reason),
|
|
478
|
+
model: response.model
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
function splitSystemMessage(messages) {
|
|
483
|
+
const systemParts = [];
|
|
484
|
+
const rest = [];
|
|
485
|
+
for (const m of messages) {
|
|
486
|
+
if (m.role === "system") {
|
|
487
|
+
const text = typeof m.content === "string" ? m.content : extractTextFromBlocks(m.content);
|
|
488
|
+
if (text) systemParts.push(text);
|
|
489
|
+
} else {
|
|
490
|
+
rest.push(m);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
system: systemParts.length > 0 ? systemParts.join("\n\n") : void 0,
|
|
495
|
+
messages: rest
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function extractTextFromBlocks(blocks) {
|
|
499
|
+
return blocks.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
500
|
+
}
|
|
501
|
+
function toHuskToolToAnthropic(tool) {
|
|
502
|
+
return {
|
|
503
|
+
name: tool.name,
|
|
504
|
+
description: tool.description,
|
|
505
|
+
input_schema: tool.inputSchema
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function toAnthropicMessage(message) {
|
|
509
|
+
if (message.role === "user") {
|
|
510
|
+
if (typeof message.content === "string") {
|
|
511
|
+
return { role: "user", content: message.content };
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
role: "user",
|
|
515
|
+
content: message.content.map((block) => {
|
|
516
|
+
if (block.type === "text") {
|
|
517
|
+
return { type: "text", text: block.text };
|
|
518
|
+
}
|
|
519
|
+
if (block.type === "tool_result") {
|
|
520
|
+
return {
|
|
521
|
+
type: "tool_result",
|
|
522
|
+
tool_use_id: block.toolUseId,
|
|
523
|
+
content: typeof block.content === "string" ? block.content : block.content.map((b) => {
|
|
524
|
+
if (b.type === "text") return { type: "text", text: b.text };
|
|
525
|
+
return { type: "text", text: JSON.stringify(b) };
|
|
526
|
+
}),
|
|
527
|
+
...block.isError ? { is_error: true } : {}
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
if (block.type === "tool_use") {
|
|
531
|
+
return {
|
|
532
|
+
type: "tool_use",
|
|
533
|
+
id: block.id,
|
|
534
|
+
name: block.name,
|
|
535
|
+
input: block.input
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
return { type: "text", text: "" };
|
|
539
|
+
})
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
if (message.role === "assistant") {
|
|
543
|
+
if (typeof message.content === "string") {
|
|
544
|
+
return { role: "assistant", content: message.content };
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
role: "assistant",
|
|
548
|
+
content: message.content.map((block) => {
|
|
549
|
+
if (block.type === "text") {
|
|
550
|
+
return { type: "text", text: block.text };
|
|
551
|
+
}
|
|
552
|
+
if (block.type === "tool_use") {
|
|
553
|
+
return {
|
|
554
|
+
type: "tool_use",
|
|
555
|
+
id: block.id,
|
|
556
|
+
name: block.name,
|
|
557
|
+
input: block.input
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
return { type: "text", text: "" };
|
|
561
|
+
})
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
return { role: "user", content: "" };
|
|
565
|
+
}
|
|
566
|
+
function fromAnthropicBlock(block) {
|
|
567
|
+
if (block.type === "text") {
|
|
568
|
+
return { type: "text", text: block.text };
|
|
569
|
+
}
|
|
570
|
+
if (block.type === "tool_use") {
|
|
571
|
+
return {
|
|
572
|
+
type: "tool_use",
|
|
573
|
+
id: block.id,
|
|
574
|
+
name: block.name,
|
|
575
|
+
input: block.input
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
return { type: "text", text: "" };
|
|
579
|
+
}
|
|
580
|
+
function mapStopReason(reason) {
|
|
581
|
+
switch (reason) {
|
|
582
|
+
case "end_turn":
|
|
583
|
+
return "end_turn";
|
|
584
|
+
case "tool_use":
|
|
585
|
+
return "tool_use";
|
|
586
|
+
case "max_tokens":
|
|
587
|
+
return "max_tokens";
|
|
588
|
+
case "stop_sequence":
|
|
589
|
+
return "stop_sequence";
|
|
590
|
+
default:
|
|
591
|
+
return "error";
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
var OpenAIProvider = class {
|
|
595
|
+
name = "openai";
|
|
596
|
+
model;
|
|
597
|
+
client;
|
|
598
|
+
constructor(options = {}) {
|
|
599
|
+
this.model = options.model ?? "gpt-5";
|
|
600
|
+
this.client = new OpenAI({
|
|
601
|
+
apiKey: options.apiKey ?? process.env.OPENAI_API_KEY,
|
|
602
|
+
...options.baseURL ? { baseURL: options.baseURL } : {},
|
|
603
|
+
...options.organization ? { organization: options.organization } : {}
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
async chat(request) {
|
|
607
|
+
const { system, messages } = splitSystemMessage2(request.messages);
|
|
608
|
+
const openaiTools = request.tools?.map(toOpenAITool);
|
|
609
|
+
const response = await this.client.chat.completions.create({
|
|
610
|
+
model: request.model || this.model,
|
|
611
|
+
...system ? { messages: [{ role: "system", content: system }] } : {},
|
|
612
|
+
messages: [
|
|
613
|
+
...system ? [{ role: "system", content: system }] : [],
|
|
614
|
+
...messages.flatMap((m) => toOpenAIMessages(m))
|
|
615
|
+
],
|
|
616
|
+
...openaiTools && openaiTools.length > 0 ? { tools: openaiTools } : {},
|
|
617
|
+
...request.temperature !== void 0 ? { temperature: request.temperature } : {},
|
|
618
|
+
...request.maxTokens ? { max_tokens: request.maxTokens } : {},
|
|
619
|
+
...request.stopSequences ? { stop: [...request.stopSequences] } : {}
|
|
620
|
+
});
|
|
621
|
+
const choice = response.choices[0];
|
|
622
|
+
if (!choice) {
|
|
623
|
+
throw new Error("OpenAI returned no choices");
|
|
624
|
+
}
|
|
625
|
+
const assistantMessage = choice.message;
|
|
626
|
+
return {
|
|
627
|
+
message: {
|
|
628
|
+
role: "assistant",
|
|
629
|
+
content: fromOpenAIAssistantMessage(assistantMessage)
|
|
630
|
+
},
|
|
631
|
+
usage: {
|
|
632
|
+
inputTokens: response.usage?.prompt_tokens ?? 0,
|
|
633
|
+
outputTokens: response.usage?.completion_tokens ?? 0
|
|
634
|
+
},
|
|
635
|
+
stopReason: mapStopReason2(choice.finish_reason),
|
|
636
|
+
model: response.model
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
function splitSystemMessage2(messages) {
|
|
641
|
+
const systemParts = [];
|
|
642
|
+
const rest = [];
|
|
643
|
+
for (const m of messages) {
|
|
644
|
+
if (m.role === "system") {
|
|
645
|
+
const text = typeof m.content === "string" ? m.content : extractTextFromBlocks2(m.content);
|
|
646
|
+
if (text) systemParts.push(text);
|
|
647
|
+
} else {
|
|
648
|
+
rest.push(m);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
system: systemParts.length > 0 ? systemParts.join("\n\n") : void 0,
|
|
653
|
+
messages: rest
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
function extractTextFromBlocks2(blocks) {
|
|
657
|
+
return blocks.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
658
|
+
}
|
|
659
|
+
function toOpenAITool(tool) {
|
|
660
|
+
return {
|
|
661
|
+
type: "function",
|
|
662
|
+
function: {
|
|
663
|
+
name: tool.name,
|
|
664
|
+
description: tool.description,
|
|
665
|
+
parameters: tool.inputSchema
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
function toOpenAIMessages(message) {
|
|
670
|
+
if (message.role === "system") {
|
|
671
|
+
const text = typeof message.content === "string" ? message.content : extractTextFromBlocks2(message.content);
|
|
672
|
+
return [{ role: "system", content: text }];
|
|
673
|
+
}
|
|
674
|
+
if (message.role === "user") {
|
|
675
|
+
if (typeof message.content === "string") {
|
|
676
|
+
return [{ role: "user", content: message.content }];
|
|
677
|
+
}
|
|
678
|
+
const toolResults = message.content.filter((b) => b.type === "tool_result");
|
|
679
|
+
const textBlocks = message.content.filter((b) => b.type === "text");
|
|
680
|
+
const out = [];
|
|
681
|
+
if (textBlocks.length > 0) {
|
|
682
|
+
out.push({
|
|
683
|
+
role: "user",
|
|
684
|
+
content: textBlocks.map((b) => b.text).join("\n")
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
for (const tr of toolResults) {
|
|
688
|
+
if (tr.type !== "tool_result") continue;
|
|
689
|
+
out.push({
|
|
690
|
+
role: "tool",
|
|
691
|
+
tool_call_id: tr.toolUseId,
|
|
692
|
+
content: typeof tr.content === "string" ? tr.content : tr.content.map((b) => b.type === "text" ? b.text : JSON.stringify(b)).join("\n")
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return out;
|
|
696
|
+
}
|
|
697
|
+
if (message.role === "assistant") {
|
|
698
|
+
if (typeof message.content === "string") {
|
|
699
|
+
return [{ role: "assistant", content: message.content }];
|
|
700
|
+
}
|
|
701
|
+
const textParts = [];
|
|
702
|
+
const toolCalls = [];
|
|
703
|
+
for (const block of message.content) {
|
|
704
|
+
if (block.type === "text") {
|
|
705
|
+
textParts.push(block.text);
|
|
706
|
+
} else if (block.type === "tool_use") {
|
|
707
|
+
toolCalls.push({
|
|
708
|
+
id: block.id,
|
|
709
|
+
type: "function",
|
|
710
|
+
function: {
|
|
711
|
+
name: block.name,
|
|
712
|
+
arguments: JSON.stringify(block.input ?? {})
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const out = {
|
|
718
|
+
role: "assistant",
|
|
719
|
+
content: textParts.length > 0 ? textParts.join("\n") : null
|
|
720
|
+
};
|
|
721
|
+
if (toolCalls.length > 0) {
|
|
722
|
+
out.tool_calls = toolCalls;
|
|
723
|
+
}
|
|
724
|
+
return [out];
|
|
725
|
+
}
|
|
726
|
+
if (message.role === "tool") {
|
|
727
|
+
const text = typeof message.content === "string" ? message.content : extractTextFromBlocks2(message.content);
|
|
728
|
+
return [
|
|
729
|
+
{
|
|
730
|
+
role: "tool",
|
|
731
|
+
tool_call_id: message.toolCallId ?? message.name ?? "unknown",
|
|
732
|
+
content: text
|
|
733
|
+
}
|
|
734
|
+
];
|
|
735
|
+
}
|
|
736
|
+
return [];
|
|
737
|
+
}
|
|
738
|
+
function fromOpenAIAssistantMessage(msg) {
|
|
739
|
+
const blocks = [];
|
|
740
|
+
if (msg.content) {
|
|
741
|
+
blocks.push({ type: "text", text: msg.content });
|
|
742
|
+
}
|
|
743
|
+
if (msg.tool_calls) {
|
|
744
|
+
for (const call of msg.tool_calls) {
|
|
745
|
+
if (call.type !== "function") continue;
|
|
746
|
+
let parsed = {};
|
|
747
|
+
try {
|
|
748
|
+
parsed = JSON.parse(call.function.arguments);
|
|
749
|
+
} catch {
|
|
750
|
+
parsed = { _parseError: call.function.arguments };
|
|
751
|
+
}
|
|
752
|
+
blocks.push({
|
|
753
|
+
type: "tool_use",
|
|
754
|
+
id: call.id,
|
|
755
|
+
name: call.function.name,
|
|
756
|
+
input: parsed
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return blocks;
|
|
761
|
+
}
|
|
762
|
+
function mapStopReason2(reason) {
|
|
763
|
+
switch (reason) {
|
|
764
|
+
case "stop":
|
|
765
|
+
return "end_turn";
|
|
766
|
+
case "tool_calls":
|
|
767
|
+
case "function_call":
|
|
768
|
+
return "tool_use";
|
|
769
|
+
case "length":
|
|
770
|
+
return "max_tokens";
|
|
771
|
+
case "content_filter":
|
|
772
|
+
return "stop_sequence";
|
|
773
|
+
default:
|
|
774
|
+
return "error";
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// src/tools/registry.ts
|
|
779
|
+
function defineTool(tool) {
|
|
780
|
+
return {
|
|
781
|
+
name: tool.name,
|
|
782
|
+
description: tool.description,
|
|
783
|
+
inputSchema: tool.inputSchema,
|
|
784
|
+
execute: async (input) => {
|
|
785
|
+
const result = await tool.execute(input);
|
|
786
|
+
return typeof result === "string" ? { output: result } : result;
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
function stringField(description, options) {
|
|
791
|
+
return { type: "string", description };
|
|
792
|
+
}
|
|
793
|
+
function integerField(description) {
|
|
794
|
+
return { type: "integer", description };
|
|
795
|
+
}
|
|
796
|
+
function booleanField(description) {
|
|
797
|
+
return { type: "boolean", description };
|
|
798
|
+
}
|
|
799
|
+
function objectSchema(properties, required) {
|
|
800
|
+
return {
|
|
801
|
+
type: "object",
|
|
802
|
+
properties,
|
|
803
|
+
...required ? { required } : {}
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
var Read = defineTool({
|
|
807
|
+
name: "Read",
|
|
808
|
+
description: "Read a file from the filesystem. Returns the file contents as text, with line numbers. Use offset and limit to page through large files.",
|
|
809
|
+
inputSchema: objectSchema(
|
|
810
|
+
{
|
|
811
|
+
path: stringField("Path to the file, relative to the working directory."),
|
|
812
|
+
offset: integerField("Line number to start reading from (1-indexed). Default: 1."),
|
|
813
|
+
limit: integerField("Maximum number of lines to read. Default: 2000.")
|
|
814
|
+
},
|
|
815
|
+
["path"]
|
|
816
|
+
),
|
|
817
|
+
execute: async (input) => {
|
|
818
|
+
const absolute = resolve(input.path);
|
|
819
|
+
const offset = input.offset ?? 1;
|
|
820
|
+
const limit = input.limit ?? 2e3;
|
|
821
|
+
let text;
|
|
822
|
+
try {
|
|
823
|
+
text = await promises.readFile(absolute, "utf-8");
|
|
824
|
+
} catch (err) {
|
|
825
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
826
|
+
return `Error reading file '${input.path}': ${message}`;
|
|
827
|
+
}
|
|
828
|
+
const lines = text.split("\n");
|
|
829
|
+
const start = Math.max(0, offset - 1);
|
|
830
|
+
const end = Math.min(lines.length, start + limit);
|
|
831
|
+
const slice = lines.slice(start, end);
|
|
832
|
+
const numbered = slice.map((line, i) => `${String(start + i + 1).padStart(6, " ")} ${line}`);
|
|
833
|
+
const header = lines.length > end ? `
|
|
834
|
+
... (${lines.length - end} more lines)
|
|
835
|
+
` : "";
|
|
836
|
+
return numbered.join("\n") + header;
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
var Write = defineTool({
|
|
840
|
+
name: "Write",
|
|
841
|
+
description: "Write content to a file. Creates parent directories as needed. Overwrites the file if it already exists. Use this for new files or full rewrites; use Edit for small changes.",
|
|
842
|
+
inputSchema: objectSchema(
|
|
843
|
+
{
|
|
844
|
+
path: stringField("Path to the file, relative to the working directory."),
|
|
845
|
+
content: stringField("Content to write to the file.")
|
|
846
|
+
},
|
|
847
|
+
["path", "content"]
|
|
848
|
+
),
|
|
849
|
+
execute: async (input) => {
|
|
850
|
+
const absolute = resolve(input.path);
|
|
851
|
+
try {
|
|
852
|
+
await promises.mkdir(dirname(absolute), { recursive: true });
|
|
853
|
+
await promises.writeFile(absolute, input.content, "utf-8");
|
|
854
|
+
const bytes = Buffer.byteLength(input.content, "utf-8");
|
|
855
|
+
return `Wrote ${bytes} bytes to ${absolute}`;
|
|
856
|
+
} catch (err) {
|
|
857
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
858
|
+
return `Error writing file '${input.path}': ${message}`;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
var Edit = defineTool({
|
|
863
|
+
name: "Edit",
|
|
864
|
+
description: "Replace a specific string in a file. The oldText must match exactly one location in the file (include enough surrounding context to make it unique). Use this for small, targeted changes; use Write for full file rewrites.",
|
|
865
|
+
inputSchema: objectSchema(
|
|
866
|
+
{
|
|
867
|
+
path: stringField("Path to the file, relative to the working directory."),
|
|
868
|
+
oldText: stringField("The exact text to replace. Must match exactly once in the file."),
|
|
869
|
+
newText: stringField("The text to replace it with.")
|
|
870
|
+
},
|
|
871
|
+
["path", "oldText", "newText"]
|
|
872
|
+
),
|
|
873
|
+
execute: async (input) => {
|
|
874
|
+
const absolute = resolve(input.path);
|
|
875
|
+
let original;
|
|
876
|
+
try {
|
|
877
|
+
original = await promises.readFile(absolute, "utf-8");
|
|
878
|
+
} catch (err) {
|
|
879
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
880
|
+
return `Error reading file '${input.path}': ${message}`;
|
|
881
|
+
}
|
|
882
|
+
let count = 0;
|
|
883
|
+
let idx = -1;
|
|
884
|
+
let searchFrom = 0;
|
|
885
|
+
while (true) {
|
|
886
|
+
const found = original.indexOf(input.oldText, searchFrom);
|
|
887
|
+
if (found === -1) break;
|
|
888
|
+
count += 1;
|
|
889
|
+
idx = found;
|
|
890
|
+
searchFrom = found + 1;
|
|
891
|
+
}
|
|
892
|
+
if (count === 0) {
|
|
893
|
+
return `Error: oldText not found in file '${input.path}'. The text must match exactly (including whitespace and indentation).`;
|
|
894
|
+
}
|
|
895
|
+
if (count > 1) {
|
|
896
|
+
return `Error: oldText matches ${count} locations in '${input.path}'. Include more surrounding context to make the match unique, or call Edit separately for each occurrence.`;
|
|
897
|
+
}
|
|
898
|
+
const updated = original.slice(0, idx) + input.newText + original.slice(idx + input.oldText.length);
|
|
899
|
+
try {
|
|
900
|
+
await promises.writeFile(absolute, updated, "utf-8");
|
|
901
|
+
return `Edited ${absolute} (replaced ${input.oldText.length} chars with ${input.newText.length} chars)`;
|
|
902
|
+
} catch (err) {
|
|
903
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
904
|
+
return `Error writing file '${input.path}': ${message}`;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
var execAsync = promisify(exec);
|
|
909
|
+
var DENY_PATTERNS = [
|
|
910
|
+
/\brm\s+(-[a-z]*f[a-z]*\s+)?-[a-z]*r[a-z]*\s+\/\s*$/i,
|
|
911
|
+
// rm -rf / (with optional -f variations)
|
|
912
|
+
/\brm\s+(-[a-z]*r[a-z]*\s+)?-[a-z]*f[a-z]*\s+\/\s*$/i,
|
|
913
|
+
// rm -rf / (reversed flags)
|
|
914
|
+
/\bdd\s+.*\bof=\/dev\/(sd|hd|nvme|vd)/i,
|
|
915
|
+
// dd to a raw block device
|
|
916
|
+
/\bmkfs(\.[a-z0-9]+)?\s+\/dev\/(sd|hd|nvme|vd)/i,
|
|
917
|
+
// mkfs on a raw block device
|
|
918
|
+
/:\(\)\s*\{.*:\s*\|.*&\s*\}\s*;\s*:/,
|
|
919
|
+
// classic bash fork bomb
|
|
920
|
+
/>\s*\/dev\/(sd|hd|nvme|vd)/i,
|
|
921
|
+
// redirect to a raw block device
|
|
922
|
+
/\bchmod\s+(-R\s+)?000\s+\//i,
|
|
923
|
+
// chmod 000 /
|
|
924
|
+
/\bchown\s+(-R\s+)?\S+\s+\/\s*$/i
|
|
925
|
+
// chown -R anything /
|
|
926
|
+
];
|
|
927
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
928
|
+
var MAX_TIMEOUT_MS = 6e5;
|
|
929
|
+
var Bash = defineTool({
|
|
930
|
+
name: "Bash",
|
|
931
|
+
description: "Execute a shell command. Returns stdout, stderr (if any), and the exit code. Use for running scripts, installing packages, git operations, and other shell tasks. Has a 60-second default timeout; pass timeout (in ms) for longer commands.",
|
|
932
|
+
inputSchema: objectSchema(
|
|
933
|
+
{
|
|
934
|
+
command: stringField("The shell command to execute."),
|
|
935
|
+
description: stringField("A short description of what this command does (for logging)."),
|
|
936
|
+
timeout: integerField("Timeout in milliseconds. Default: 60000. Max: 600000.")
|
|
937
|
+
},
|
|
938
|
+
["command"]
|
|
939
|
+
),
|
|
940
|
+
execute: async (input) => {
|
|
941
|
+
for (const pattern of DENY_PATTERNS) {
|
|
942
|
+
if (pattern.test(input.command)) {
|
|
943
|
+
return `Error: command blocked by safety policy. The command matches a known dangerous pattern: ${pattern}
|
|
944
|
+
|
|
945
|
+
If this is intentional, the user must run it manually.`;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
const timeout = Math.min(input.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
949
|
+
try {
|
|
950
|
+
const { stdout, stderr } = await execAsync(input.command, { timeout });
|
|
951
|
+
const out = stdout ? `STDOUT:
|
|
952
|
+
${stdout}` : "(no stdout)";
|
|
953
|
+
const err = stderr ? `
|
|
954
|
+
STDERR:
|
|
955
|
+
${stderr}` : "";
|
|
956
|
+
return `${out}${err}`.trim();
|
|
957
|
+
} catch (err) {
|
|
958
|
+
const e = err;
|
|
959
|
+
if (e.killed) {
|
|
960
|
+
return `Error: command timed out after ${timeout}ms. If you need longer, pass a higher timeout (max 600000ms).`;
|
|
961
|
+
}
|
|
962
|
+
const out = e.stdout ? `STDOUT:
|
|
963
|
+
${e.stdout}
|
|
964
|
+
` : "";
|
|
965
|
+
const errOut = e.stderr ? `STDERR:
|
|
966
|
+
${e.stderr}
|
|
967
|
+
` : "";
|
|
968
|
+
return `Error: command exited with code ${e.code ?? "unknown"}.
|
|
969
|
+
${out}${errOut}Message: ${e.message ?? "unknown"}`;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
var execAsync2 = promisify(exec);
|
|
974
|
+
var Grep = defineTool({
|
|
975
|
+
name: "Grep",
|
|
976
|
+
description: `Search files for a regex pattern. Returns matching lines in 'file:line:content' format. Uses ripgrep if available, falls back to grep. Default scope is the current directory, recursive, respecting .gitignore.`,
|
|
977
|
+
inputSchema: objectSchema(
|
|
978
|
+
{
|
|
979
|
+
pattern: stringField("Regex pattern to search for."),
|
|
980
|
+
path: stringField("File or directory to search in. Default: current directory."),
|
|
981
|
+
glob: stringField("File glob to filter by (e.g. '*.ts'). Default: all files."),
|
|
982
|
+
ignoreCase: booleanField("Case-insensitive search. Default: false."),
|
|
983
|
+
limit: integerField("Maximum number of matching lines to return. Default: 100.")
|
|
984
|
+
},
|
|
985
|
+
["pattern"]
|
|
986
|
+
),
|
|
987
|
+
execute: async (input) => {
|
|
988
|
+
const limit = input.limit ?? 100;
|
|
989
|
+
const target = input.path ?? ".";
|
|
990
|
+
try {
|
|
991
|
+
const args = [
|
|
992
|
+
"--line-number",
|
|
993
|
+
"--no-heading",
|
|
994
|
+
"--color=never",
|
|
995
|
+
...input.ignoreCase ? ["--ignore-case"] : [],
|
|
996
|
+
...input.glob ? [`--glob=${input.glob}`] : [],
|
|
997
|
+
"--",
|
|
998
|
+
input.pattern,
|
|
999
|
+
target
|
|
1000
|
+
];
|
|
1001
|
+
const { stdout } = await execAsync2(`rg ${args.map(shellQuote).join(" ")}`, {
|
|
1002
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1003
|
+
});
|
|
1004
|
+
return truncateOutput(stdout, limit);
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
const e = err;
|
|
1007
|
+
if (e.code === 1 && !e.stdout) {
|
|
1008
|
+
return "No matches found.";
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
try {
|
|
1012
|
+
const args = [
|
|
1013
|
+
"-rn",
|
|
1014
|
+
"--color=never",
|
|
1015
|
+
...input.ignoreCase ? ["-i"] : [],
|
|
1016
|
+
...input.glob ? [`--include=${input.glob}`] : [],
|
|
1017
|
+
"-E",
|
|
1018
|
+
"--",
|
|
1019
|
+
input.pattern,
|
|
1020
|
+
target
|
|
1021
|
+
];
|
|
1022
|
+
const { stdout } = await execAsync2(`grep ${args.map(shellQuote).join(" ")}`, {
|
|
1023
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1024
|
+
});
|
|
1025
|
+
return truncateOutput(stdout, limit);
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
const e = err;
|
|
1028
|
+
if (e.code === 1 && !e.stdout) {
|
|
1029
|
+
return "No matches found.";
|
|
1030
|
+
}
|
|
1031
|
+
return `Error running grep: ${e.message ?? "unknown"}`;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
function shellQuote(s) {
|
|
1036
|
+
if (/^[a-zA-Z0-9_./:=@%+-]+$/.test(s)) return s;
|
|
1037
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1038
|
+
}
|
|
1039
|
+
function truncateOutput(output, limit) {
|
|
1040
|
+
const lines = output.split("\n");
|
|
1041
|
+
if (lines.length <= limit) return output || "No matches found.";
|
|
1042
|
+
return `${lines.slice(0, limit).join("\n")}
|
|
1043
|
+
... (${lines.length - limit} more matches truncated)`;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// src/cli/index.ts
|
|
1047
|
+
var TOOL_REGISTRY = { read: Read, write: Write, edit: Edit, bash: Bash, grep: Grep };
|
|
1048
|
+
async function main() {
|
|
1049
|
+
const subcommand = process.argv[2];
|
|
1050
|
+
if (subcommand === "--help" || subcommand === "-h" || subcommand === void 0) {
|
|
1051
|
+
printHelp();
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (subcommand === "run") {
|
|
1055
|
+
await runCommand();
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (subcommand === "version" || subcommand === "--version" || subcommand === "-v") {
|
|
1059
|
+
console.log(`husk ${VERSION}`);
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
console.error(`Unknown command: ${subcommand}
|
|
1063
|
+
Run 'husk --help' for usage.`);
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
}
|
|
1066
|
+
async function runCommand() {
|
|
1067
|
+
const { values } = parseArgs({
|
|
1068
|
+
args: process.argv.slice(3),
|
|
1069
|
+
options: {
|
|
1070
|
+
model: { type: "string" },
|
|
1071
|
+
provider: { type: "string" },
|
|
1072
|
+
tools: { type: "string" },
|
|
1073
|
+
memory: { type: "string" },
|
|
1074
|
+
max: { type: "string" },
|
|
1075
|
+
help: { type: "boolean", short: "h" }
|
|
1076
|
+
},
|
|
1077
|
+
allowPositionals: true
|
|
1078
|
+
});
|
|
1079
|
+
if (values.help) {
|
|
1080
|
+
printHelp();
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const prompt = values.help === void 0 ? process.argv[3] : void 0;
|
|
1084
|
+
if (!prompt) {
|
|
1085
|
+
console.error("Error: husk run requires a prompt argument.");
|
|
1086
|
+
console.error('Usage: husk run "your prompt here"');
|
|
1087
|
+
process.exit(1);
|
|
1088
|
+
}
|
|
1089
|
+
const providerName = values.provider ?? process.env.HUSK_PROVIDER ?? "anthropic";
|
|
1090
|
+
const modelId = values.model ?? process.env.HUSK_MODEL ?? "claude-opus-4-6";
|
|
1091
|
+
const provider = providerName === "openai" ? new OpenAIProvider({ model: modelId }) : new AnthropicProvider({ model: modelId });
|
|
1092
|
+
const toolNames = (values.tools ?? "read,write,edit,bash,grep").split(",").map((s) => s.trim()).filter(Boolean);
|
|
1093
|
+
const tools = toolNames.map((name) => {
|
|
1094
|
+
const t = TOOL_REGISTRY[name];
|
|
1095
|
+
if (!t) {
|
|
1096
|
+
console.error(
|
|
1097
|
+
`Error: unknown tool '${name}'. Available: ${Object.keys(TOOL_REGISTRY).join(", ")}`
|
|
1098
|
+
);
|
|
1099
|
+
process.exit(1);
|
|
1100
|
+
}
|
|
1101
|
+
return t;
|
|
1102
|
+
});
|
|
1103
|
+
const memory = values.memory === "file" ? new FileStore() : new InMemoryStore();
|
|
1104
|
+
const maxIterations = values.max ? Number.parseInt(values.max, 10) : 25;
|
|
1105
|
+
const agent = new Agent({
|
|
1106
|
+
model: provider,
|
|
1107
|
+
...tools.length > 0 ? { tools } : {},
|
|
1108
|
+
memory,
|
|
1109
|
+
maxIterations
|
|
1110
|
+
});
|
|
1111
|
+
const result = await agent.run(prompt);
|
|
1112
|
+
console.log(result.output);
|
|
1113
|
+
process.exit(0);
|
|
1114
|
+
}
|
|
1115
|
+
function printHelp() {
|
|
1116
|
+
console.log(`husk \u2014 run an agent from the command line
|
|
1117
|
+
|
|
1118
|
+
Usage:
|
|
1119
|
+
husk run "<prompt>" [options]
|
|
1120
|
+
|
|
1121
|
+
Options:
|
|
1122
|
+
--model <id> Model id (default: claude-opus-4-6)
|
|
1123
|
+
--provider <name> 'anthropic' (default) or 'openai'
|
|
1124
|
+
--tools <list> Comma-separated tool names: read,write,edit,bash,grep
|
|
1125
|
+
(default: all five)
|
|
1126
|
+
--memory <kind> 'in-memory' (default) or 'file'
|
|
1127
|
+
--max <n> Max agent iterations (default: 25)
|
|
1128
|
+
-h, --help Show this help
|
|
1129
|
+
-v, --version Show version
|
|
1130
|
+
|
|
1131
|
+
Environment:
|
|
1132
|
+
ANTHROPIC_API_KEY Required for Anthropic provider
|
|
1133
|
+
OPENAI_API_KEY Required for OpenAI provider
|
|
1134
|
+
HUSK_MODEL Override default model
|
|
1135
|
+
HUSK_PROVIDER Override default provider
|
|
1136
|
+
|
|
1137
|
+
Examples:
|
|
1138
|
+
husk run "What is the capital of France?"
|
|
1139
|
+
husk run "Refactor src/foo.ts" --tools read,edit,write
|
|
1140
|
+
husk run "Summarize README.md" --provider openai --model gpt-5
|
|
1141
|
+
`);
|
|
1142
|
+
}
|
|
1143
|
+
var VERSION = "0.0.1";
|
|
1144
|
+
await main();
|
|
1145
|
+
//# sourceMappingURL=index.js.map
|
|
1146
|
+
//# sourceMappingURL=index.js.map
|