hoomanjs 1.2.0 → 1.4.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/README.md +17 -1
- package/package.json +2 -1
- package/src/cli.ts +11 -1
- package/src/core/config.ts +1 -0
- package/src/core/models/index.ts +1 -0
- package/src/core/models/xai.ts +47 -0
- package/src/core/tools/filesystem.ts +109 -17
- package/src/core/utils/file-formats.ts +60 -0
- package/src/daemon/index.ts +6 -2
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ It gives you:
|
|
|
26
26
|
|
|
27
27
|
## Features
|
|
28
28
|
|
|
29
|
-
- Multiple LLM providers: `ollama`, `openai`, `anthropic`, `google`, `bedrock`
|
|
29
|
+
- Multiple LLM providers: `ollama`, `openai`, `anthropic`, `google`, `bedrock`, `xai`
|
|
30
30
|
- Local configuration under `~/.hooman`
|
|
31
31
|
- MCP server support via `stdio`, `streamable-http`, and `sse`
|
|
32
32
|
- MCP server `instructions` support: server-provided instructions are appended to the agent system prompt
|
|
@@ -262,6 +262,7 @@ Supported `llm.provider` values:
|
|
|
262
262
|
- `anthropic`
|
|
263
263
|
- `google`
|
|
264
264
|
- `bedrock`
|
|
265
|
+
- `xai`
|
|
265
266
|
|
|
266
267
|
## Provider Notes
|
|
267
268
|
|
|
@@ -328,6 +329,21 @@ Uses Strands `GoogleModel` on top of `@google/genai`. Top-level options like `ap
|
|
|
328
329
|
|
|
329
330
|
Supports `region`, `clientConfig`, and optional `apiKey`, with all other values forwarded as Bedrock model options.
|
|
330
331
|
|
|
332
|
+
### xAI
|
|
333
|
+
|
|
334
|
+
Uses the Vercel AI SDK xAI provider (`@ai-sdk/xai`) on top of Strands `VercelModel`. Provider-specific settings `apiKey`, `baseURL`, and `headers` are picked up; other values are forwarded into the model config (`temperature`, `maxTokens`, etc.). Defaults to `XAI_API_KEY` from the environment when no `apiKey` is supplied.
|
|
335
|
+
|
|
336
|
+
```json
|
|
337
|
+
{
|
|
338
|
+
"provider": "xai",
|
|
339
|
+
"model": "grok-4.20-non-reasoning",
|
|
340
|
+
"params": {
|
|
341
|
+
"apiKey": "...",
|
|
342
|
+
"temperature": 0.7
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
331
347
|
## MCP Configuration
|
|
332
348
|
|
|
333
349
|
`mcp.json` is stored as:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hoomanjs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Bun-powered local AI agent CLI with chat, exec, ACP, MCP, and skills support.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Vaibhav Pandey",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.18.2",
|
|
53
53
|
"@ai-sdk/anthropic": "^3.0.69",
|
|
54
|
+
"@ai-sdk/xai": "^3.0.83",
|
|
54
55
|
"@aws-sdk/client-bedrock-runtime": "^3.1028.0",
|
|
55
56
|
"@google/genai": "^1.40.0",
|
|
56
57
|
"@huggingface/transformers": "^4.0.1",
|
package/src/cli.ts
CHANGED
|
@@ -134,12 +134,17 @@ program
|
|
|
134
134
|
"MCP notification channel to subscribe to (repeatable).",
|
|
135
135
|
(value: string, previous?: string[]) => [...(previous ?? []), value],
|
|
136
136
|
)
|
|
137
|
+
.option(
|
|
138
|
+
"--debug",
|
|
139
|
+
"Log each MCP channel notification payload to the console.",
|
|
140
|
+
)
|
|
137
141
|
.addOption(createToolkitOption())
|
|
138
142
|
.action(
|
|
139
143
|
async (options: {
|
|
140
144
|
session?: string;
|
|
141
145
|
toolkit?: Toolkit;
|
|
142
146
|
channel?: string[];
|
|
147
|
+
debug?: boolean;
|
|
143
148
|
}) => {
|
|
144
149
|
const sessionId = options.session?.trim() || crypto.randomUUID();
|
|
145
150
|
const channels = options.channel ?? [];
|
|
@@ -151,7 +156,12 @@ program
|
|
|
151
156
|
true,
|
|
152
157
|
);
|
|
153
158
|
try {
|
|
154
|
-
await daemon({
|
|
159
|
+
await daemon({
|
|
160
|
+
agent,
|
|
161
|
+
manager,
|
|
162
|
+
channels,
|
|
163
|
+
debug: Boolean(options.debug),
|
|
164
|
+
});
|
|
155
165
|
} finally {
|
|
156
166
|
try {
|
|
157
167
|
await manager.disconnect();
|
package/src/core/config.ts
CHANGED
package/src/core/models/index.ts
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createXai, xai } from "@ai-sdk/xai";
|
|
2
|
+
import { VercelModel } from "@strands-agents/sdk/models/vercel";
|
|
3
|
+
import type { XaiProviderSettings } from "@ai-sdk/xai";
|
|
4
|
+
import type { VercelModelConfig } from "@strands-agents/sdk/models/vercel";
|
|
5
|
+
import { omit, pick } from "lodash";
|
|
6
|
+
|
|
7
|
+
const PROVIDER_SETTINGS_KEYS = ["apiKey", "baseURL", "headers"] as const;
|
|
8
|
+
|
|
9
|
+
function pickProviderSettings(
|
|
10
|
+
params: Record<string, unknown>,
|
|
11
|
+
): XaiProviderSettings {
|
|
12
|
+
const picked = pick(params, [...PROVIDER_SETTINGS_KEYS]) as Record<
|
|
13
|
+
string,
|
|
14
|
+
unknown
|
|
15
|
+
>;
|
|
16
|
+
const unset = Object.keys(picked).filter((k) => picked[k] === undefined);
|
|
17
|
+
return omit(picked, unset) as XaiProviderSettings;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pickVercelModelConfig(
|
|
21
|
+
params: Record<string, unknown>,
|
|
22
|
+
): Partial<VercelModelConfig> {
|
|
23
|
+
return omit(params, [
|
|
24
|
+
...PROVIDER_SETTINGS_KEYS,
|
|
25
|
+
]) as Partial<VercelModelConfig>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* xAI (Grok) via AI SDK + Strands {@link VercelModel}.
|
|
30
|
+
*
|
|
31
|
+
* - **`config.llm.model`**: model id passed to `xai(...)` (e.g. `grok-4.20-non-reasoning`).
|
|
32
|
+
* - **`params`**: {@link XaiProviderSettings} (`apiKey`, `baseURL`, `headers`).
|
|
33
|
+
* If none are set, the default provider is used (`XAI_API_KEY` from env).
|
|
34
|
+
* - Any other `params` keys are forwarded as {@link VercelModelConfig} (e.g. `temperature`, `maxTokens`).
|
|
35
|
+
*/
|
|
36
|
+
export function create(
|
|
37
|
+
model: string,
|
|
38
|
+
params: Record<string, unknown> = {},
|
|
39
|
+
): VercelModel {
|
|
40
|
+
const settings = pickProviderSettings(params);
|
|
41
|
+
const provider = Object.keys(settings).length > 0 ? createXai(settings) : xai;
|
|
42
|
+
const config = pickVercelModelConfig(params);
|
|
43
|
+
return new VercelModel({
|
|
44
|
+
provider: provider(model),
|
|
45
|
+
...config,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -2,9 +2,20 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import os from "node:os";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DocumentBlock,
|
|
7
|
+
ImageBlock,
|
|
8
|
+
TextBlock,
|
|
9
|
+
VideoBlock,
|
|
10
|
+
tool,
|
|
11
|
+
type JSONValue,
|
|
12
|
+
} from "@strands-agents/sdk";
|
|
6
13
|
import { getCwd } from "../utils/cwd-context.ts";
|
|
7
|
-
import
|
|
14
|
+
import {
|
|
15
|
+
detectDocumentFormat,
|
|
16
|
+
detectImageFormat,
|
|
17
|
+
detectVideoFormat,
|
|
18
|
+
} from "../utils/file-formats.ts";
|
|
8
19
|
import { z } from "zod";
|
|
9
20
|
|
|
10
21
|
const DEFAULT_READ_LIMIT = 250;
|
|
@@ -190,7 +201,7 @@ async function readTextFile(
|
|
|
190
201
|
const buffer = await fs.readFile(filePath);
|
|
191
202
|
if (isProbablyBinary(buffer)) {
|
|
192
203
|
throw new Error(
|
|
193
|
-
"File appears to be binary.
|
|
204
|
+
"File appears to be binary. Call read_file again with `binary: true` — images (png/jpeg/gif/webp), videos (mp4/mov/mkv/webm/etc.), and documents (pdf/docx/csv/etc.) are returned as multimodal content blocks the provider can forward to the model; unknown binary types come back as base64.",
|
|
194
205
|
);
|
|
195
206
|
}
|
|
196
207
|
|
|
@@ -211,14 +222,87 @@ async function readTextFile(
|
|
|
211
222
|
};
|
|
212
223
|
}
|
|
213
224
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
225
|
+
type BinaryReadResult =
|
|
226
|
+
| Array<TextBlock | ImageBlock | VideoBlock | DocumentBlock>
|
|
227
|
+
| {
|
|
228
|
+
path: string;
|
|
229
|
+
encoding: "base64";
|
|
230
|
+
content: string;
|
|
231
|
+
sizeBytes: number;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
async function readBinaryFile(
|
|
235
|
+
filePath: string,
|
|
236
|
+
options?: { maxBytes?: number },
|
|
237
|
+
): Promise<BinaryReadResult> {
|
|
220
238
|
await ensureFile(filePath);
|
|
239
|
+
const stat = await fs.stat(filePath);
|
|
240
|
+
|
|
241
|
+
if (stat.size > (options?.maxBytes ?? DEFAULT_MAX_READ_BYTES)) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`File too large to read safely (${stat.size} bytes). Use get_file_info for metadata or process the file with another tool.`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
221
247
|
const buffer = await fs.readFile(filePath);
|
|
248
|
+
// ImageBlock / DocumentBlock expect Uint8Array; construct a zero-copy view.
|
|
249
|
+
const bytes = new Uint8Array(
|
|
250
|
+
buffer.buffer,
|
|
251
|
+
buffer.byteOffset,
|
|
252
|
+
buffer.byteLength,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const imageFormat = detectImageFormat(filePath);
|
|
256
|
+
if (imageFormat) {
|
|
257
|
+
const metadata = new TextBlock(
|
|
258
|
+
JSON.stringify({
|
|
259
|
+
path: filePath,
|
|
260
|
+
kind: "image",
|
|
261
|
+
format: imageFormat,
|
|
262
|
+
size_bytes: stat.size,
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
const image = new ImageBlock({
|
|
266
|
+
format: imageFormat,
|
|
267
|
+
source: { bytes },
|
|
268
|
+
});
|
|
269
|
+
return [metadata, image];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const videoFormat = detectVideoFormat(filePath);
|
|
273
|
+
if (videoFormat) {
|
|
274
|
+
const metadata = new TextBlock(
|
|
275
|
+
JSON.stringify({
|
|
276
|
+
path: filePath,
|
|
277
|
+
kind: "video",
|
|
278
|
+
format: videoFormat,
|
|
279
|
+
size_bytes: stat.size,
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
const video = new VideoBlock({
|
|
283
|
+
format: videoFormat,
|
|
284
|
+
source: { bytes },
|
|
285
|
+
});
|
|
286
|
+
return [metadata, video];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const documentFormat = detectDocumentFormat(filePath);
|
|
290
|
+
if (documentFormat) {
|
|
291
|
+
const metadata = new TextBlock(
|
|
292
|
+
JSON.stringify({
|
|
293
|
+
path: filePath,
|
|
294
|
+
kind: "document",
|
|
295
|
+
format: documentFormat,
|
|
296
|
+
size_bytes: stat.size,
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
const document = new DocumentBlock({
|
|
300
|
+
name: path.basename(filePath),
|
|
301
|
+
format: documentFormat,
|
|
302
|
+
source: { bytes },
|
|
303
|
+
});
|
|
304
|
+
return [metadata, document];
|
|
305
|
+
}
|
|
222
306
|
|
|
223
307
|
return {
|
|
224
308
|
path: filePath,
|
|
@@ -437,7 +521,9 @@ function createFilesystemSchema() {
|
|
|
437
521
|
binary: z
|
|
438
522
|
.boolean()
|
|
439
523
|
.optional()
|
|
440
|
-
.describe(
|
|
524
|
+
.describe(
|
|
525
|
+
"Read as binary. Images, videos, and documents are returned as multimodal content blocks (forwarded to the active provider's native media format where supported); other binary files come back as base64.",
|
|
526
|
+
),
|
|
441
527
|
}),
|
|
442
528
|
readMultipleFiles: z.object({
|
|
443
529
|
paths: z.array(z.string()).min(1).describe("List of file paths to read."),
|
|
@@ -519,17 +605,23 @@ export function createFilesystemTools() {
|
|
|
519
605
|
tool({
|
|
520
606
|
name: "read_file",
|
|
521
607
|
description:
|
|
522
|
-
"Read a text
|
|
608
|
+
"Read a file. Defaults to UTF-8 text with optional line offset/limit. Pass `binary: true` for non-text files: images (jpeg/png/gif/webp), videos (mp4/mov/mkv/webm/etc.), and documents (pdf/docx/csv/etc.) are returned as multimodal content blocks — the active model provider forwards them natively where supported (Bedrock for all; Anthropic for images + docs; Google for images + docs; OpenAI for images; Ollama for images) and logs a warning for unsupported kinds. Any other binary file is returned as base64.",
|
|
523
609
|
inputSchema: schema.readFile,
|
|
524
610
|
callback: async (input) => {
|
|
525
611
|
const filePath = normalizeUserPath(input.path);
|
|
526
|
-
const result = input.binary
|
|
527
|
-
? await readBinaryFile(filePath)
|
|
528
|
-
: await readTextFile(filePath, {
|
|
529
|
-
offset: input.offset,
|
|
530
|
-
limit: input.limit,
|
|
531
|
-
});
|
|
532
612
|
|
|
613
|
+
if (input.binary) {
|
|
614
|
+
// Binary reads can return SDK media blocks (ImageBlock / DocumentBlock)
|
|
615
|
+
// or a plain base64 JSON object. Both are accepted by FunctionTool's
|
|
616
|
+
// result wrapping, but the callback signature is JSONValue, so cast.
|
|
617
|
+
const result = await readBinaryFile(filePath);
|
|
618
|
+
return result as unknown as JSONValue;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const result = await readTextFile(filePath, {
|
|
622
|
+
offset: input.offset,
|
|
623
|
+
limit: input.limit,
|
|
624
|
+
});
|
|
533
625
|
return toJsonValue(result);
|
|
534
626
|
},
|
|
535
627
|
}),
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type {
|
|
3
|
+
DocumentFormat,
|
|
4
|
+
ImageFormat,
|
|
5
|
+
VideoFormat,
|
|
6
|
+
} from "@strands-agents/sdk";
|
|
7
|
+
|
|
8
|
+
// Extension → SDK media format. Values must match the unions the Strands SDK
|
|
9
|
+
// exposes so ImageBlock / VideoBlock / DocumentBlock construct cleanly. Each
|
|
10
|
+
// provider adapter (OpenAI, Anthropic, Bedrock, Google, Ollama) converts these
|
|
11
|
+
// into its native shape or gracefully drops unsupported ones with a warning —
|
|
12
|
+
// the paired TextBlock metadata still reaches the model either way.
|
|
13
|
+
const IMAGE_EXT_FORMATS: Record<string, ImageFormat> = {
|
|
14
|
+
".png": "png",
|
|
15
|
+
".jpg": "jpeg",
|
|
16
|
+
".jpeg": "jpeg",
|
|
17
|
+
".gif": "gif",
|
|
18
|
+
".webp": "webp",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const VIDEO_EXT_FORMATS: Record<string, VideoFormat> = {
|
|
22
|
+
".mp4": "mp4",
|
|
23
|
+
".mov": "mov",
|
|
24
|
+
".mkv": "mkv",
|
|
25
|
+
".webm": "webm",
|
|
26
|
+
".flv": "flv",
|
|
27
|
+
".mpeg": "mpeg",
|
|
28
|
+
".mpg": "mpg",
|
|
29
|
+
".wmv": "wmv",
|
|
30
|
+
".3gp": "3gp",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const DOCUMENT_EXT_FORMATS: Record<string, DocumentFormat> = {
|
|
34
|
+
".pdf": "pdf",
|
|
35
|
+
".csv": "csv",
|
|
36
|
+
".doc": "doc",
|
|
37
|
+
".docx": "docx",
|
|
38
|
+
".xls": "xls",
|
|
39
|
+
".xlsx": "xlsx",
|
|
40
|
+
".html": "html",
|
|
41
|
+
".htm": "html",
|
|
42
|
+
".txt": "txt",
|
|
43
|
+
".md": "md",
|
|
44
|
+
".json": "json",
|
|
45
|
+
".xml": "xml",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function detectImageFormat(filePath: string): ImageFormat | undefined {
|
|
49
|
+
return IMAGE_EXT_FORMATS[path.extname(filePath).toLowerCase()];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function detectVideoFormat(filePath: string): VideoFormat | undefined {
|
|
53
|
+
return VIDEO_EXT_FORMATS[path.extname(filePath).toLowerCase()];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function detectDocumentFormat(
|
|
57
|
+
filePath: string,
|
|
58
|
+
): DocumentFormat | undefined {
|
|
59
|
+
return DOCUMENT_EXT_FORMATS[path.extname(filePath).toLowerCase()];
|
|
60
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ type RunDaemonOptions = {
|
|
|
10
10
|
agent: Agent;
|
|
11
11
|
manager: McpManager;
|
|
12
12
|
channels: string[];
|
|
13
|
+
debug?: boolean;
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
function debug(text: string): void {
|
|
@@ -39,13 +40,16 @@ export async function main(options: RunDaemonOptions): Promise<void> {
|
|
|
39
40
|
);
|
|
40
41
|
|
|
41
42
|
const [queue, stop] = await createQueue(async (message: ChannelMessage) => {
|
|
42
|
-
debug(`
|
|
43
|
+
debug(`processing → ${message.meta.server}:${message.meta.channel}`);
|
|
44
|
+
if (options.debug) {
|
|
45
|
+
debug(`raw → ${JSON.stringify(message.meta)}`);
|
|
46
|
+
}
|
|
43
47
|
try {
|
|
44
48
|
await options.agent.invoke(message.prompt);
|
|
45
49
|
} catch (error) {
|
|
46
50
|
const text = error instanceof Error ? error.message : String(error);
|
|
47
51
|
debug(
|
|
48
|
-
`turn failed
|
|
52
|
+
`turn failed → ${message.meta.server}:${message.meta.channel}: ${text}`,
|
|
49
53
|
);
|
|
50
54
|
}
|
|
51
55
|
}, unsubscribe);
|