@hmharness/kernel 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/dist/config.d.ts +34 -0
- package/dist/config.js +130 -0
- package/dist/context.d.ts +11 -0
- package/dist/context.js +32 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/loop-types.d.ts +31 -0
- package/dist/loop-types.js +1 -0
- package/dist/loop.d.ts +39 -0
- package/dist/loop.js +98 -0
- package/dist/mcp.d.ts +55 -0
- package/dist/mcp.js +282 -0
- package/dist/provider.d.ts +32 -0
- package/dist/provider.js +237 -0
- package/dist/registry.d.ts +24 -0
- package/dist/registry.js +31 -0
- package/dist/session.d.ts +61 -0
- package/dist/session.js +111 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.js +131 -0
- package/package.json +28 -0
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/kernel - mcp
|
|
3
|
+
* A zero-dependency MCP (Model Context Protocol) client: JSON-RPC 2.0 over
|
|
4
|
+
* stdio (spawned server process) or Streamable HTTP (POST + SSE response).
|
|
5
|
+
* Remote tools are projected onto the same Tool shape as native ones, so the
|
|
6
|
+
* registry and loop never know where a capability came from. Borrowing the
|
|
7
|
+
* 5800+-server ecosystem instead of rebuilding it is the whole point.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
11
|
+
const CLIENT_INFO = { name: 'hmharness', version: '0.1.0' };
|
|
12
|
+
/** Call-local incremental id; the wire only needs uniqueness per session. */
|
|
13
|
+
function nextId() {
|
|
14
|
+
nextId.n = (nextId.n ?? 0) + 1;
|
|
15
|
+
return nextId.n;
|
|
16
|
+
}
|
|
17
|
+
(function (nextId) {
|
|
18
|
+
})(nextId || (nextId = {}));
|
|
19
|
+
export class McpClient {
|
|
20
|
+
serverName;
|
|
21
|
+
config;
|
|
22
|
+
proc = null;
|
|
23
|
+
sessionId = null;
|
|
24
|
+
buffer = '';
|
|
25
|
+
stderrTail = '';
|
|
26
|
+
pending = new Map();
|
|
27
|
+
ready = false;
|
|
28
|
+
constructor(serverName, config) {
|
|
29
|
+
this.serverName = serverName;
|
|
30
|
+
this.config = config;
|
|
31
|
+
}
|
|
32
|
+
/** initialize handshake. Must be called exactly once before use. */
|
|
33
|
+
async connect(timeoutMs = 15_000) {
|
|
34
|
+
if (this.config.type === 'stdio')
|
|
35
|
+
this.spawnStdio();
|
|
36
|
+
await this.request('initialize', {
|
|
37
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
38
|
+
capabilities: {},
|
|
39
|
+
clientInfo: CLIENT_INFO,
|
|
40
|
+
}, timeoutMs).then((r) => {
|
|
41
|
+
if (r.error)
|
|
42
|
+
throw new Error(`mcp/${this.serverName}: initialize failed: ${r.error.message}`);
|
|
43
|
+
});
|
|
44
|
+
// initialized is a notification (no id, no response expected)
|
|
45
|
+
if (this.config.type === 'stdio') {
|
|
46
|
+
this.proc?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
await this.notify('notifications/initialized');
|
|
50
|
+
}
|
|
51
|
+
this.ready = true;
|
|
52
|
+
}
|
|
53
|
+
/** Fire-and-forget notification over HTTP (server answers 202). */
|
|
54
|
+
async notify(method) {
|
|
55
|
+
const cfg = this.config;
|
|
56
|
+
const headers = {
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
Accept: 'application/json, text/event-stream',
|
|
59
|
+
...cfg.headers,
|
|
60
|
+
};
|
|
61
|
+
if (this.sessionId)
|
|
62
|
+
headers['Mcp-Session-Id'] = this.sessionId;
|
|
63
|
+
await fetch(cfg.url, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers,
|
|
66
|
+
body: JSON.stringify({ jsonrpc: '2.0', method }),
|
|
67
|
+
}).catch(() => undefined);
|
|
68
|
+
}
|
|
69
|
+
spawnStdio() {
|
|
70
|
+
const cfg = this.config;
|
|
71
|
+
// Allowlist, not passthrough: a spawned MCP server is third-party code;
|
|
72
|
+
// it must not inherit HMH_* keys or anything else not explicitly needed.
|
|
73
|
+
const SAFE_ENV = ['PATH', 'SYSTEMROOT', 'COMSPEC', 'TEMP', 'TMP', 'HOMEDRIVE', 'HOMEPATH', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'LANG', 'LC_ALL', 'TERM', 'NUMBER_OF_PROCESSORS'];
|
|
74
|
+
const env = {};
|
|
75
|
+
for (const k of SAFE_ENV) {
|
|
76
|
+
const v = process.env[k];
|
|
77
|
+
if (v !== undefined)
|
|
78
|
+
env[k] = v;
|
|
79
|
+
}
|
|
80
|
+
this.proc = spawn(cfg.command, cfg.args ?? [], {
|
|
81
|
+
env: { ...env, ...cfg.env },
|
|
82
|
+
windowsHide: true,
|
|
83
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
84
|
+
});
|
|
85
|
+
this.proc.stdout.setEncoding('utf8');
|
|
86
|
+
this.proc.stdout.on('data', (chunk) => this.onStdioChunk(chunk));
|
|
87
|
+
this.proc.on('error', (err) => {
|
|
88
|
+
// spawn failure (ENOENT etc.) - fail all in-flight requests loudly
|
|
89
|
+
const e = new Error(`mcp/${this.serverName}: failed to start server: ${String(err)}`);
|
|
90
|
+
for (const p of this.pending.values())
|
|
91
|
+
p.reject(e);
|
|
92
|
+
this.pending.clear();
|
|
93
|
+
});
|
|
94
|
+
this.proc.on('exit', (code) => {
|
|
95
|
+
const detail = this.stderrTail.trim().slice(-300);
|
|
96
|
+
const err = new Error(`mcp/${this.serverName}: server exited (code ${code})${detail ? `: ${detail}` : ''}`);
|
|
97
|
+
for (const p of this.pending.values())
|
|
98
|
+
p.reject(err);
|
|
99
|
+
this.pending.clear();
|
|
100
|
+
});
|
|
101
|
+
this.proc.stderr.setEncoding('utf8');
|
|
102
|
+
// MCP servers are chatty on stderr by design (logging); keep only the
|
|
103
|
+
// tail so a crash can be diagnosed without spamming the console.
|
|
104
|
+
this.proc.stderr.on('data', (chunk) => {
|
|
105
|
+
this.stderrTail = (this.stderrTail + chunk).slice(-500);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
onStdioChunk(chunk) {
|
|
109
|
+
this.buffer += chunk;
|
|
110
|
+
let nl;
|
|
111
|
+
while ((nl = this.buffer.indexOf('\n')) >= 0) {
|
|
112
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
113
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
114
|
+
if (!line)
|
|
115
|
+
continue;
|
|
116
|
+
try {
|
|
117
|
+
this.onMessage(JSON.parse(line));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
/* non-JSON noise on stdout - ignore */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
onMessage(msg) {
|
|
125
|
+
const id = typeof msg.id === 'number' ? msg.id : Number(msg.id);
|
|
126
|
+
const p = this.pending.get(id);
|
|
127
|
+
if (p) {
|
|
128
|
+
this.pending.delete(id);
|
|
129
|
+
p.resolve(msg);
|
|
130
|
+
}
|
|
131
|
+
// server-initiated requests/notifications are not needed yet - ignored.
|
|
132
|
+
}
|
|
133
|
+
/** One JSON-RPC round trip over whichever transport this server uses. */
|
|
134
|
+
async request(method, params, timeoutMs) {
|
|
135
|
+
const id = nextId();
|
|
136
|
+
const payload = { jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) };
|
|
137
|
+
if (this.config.type === 'stdio') {
|
|
138
|
+
if (!this.proc?.stdin?.writable)
|
|
139
|
+
throw new Error(`mcp/${this.serverName}: server not connected`);
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
this.pending.delete(id);
|
|
143
|
+
reject(new Error(`mcp/${this.serverName}: "${method}" timed out after ${timeoutMs}ms`));
|
|
144
|
+
}, timeoutMs);
|
|
145
|
+
this.pending.set(id, {
|
|
146
|
+
resolve: (r) => { clearTimeout(timer); resolve(r); },
|
|
147
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
148
|
+
});
|
|
149
|
+
this.proc.stdin.write(JSON.stringify(payload) + '\n');
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// Streamable HTTP: single POST; response is JSON or an SSE stream of one message.
|
|
153
|
+
const cfg = this.config;
|
|
154
|
+
const headers = {
|
|
155
|
+
'Content-Type': 'application/json',
|
|
156
|
+
Accept: 'application/json, text/event-stream',
|
|
157
|
+
...cfg.headers,
|
|
158
|
+
};
|
|
159
|
+
if (this.sessionId)
|
|
160
|
+
headers['Mcp-Session-Id'] = this.sessionId;
|
|
161
|
+
const ctrl = new AbortController();
|
|
162
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(cfg.url, { method: 'POST', headers, body: JSON.stringify(payload), signal: ctrl.signal });
|
|
165
|
+
const sid = res.headers.get('mcp-session-id');
|
|
166
|
+
if (sid)
|
|
167
|
+
this.sessionId = sid;
|
|
168
|
+
if (!res.ok)
|
|
169
|
+
throw new Error(`mcp/${this.serverName}: HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
170
|
+
const ctype = res.headers.get('content-type') ?? '';
|
|
171
|
+
let body;
|
|
172
|
+
if (ctype.includes('text/event-stream')) {
|
|
173
|
+
body = await this.firstSseMessage(res);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
body = await res.json();
|
|
177
|
+
}
|
|
178
|
+
return body;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Read an SSE body up to the first `data:` JSON message, then stop. */
|
|
185
|
+
async firstSseMessage(res) {
|
|
186
|
+
const reader = res.body.getReader();
|
|
187
|
+
const decoder = new TextDecoder();
|
|
188
|
+
let buf = '';
|
|
189
|
+
try {
|
|
190
|
+
for (;;) {
|
|
191
|
+
const { done, value } = await reader.read();
|
|
192
|
+
if (done)
|
|
193
|
+
break;
|
|
194
|
+
buf += decoder.decode(value, { stream: true });
|
|
195
|
+
let nl;
|
|
196
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
197
|
+
const line = buf.slice(0, nl).trim();
|
|
198
|
+
buf = buf.slice(nl + 1);
|
|
199
|
+
if (line.startsWith('data:')) {
|
|
200
|
+
const data = line.slice(5).trim();
|
|
201
|
+
if (data && data !== '[DONE]') {
|
|
202
|
+
await reader.cancel().catch(() => undefined);
|
|
203
|
+
return JSON.parse(data);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
reader.releaseLock();
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`mcp/${this.serverName}: SSE stream ended without a message`);
|
|
213
|
+
}
|
|
214
|
+
async listTools() {
|
|
215
|
+
if (!this.ready)
|
|
216
|
+
throw new Error(`mcp/${this.serverName}: not connected`);
|
|
217
|
+
const r = await this.request('tools/list', {}, 20_000);
|
|
218
|
+
if (r.error)
|
|
219
|
+
throw new Error(`mcp/${this.serverName}: tools/list failed: ${r.error.message}`);
|
|
220
|
+
const tools = r.result.tools ?? [];
|
|
221
|
+
return tools;
|
|
222
|
+
}
|
|
223
|
+
async callTool(name, args, timeoutMs = 60_000) {
|
|
224
|
+
const r = await this.request('tools/call', { name, arguments: args }, timeoutMs);
|
|
225
|
+
if (r.error)
|
|
226
|
+
return { output: `mcp/${this.serverName}: call failed: ${r.error.message}`, isError: true };
|
|
227
|
+
const res = r.result;
|
|
228
|
+
const text = (res.content ?? [])
|
|
229
|
+
.filter((c) => c.type === 'text' && typeof c.text === 'string')
|
|
230
|
+
.map((c) => c.text)
|
|
231
|
+
.join('\n');
|
|
232
|
+
const output = text || (res.structuredContent ? JSON.stringify(res.structuredContent) : '(empty result)');
|
|
233
|
+
return { output: output.length > 60_000 ? output.slice(0, 60_000) + '\n...[truncated]' : output, isError: res.isError === true };
|
|
234
|
+
}
|
|
235
|
+
close() {
|
|
236
|
+
for (const p of this.pending.values())
|
|
237
|
+
p.reject(new Error(`mcp/${this.serverName}: closed`));
|
|
238
|
+
this.pending.clear();
|
|
239
|
+
this.proc?.kill();
|
|
240
|
+
this.proc = null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** OpenAI function-name charset; MCP allows dots/dashes which it forbids. */
|
|
244
|
+
export function sanitizeToolName(name) {
|
|
245
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Connect + list + project. Separate from projectMcpTools so callers can
|
|
249
|
+
* distinguish "server down" from "server has no tools".
|
|
250
|
+
*/
|
|
251
|
+
export async function mcpServerTools(serverName, config) {
|
|
252
|
+
const client = new McpClient(serverName, config);
|
|
253
|
+
await client.connect();
|
|
254
|
+
const remote = await client.listTools();
|
|
255
|
+
const trusted = 'trusted' in config && config.trusted === true;
|
|
256
|
+
const tools = remote.map((t) => {
|
|
257
|
+
const localName = `mcp_${sanitizeToolName(serverName)}_${sanitizeToolName(t.name)}`.slice(0, 60);
|
|
258
|
+
const remoteName = t.name;
|
|
259
|
+
return {
|
|
260
|
+
name: localName,
|
|
261
|
+
description: `[mcp:${serverName}] ${t.description ?? t.name}`.slice(0, 400),
|
|
262
|
+
parameters: normalizeSchema(t.inputSchema),
|
|
263
|
+
needsApproval: trusted ? undefined : () => true,
|
|
264
|
+
async execute(args) {
|
|
265
|
+
return client.callTool(remoteName, args);
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
return { client, tools };
|
|
270
|
+
}
|
|
271
|
+
/** MCP inputSchema is already JSON-Schema; coerce loosely, never throw. */
|
|
272
|
+
function normalizeSchema(schema) {
|
|
273
|
+
if (schema && typeof schema === 'object') {
|
|
274
|
+
const s = schema;
|
|
275
|
+
return {
|
|
276
|
+
type: s.type ?? 'object',
|
|
277
|
+
...(s.properties ? { properties: s.properties } : {}),
|
|
278
|
+
...(s.required ? { required: s.required } : {}),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return { type: 'object' };
|
|
282
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/kernel - provider
|
|
3
|
+
* OpenAI-compatible chat adapter. Works with any /v1/chat/completions
|
|
4
|
+
* endpoint: zhipu GLM, OpenAI, OpenRouter, NVIDIA NIM, vLLM, Ollama, ...
|
|
5
|
+
* Streaming (SSE) activates when onDelta is provided; reasoning deltas are
|
|
6
|
+
* surfaced separately so frontends can show the model thinking. One retry on
|
|
7
|
+
* transient 429/5xx before the stream starts; mid-stream failures surface.
|
|
8
|
+
*/
|
|
9
|
+
import type { ChatMessage, ProviderConfig } from './types.ts';
|
|
10
|
+
export interface ChatResponse {
|
|
11
|
+
message: ChatMessage;
|
|
12
|
+
usage?: {
|
|
13
|
+
prompt_tokens?: number;
|
|
14
|
+
completion_tokens?: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export type DeltaKind = 'text' | 'reasoning';
|
|
18
|
+
export interface ChatOptions {
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
/** Streaming callback; presence switches the request to stream:true. */
|
|
21
|
+
onDelta?(kind: DeltaKind, chunk: string): void;
|
|
22
|
+
}
|
|
23
|
+
export declare function chat(cfg: ProviderConfig, messages: ChatMessage[], tools?: unknown[], opts?: ChatOptions): Promise<ChatResponse>;
|
|
24
|
+
/**
|
|
25
|
+
* Single multimodal call: text prompt + one image (data URL). Used by the
|
|
26
|
+
* see_image tool; deliberately separate from chat() so the streaming path
|
|
27
|
+
* stays boring. Non-streaming, one retry, hard timeout.
|
|
28
|
+
*/
|
|
29
|
+
export declare function chatVision(cfg: ProviderConfig, prompt: string, imageDataUrl: string, opts?: {
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
maxTokens?: number;
|
|
32
|
+
}): Promise<string>;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
export async function chat(cfg, messages, tools, opts = {}) {
|
|
2
|
+
const streaming = typeof opts.onDelta === 'function';
|
|
3
|
+
const body = { model: cfg.model, messages };
|
|
4
|
+
if (tools && tools.length > 0)
|
|
5
|
+
body.tools = tools;
|
|
6
|
+
if (streaming) {
|
|
7
|
+
body.stream = true;
|
|
8
|
+
body.stream_options = { include_usage: true };
|
|
9
|
+
}
|
|
10
|
+
// auth scheme: standard Bearer, or a custom header (some gateways such as
|
|
11
|
+
// freellmapi only accept X-Api-Key). If Bearer gets a 401 we silently
|
|
12
|
+
// renegotiate once with X-Api-Key - misconfigured gateways then just work.
|
|
13
|
+
const mkHeaders = (scheme) => scheme === 'bearer'
|
|
14
|
+
? { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.apiKey}` }
|
|
15
|
+
: { 'Content-Type': 'application/json', [typeof scheme === 'string' ? scheme : 'X-Api-Key']: cfg.apiKey };
|
|
16
|
+
let authScheme = cfg.authHeader ?? 'bearer';
|
|
17
|
+
let lastError = '';
|
|
18
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
19
|
+
const ctrl = new AbortController();
|
|
20
|
+
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 120_000);
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(endpoint(cfg.baseUrl), {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: mkHeaders(authScheme),
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
signal: ctrl.signal,
|
|
27
|
+
});
|
|
28
|
+
if (res.status === 429 || res.status >= 500) {
|
|
29
|
+
lastError = `HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`;
|
|
30
|
+
await sleep(1500 * (attempt + 1));
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (res.status === 401 && !cfg.authHeader && authScheme === 'bearer' && cfg.apiKey) {
|
|
34
|
+
// gateway rejected Bearer - try the other common scheme once
|
|
35
|
+
authScheme = 'X-Api-Key';
|
|
36
|
+
lastError = 'renegotiating auth: Bearer rejected, retrying with X-Api-Key';
|
|
37
|
+
attempt--;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
throw new Error(`provider: HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
42
|
+
}
|
|
43
|
+
if (streaming) {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
return await consumeStream(res, opts.onDelta);
|
|
46
|
+
}
|
|
47
|
+
const data = (await res.json());
|
|
48
|
+
const choice = data.choices?.[0];
|
|
49
|
+
if (!choice)
|
|
50
|
+
throw new Error('provider: response had no choices');
|
|
51
|
+
return {
|
|
52
|
+
message: {
|
|
53
|
+
role: 'assistant',
|
|
54
|
+
content: choice.message?.content ?? null,
|
|
55
|
+
...(choice.message?.tool_calls ? { tool_calls: choice.message.tool_calls } : {}),
|
|
56
|
+
},
|
|
57
|
+
usage: data.usage,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
lastError = String(err);
|
|
62
|
+
if (!/abort|fetch failed|ECONN|timeout/i.test(lastError))
|
|
63
|
+
throw err;
|
|
64
|
+
await sleep(1500 * (attempt + 1));
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`provider: failed after retry (${cfg.baseUrl}): ${lastError}`);
|
|
71
|
+
}
|
|
72
|
+
/** Assemble a ChatResponse from an SSE stream, emitting deltas as they land. */
|
|
73
|
+
async function consumeStream(res, onDelta) {
|
|
74
|
+
const reader = res.body.getReader();
|
|
75
|
+
const decoder = new TextDecoder();
|
|
76
|
+
let buf = '';
|
|
77
|
+
let text = '';
|
|
78
|
+
let reasoning = '';
|
|
79
|
+
let usage;
|
|
80
|
+
// tool_calls accumulate across deltas, keyed by their index in the stream.
|
|
81
|
+
const calls = new Map();
|
|
82
|
+
// Idle guard rather than a total cap: generation length is unbounded.
|
|
83
|
+
const idleCtrl = new AbortController();
|
|
84
|
+
let idleTimer = setTimeout(() => idleCtrl.abort(), 180_000);
|
|
85
|
+
const bumpIdle = () => {
|
|
86
|
+
clearTimeout(idleTimer);
|
|
87
|
+
idleTimer = setTimeout(() => idleCtrl.abort(), 180_000);
|
|
88
|
+
};
|
|
89
|
+
const idlePromise = new Promise((_, reject) => {
|
|
90
|
+
idleCtrl.signal.addEventListener('abort', () => reject(new Error('provider: stream idle timeout')), { once: true });
|
|
91
|
+
});
|
|
92
|
+
const pump = async () => {
|
|
93
|
+
for (;;) {
|
|
94
|
+
const { done, value } = await Promise.race([reader.read(), idlePromise]);
|
|
95
|
+
if (done)
|
|
96
|
+
return;
|
|
97
|
+
bumpIdle();
|
|
98
|
+
buf += decoder.decode(value, { stream: true });
|
|
99
|
+
let nl;
|
|
100
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
101
|
+
const line = buf.slice(0, nl).trim();
|
|
102
|
+
buf = buf.slice(nl + 1);
|
|
103
|
+
if (!line.startsWith('data:'))
|
|
104
|
+
continue;
|
|
105
|
+
const data = line.slice(5).trim();
|
|
106
|
+
if (!data || data === '[DONE]')
|
|
107
|
+
continue;
|
|
108
|
+
let chunk;
|
|
109
|
+
try {
|
|
110
|
+
chunk = JSON.parse(data);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
continue; // keep-alive comment or malformed line
|
|
114
|
+
}
|
|
115
|
+
if (chunk.usage)
|
|
116
|
+
usage = chunk.usage;
|
|
117
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
118
|
+
if (!delta)
|
|
119
|
+
continue;
|
|
120
|
+
if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) {
|
|
121
|
+
reasoning += delta.reasoning_content;
|
|
122
|
+
onDelta('reasoning', delta.reasoning_content);
|
|
123
|
+
}
|
|
124
|
+
else if (typeof delta.reasoning === 'string' && delta.reasoning) {
|
|
125
|
+
reasoning += delta.reasoning;
|
|
126
|
+
onDelta('reasoning', delta.reasoning);
|
|
127
|
+
}
|
|
128
|
+
if (typeof delta.content === 'string' && delta.content) {
|
|
129
|
+
text += delta.content;
|
|
130
|
+
onDelta('text', delta.content);
|
|
131
|
+
}
|
|
132
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
133
|
+
for (const tc of delta.tool_calls) {
|
|
134
|
+
const idx = tc.index ?? 0;
|
|
135
|
+
const cur = calls.get(idx) ?? { id: '', type: 'function', function: { name: '', arguments: '' } };
|
|
136
|
+
if (tc.id)
|
|
137
|
+
cur.id = tc.id;
|
|
138
|
+
if (tc.function?.name)
|
|
139
|
+
cur.function.name += tc.function.name;
|
|
140
|
+
if (tc.function?.arguments)
|
|
141
|
+
cur.function.arguments += tc.function.arguments;
|
|
142
|
+
calls.set(idx, cur);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
await pump();
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
clearTimeout(idleTimer);
|
|
153
|
+
reader.releaseLock();
|
|
154
|
+
}
|
|
155
|
+
const ordered = [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([, c]) => c);
|
|
156
|
+
return {
|
|
157
|
+
message: {
|
|
158
|
+
role: 'assistant',
|
|
159
|
+
content: text || null,
|
|
160
|
+
...(ordered.length > 0 ? { tool_calls: ordered } : {}),
|
|
161
|
+
},
|
|
162
|
+
usage,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** Accept bases both with and without the /v1 suffix. */
|
|
166
|
+
function endpoint(baseUrl) {
|
|
167
|
+
const b = baseUrl.replace(/\/+$/, '');
|
|
168
|
+
return b.endsWith('/v1') ? `${b}/chat/completions` : `${b}/v1/chat/completions`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Single multimodal call: text prompt + one image (data URL). Used by the
|
|
172
|
+
* see_image tool; deliberately separate from chat() so the streaming path
|
|
173
|
+
* stays boring. Non-streaming, one retry, hard timeout.
|
|
174
|
+
*/
|
|
175
|
+
export async function chatVision(cfg, prompt, imageDataUrl, opts = {}) {
|
|
176
|
+
const body = {
|
|
177
|
+
model: cfg.model,
|
|
178
|
+
max_tokens: opts.maxTokens ?? 800,
|
|
179
|
+
messages: [
|
|
180
|
+
{
|
|
181
|
+
role: 'user',
|
|
182
|
+
content: [
|
|
183
|
+
{ type: 'text', text: prompt },
|
|
184
|
+
{ type: 'image_url', image_url: { url: imageDataUrl } },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
let lastError = '';
|
|
190
|
+
// same auth negotiation as chat(): explicit header, Bearer, then X-Api-Key
|
|
191
|
+
let vAuth = cfg.authHeader ?? 'bearer';
|
|
192
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
193
|
+
const ctrl = new AbortController();
|
|
194
|
+
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 120_000);
|
|
195
|
+
try {
|
|
196
|
+
const vHeaders = vAuth === 'bearer'
|
|
197
|
+
? { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.apiKey}` }
|
|
198
|
+
: { 'Content-Type': 'application/json', [vAuth]: cfg.apiKey };
|
|
199
|
+
const res = await fetch(endpoint(cfg.baseUrl), {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: vHeaders,
|
|
202
|
+
body: JSON.stringify(body),
|
|
203
|
+
signal: ctrl.signal,
|
|
204
|
+
});
|
|
205
|
+
if (res.status === 401 && !cfg.authHeader && vAuth === 'bearer' && cfg.apiKey) {
|
|
206
|
+
vAuth = 'X-Api-Key';
|
|
207
|
+
attempt--;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (res.status === 429 || res.status >= 500) {
|
|
211
|
+
lastError = `HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`;
|
|
212
|
+
await sleep(1500 * (attempt + 1));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!res.ok)
|
|
216
|
+
throw new Error(`provider: HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
217
|
+
const data = (await res.json());
|
|
218
|
+
const text = data.choices?.[0]?.message?.content;
|
|
219
|
+
if (typeof text !== 'string')
|
|
220
|
+
throw new Error('provider: vision response had no content');
|
|
221
|
+
return text;
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
lastError = String(err);
|
|
225
|
+
if (!/abort|fetch failed|ECONN|timeout/i.test(lastError))
|
|
226
|
+
throw err;
|
|
227
|
+
await sleep(1500 * (attempt + 1));
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
throw new Error(`provider: vision call failed after retry (${cfg.baseUrl}): ${lastError}`);
|
|
234
|
+
}
|
|
235
|
+
function sleep(ms) {
|
|
236
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
237
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/kernel - registry
|
|
3
|
+
* The capability registry. One Map, duplicate-name rejection, OpenAI shape
|
|
4
|
+
* projection. Boring on purpose: 2026 consensus is that the loop+registry
|
|
5
|
+
* core should stay simple while capability volume grows around it.
|
|
6
|
+
*/
|
|
7
|
+
import type { Tool } from './types.ts';
|
|
8
|
+
export declare class Registry {
|
|
9
|
+
#private;
|
|
10
|
+
register(tool: Tool): this;
|
|
11
|
+
registerAll(tools: Tool[]): this;
|
|
12
|
+
get(name: string): Tool | undefined;
|
|
13
|
+
names(): string[];
|
|
14
|
+
list(): Tool[];
|
|
15
|
+
/** Project into the OpenAI chat.completions `tools` array. */
|
|
16
|
+
toOpenAITools(): Array<{
|
|
17
|
+
type: 'function';
|
|
18
|
+
function: {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
parameters: unknown;
|
|
22
|
+
};
|
|
23
|
+
}>;
|
|
24
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export class Registry {
|
|
2
|
+
#tools = new Map();
|
|
3
|
+
register(tool) {
|
|
4
|
+
if (this.#tools.has(tool.name)) {
|
|
5
|
+
throw new Error(`kernel/registry: duplicate tool name "${tool.name}"`);
|
|
6
|
+
}
|
|
7
|
+
this.#tools.set(tool.name, tool);
|
|
8
|
+
return this;
|
|
9
|
+
}
|
|
10
|
+
registerAll(tools) {
|
|
11
|
+
for (const t of tools)
|
|
12
|
+
this.register(t);
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
get(name) {
|
|
16
|
+
return this.#tools.get(name);
|
|
17
|
+
}
|
|
18
|
+
names() {
|
|
19
|
+
return [...this.#tools.keys()];
|
|
20
|
+
}
|
|
21
|
+
list() {
|
|
22
|
+
return [...this.#tools.values()];
|
|
23
|
+
}
|
|
24
|
+
/** Project into the OpenAI chat.completions `tools` array. */
|
|
25
|
+
toOpenAITools() {
|
|
26
|
+
return this.list().map((t) => ({
|
|
27
|
+
type: 'function',
|
|
28
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ChatMessage } from './types.ts';
|
|
2
|
+
export type SessionEvent = {
|
|
3
|
+
t: 'session/start';
|
|
4
|
+
id: string;
|
|
5
|
+
time: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
model: string;
|
|
8
|
+
} | {
|
|
9
|
+
t: 'user';
|
|
10
|
+
time: string;
|
|
11
|
+
text: string;
|
|
12
|
+
} | {
|
|
13
|
+
t: 'assistant';
|
|
14
|
+
time: string;
|
|
15
|
+
text: string | null;
|
|
16
|
+
tool_calls?: unknown[];
|
|
17
|
+
} | {
|
|
18
|
+
t: 'tool';
|
|
19
|
+
time: string;
|
|
20
|
+
name: string;
|
|
21
|
+
output: string;
|
|
22
|
+
isError: boolean;
|
|
23
|
+
} | {
|
|
24
|
+
t: 'approval';
|
|
25
|
+
time: string;
|
|
26
|
+
tool: string;
|
|
27
|
+
granted: boolean;
|
|
28
|
+
} | {
|
|
29
|
+
t: 'final';
|
|
30
|
+
time: string;
|
|
31
|
+
text: string;
|
|
32
|
+
turns: number;
|
|
33
|
+
toolUses: number;
|
|
34
|
+
};
|
|
35
|
+
export declare class Session {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly file: string;
|
|
38
|
+
/** Append chain: events serialize in call order, even fire-and-forget ones. */
|
|
39
|
+
private tail;
|
|
40
|
+
constructor(home: string, cwd: string, model: string);
|
|
41
|
+
append(event: SessionEvent): Promise<void>;
|
|
42
|
+
user(text: string): Promise<void>;
|
|
43
|
+
assistant(text: string | null, toolCalls?: unknown[]): Promise<void>;
|
|
44
|
+
tool(name: string, output: string, isError: boolean): Promise<void>;
|
|
45
|
+
approval(tool: string, granted: boolean): Promise<void>;
|
|
46
|
+
final(text: string, turns: number, toolUses: number): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export interface SessionTranscript {
|
|
49
|
+
id: string;
|
|
50
|
+
model: string;
|
|
51
|
+
cwd: string;
|
|
52
|
+
messages: ChatMessage[];
|
|
53
|
+
}
|
|
54
|
+
/** Find the newest session file under home/sessions matching an id prefix. */
|
|
55
|
+
export declare function latestSession(home: string, prefix?: string): Promise<string | null>;
|
|
56
|
+
/**
|
|
57
|
+
* Rebuild a chat transcript from a session log. Tool events don't record
|
|
58
|
+
* tool_call_id, but the loop executes calls sequentially, so ids pair with
|
|
59
|
+
* the tool events that follow their assistant message in order.
|
|
60
|
+
*/
|
|
61
|
+
export declare function loadTranscript(file: string): Promise<SessionTranscript | null>;
|