@gns-foundation/hive-worker 0.1.10 → 0.5.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/cli.js +61 -4
- package/dist/cli.js.map +1 -1
- package/dist/executor.js +2 -0
- package/dist/executor.js.map +1 -1
- package/dist/mobydb.d.ts +132 -0
- package/dist/mobydb.js +379 -0
- package/dist/mobydb.js.map +1 -0
- package/dist/mobydb_hooks.d.ts +54 -0
- package/dist/mobydb_hooks.js +136 -0
- package/dist/mobydb_hooks.js.map +1 -0
- package/package.json +10 -8
- package/package.json.v0.5.bak.20260515T094956Z +43 -0
- package/src/cli.ts +63 -4
- package/src/cli.ts.v0.5.bak.20260515T094956Z +569 -0
- package/src/executor.ts +4 -0
- package/src/executor.ts.v0.5.bak.20260515T094956Z +279 -0
- package/src/mobydb.ts +558 -0
- package/src/mobydb_hooks.ts +173 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// HIVE WORKER — EXECUTOR
|
|
3
|
+
// Runs llama-cli for an assigned job.
|
|
4
|
+
//
|
|
5
|
+
// Two modes:
|
|
6
|
+
// pipeline — rpc-server already running, orchestrator calls us
|
|
7
|
+
// (worker just tracks status, no local llama-cli)
|
|
8
|
+
// solo — worker claims and runs the whole model locally
|
|
9
|
+
// (for small models: phi-3-mini, gemma-2-2b, etc.)
|
|
10
|
+
//
|
|
11
|
+
// Solo mode is the active path for hive-worker v0.1 / v0.2.
|
|
12
|
+
// Pipeline mode is the full swarm path (Phase 3 whitepaper).
|
|
13
|
+
// ============================================================
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'child_process';
|
|
16
|
+
import { execSync } from 'child_process';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import type { HiveJob, JobResult, RpcPeer } from './jobs.js';
|
|
20
|
+
|
|
21
|
+
// ─── Binary detection ─────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const LLAMA_CLI_CANDIDATES = [
|
|
24
|
+
// llama-completion: pure completion mode, clean stdout, no banner
|
|
25
|
+
`${os.homedir()}/llama.cpp/build/bin/llama-completion`,
|
|
26
|
+
`${os.homedir()}/llama.cpp/build/llama-completion`,
|
|
27
|
+
'llama-completion',
|
|
28
|
+
// Fallback to llama-cli
|
|
29
|
+
`${os.homedir()}/llama.cpp/build/bin/llama-cli`,
|
|
30
|
+
`${os.homedir()}/llama.cpp/build/llama-cli`,
|
|
31
|
+
'llama-cli',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function findLlamaCli(): string | null {
|
|
35
|
+
for (const candidate of LLAMA_CLI_CANDIDATES) {
|
|
36
|
+
try {
|
|
37
|
+
execSync(`test -f "${candidate}" || which "${candidate}" 2>/dev/null`, { timeout: 2000 });
|
|
38
|
+
return candidate;
|
|
39
|
+
} catch { /* not found */ }
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Model cache ──────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const MODEL_CACHE_DIR = path.join(os.homedir(), '.hive', 'models');
|
|
47
|
+
|
|
48
|
+
import fs from 'fs';
|
|
49
|
+
|
|
50
|
+
export function ensureModelCacheDir(): void {
|
|
51
|
+
fs.mkdirSync(MODEL_CACHE_DIR, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function modelCachePath(modelId: string): string {
|
|
55
|
+
// Normalize: "phi-3-mini" → "phi-3-mini.gguf"
|
|
56
|
+
const filename = modelId.endsWith('.gguf') ? modelId : `${modelId}.gguf`;
|
|
57
|
+
return path.join(MODEL_CACHE_DIR, filename);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isModelCached(modelId: string): boolean {
|
|
61
|
+
return fs.existsSync(modelCachePath(modelId));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Known small models with public GGUF URLs (Q4_K_M quantizations)
|
|
65
|
+
const KNOWN_MODELS: Record<string, string> = {
|
|
66
|
+
'phi-3-mini':
|
|
67
|
+
'https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-gguf/resolve/main/Phi-3-mini-4k-instruct-q4.gguf',
|
|
68
|
+
'gemma-2-2b':
|
|
69
|
+
'https://huggingface.co/bartowski/gemma-2-2b-it-GGUF/resolve/main/gemma-2-2b-it-Q4_K_M.gguf',
|
|
70
|
+
'tinyllama':
|
|
71
|
+
'https://huggingface.co/TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF/resolve/main/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function resolveModelUrl(job: HiveJob): string | null {
|
|
75
|
+
if (job.model_url) return job.model_url;
|
|
76
|
+
return KNOWN_MODELS[job.model_id] ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Download model (streaming, with progress) ───────────────
|
|
80
|
+
|
|
81
|
+
export async function downloadModel(
|
|
82
|
+
modelId: string,
|
|
83
|
+
url: string,
|
|
84
|
+
onProgress: (pct: number, mbDone: number, mbTotal: number) => void,
|
|
85
|
+
): Promise<string> {
|
|
86
|
+
ensureModelCacheDir();
|
|
87
|
+
const dest = modelCachePath(modelId);
|
|
88
|
+
const tmpDest = dest + '.download';
|
|
89
|
+
|
|
90
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(300_000) });
|
|
91
|
+
if (!resp.ok) throw new Error(`Model download failed: ${resp.status}`);
|
|
92
|
+
|
|
93
|
+
const totalBytes = parseInt(resp.headers.get('content-length') ?? '0', 10);
|
|
94
|
+
const totalMb = totalBytes / 1024 / 1024;
|
|
95
|
+
|
|
96
|
+
const writer = fs.createWriteStream(tmpDest);
|
|
97
|
+
let downloaded = 0;
|
|
98
|
+
|
|
99
|
+
if (!resp.body) throw new Error('No response body');
|
|
100
|
+
|
|
101
|
+
const reader = resp.body.getReader();
|
|
102
|
+
while (true) {
|
|
103
|
+
const { done, value } = await reader.read();
|
|
104
|
+
if (done) break;
|
|
105
|
+
writer.write(value);
|
|
106
|
+
downloaded += value.length;
|
|
107
|
+
if (totalBytes > 0) {
|
|
108
|
+
onProgress(
|
|
109
|
+
Math.round((downloaded / totalBytes) * 100),
|
|
110
|
+
Math.round(downloaded / 1024 / 1024),
|
|
111
|
+
Math.round(totalMb),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await new Promise<void>((res, rej) => {
|
|
117
|
+
writer.end(() => res());
|
|
118
|
+
writer.on('error', rej);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
fs.renameSync(tmpDest, dest);
|
|
122
|
+
return dest;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Execute inference ────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export interface ExecutorOptions {
|
|
128
|
+
onToken?: (token: string) => void;
|
|
129
|
+
onLog?: (line: string) => void;
|
|
130
|
+
rpcPeers?: RpcPeer[]; // peer RPC servers for pipeline parallelism
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function executeJob(
|
|
134
|
+
job: HiveJob,
|
|
135
|
+
onTokenOrOpts: ((t: string) => void) | ExecutorOptions = {},
|
|
136
|
+
): Promise<JobResult> {
|
|
137
|
+
const opts: ExecutorOptions = typeof onTokenOrOpts === 'function'
|
|
138
|
+
? { onToken: onTokenOrOpts }
|
|
139
|
+
: onTokenOrOpts;
|
|
140
|
+
const llamaCli = findLlamaCli();
|
|
141
|
+
if (!llamaCli) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
'llama-cli not found. Install llama.cpp: https://github.com/ggerganov/llama.cpp',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const modelPath = modelCachePath(job.model_id);
|
|
148
|
+
if (!fs.existsSync(modelPath)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Model "${job.model_id}" not cached at ${modelPath}. ` +
|
|
151
|
+
`Run: hive-worker models fetch ${job.model_id}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return runLlamaCli(llamaCli, modelPath, job, opts);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function runLlamaCli(
|
|
159
|
+
binary: string,
|
|
160
|
+
modelPath: string,
|
|
161
|
+
job: HiveJob,
|
|
162
|
+
opts: ExecutorOptions,
|
|
163
|
+
): Promise<JobResult> {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const startMs = Date.now();
|
|
166
|
+
const threads = Math.max(1, Math.floor(os.cpus().length / 2));
|
|
167
|
+
|
|
168
|
+
// llama-completion: pure completion mode, clean stdout, no banner
|
|
169
|
+
const args = [
|
|
170
|
+
'--model', modelPath,
|
|
171
|
+
'--prompt', job.prompt,
|
|
172
|
+
'--n-predict', String(job.max_tokens),
|
|
173
|
+
'--temp', String(job.temperature),
|
|
174
|
+
'--ctx-size', '4096',
|
|
175
|
+
'--threads', String(threads),
|
|
176
|
+
'--single-turn', // exit after one completion
|
|
177
|
+
'-ngl', '0', // CPU-only (Metal tensor API disabled on pre-M5)
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
// Pipeline parallelism: add --rpc for each peer worker
|
|
181
|
+
const peers = opts.rpcPeers ?? [];
|
|
182
|
+
for (const peer of peers) {
|
|
183
|
+
args.push('--rpc', `${peer.rpc_host}:${peer.rpc_port}`);
|
|
184
|
+
opts.onLog?.(`Pipeline peer: ${peer.rpc_host}:${peer.rpc_port} (${peer.tflops} TFLOPS)`);
|
|
185
|
+
}
|
|
186
|
+
if (peers.length > 0) {
|
|
187
|
+
opts.onLog?.(`Pipeline mode: ${peers.length + 1} nodes, splitting ${job.max_tokens} tokens`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
opts.onLog?.(`Executing ${job.model_id} (${job.max_tokens} tokens)`);
|
|
191
|
+
|
|
192
|
+
const proc = spawn(binary, args, {
|
|
193
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
let stdout = '';
|
|
197
|
+
let stderr = '';
|
|
198
|
+
let streamBuffer = ''; // buffer for <|assistant|> detection before streaming
|
|
199
|
+
let streamingStarted = false;
|
|
200
|
+
|
|
201
|
+
proc.stdout.on('data', (chunk: Buffer) => {
|
|
202
|
+
const text = chunk.toString();
|
|
203
|
+
stdout += text;
|
|
204
|
+
|
|
205
|
+
// Stream tokens to caller once we've passed the <|assistant|> marker
|
|
206
|
+
if (opts.onToken) {
|
|
207
|
+
if (!streamingStarted) {
|
|
208
|
+
streamBuffer += text;
|
|
209
|
+
const markerIdx = streamBuffer.lastIndexOf('<|assistant|>');
|
|
210
|
+
if (markerIdx !== -1) {
|
|
211
|
+
// Found marker — emit everything after it
|
|
212
|
+
streamingStarted = true;
|
|
213
|
+
const afterMarker = streamBuffer.slice(markerIdx + '<|assistant|>'.length);
|
|
214
|
+
if (afterMarker.length > 0) opts.onToken(afterMarker);
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
// Already past marker — emit each chunk immediately
|
|
218
|
+
opts.onToken(text);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
proc.stderr.on('data', (chunk: Buffer) => {
|
|
223
|
+
stderr += chunk.toString();
|
|
224
|
+
opts.onLog?.(chunk.toString().slice(0, 60).trim());
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
proc.on('error', (err) => reject(new Error(`spawn error: ${err.message}`)));
|
|
228
|
+
|
|
229
|
+
proc.on('close', (code) => {
|
|
230
|
+
const wallMs = Date.now() - startMs;
|
|
231
|
+
opts.onLog?.(`stdout=${stdout.length}b stderr=${stderr.length}b code=${code}`);
|
|
232
|
+
|
|
233
|
+
if (code !== 0 && stdout.trim().length === 0) {
|
|
234
|
+
reject(new Error(`llama-cli exited ${code}: ${stderr.slice(-200)}`));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// llama-completion echoes the prompt with chat template then the response.
|
|
239
|
+
// Strip everything up to and including <|assistant|> token.
|
|
240
|
+
let resultText = stdout.trim();
|
|
241
|
+
const assistantMarker = '<|assistant|>';
|
|
242
|
+
const assistantIdx = resultText.lastIndexOf(assistantMarker);
|
|
243
|
+
if (assistantIdx !== -1) {
|
|
244
|
+
resultText = resultText.slice(assistantIdx + assistantMarker.length).trim();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Parse tok/s from stderr timing line
|
|
248
|
+
let tokensPerSecond = 0;
|
|
249
|
+
const tokensGenerated = resultText.split(/\s+/).filter(Boolean).length;
|
|
250
|
+
const tpsMatch = stderr.match(/eval time\s*=\s*([\d.]+)\s*ms\s*\/\s*(\d+)\s*tokens/);
|
|
251
|
+
if (tpsMatch) {
|
|
252
|
+
const evalMs = parseFloat(tpsMatch[1]);
|
|
253
|
+
tokensPerSecond = Math.round((parseInt(tpsMatch[2], 10) / evalMs) * 1000 * 10) / 10;
|
|
254
|
+
} else if (wallMs > 0) {
|
|
255
|
+
tokensPerSecond = Math.round((tokensGenerated / wallMs) * 1000 * 10) / 10;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
resolve({ resultText, tokensGenerated, tokensPerSecond });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const deadline = new Date(job.timeout_at).getTime();
|
|
262
|
+
const remaining = deadline - Date.now() - 10_000;
|
|
263
|
+
if (remaining > 0) {
|
|
264
|
+
setTimeout(() => { proc.kill(); reject(new Error('Job timed out')); }, remaining);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
// ─── List cached models ───────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
export function listCachedModels(): Array<{ modelId: string; sizeMb: number }> {
|
|
273
|
+
ensureModelCacheDir();
|
|
274
|
+
const files = fs.readdirSync(MODEL_CACHE_DIR).filter(f => f.endsWith('.gguf'));
|
|
275
|
+
return files.map(f => ({
|
|
276
|
+
modelId: f.replace('.gguf', ''),
|
|
277
|
+
sizeMb: Math.round(fs.statSync(path.join(MODEL_CACHE_DIR, f)).size / 1024 / 1024),
|
|
278
|
+
}));
|
|
279
|
+
}
|