@flue/client 0.0.16 → 0.0.18
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/index.d.mts +0 -2
- package/dist/index.mjs +115 -68
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -100,8 +100,6 @@ interface SkillOptions<S extends v.GenericSchema | undefined = undefined> {
|
|
|
100
100
|
providerID: string;
|
|
101
101
|
modelID: string;
|
|
102
102
|
};
|
|
103
|
-
/** Advanced: override the entire prompt. */
|
|
104
|
-
prompt?: string;
|
|
105
103
|
}
|
|
106
104
|
interface PromptOptions<S extends v.GenericSchema | undefined = undefined> {
|
|
107
105
|
/** Valibot schema for structured result extraction. */
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createOpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
-
import { exec } from "node:child_process";
|
|
3
2
|
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
4
|
import * as v from "valibot";
|
|
5
5
|
|
|
6
6
|
//#region src/errors.ts
|
|
@@ -162,6 +162,66 @@ function transformEvent(raw) {
|
|
|
162
162
|
return null;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/prompt.ts
|
|
167
|
+
/**
|
|
168
|
+
* Preamble instructing the LLM to operate autonomously without user interaction.
|
|
169
|
+
*/
|
|
170
|
+
const HEADLESS_PREAMBLE = "You are running in headless mode with no human operator. Work autonomously — never ask questions, never wait for user input, never use the question tool. Make your best judgment and proceed independently.";
|
|
171
|
+
/**
|
|
172
|
+
* Checks if a skill name is a file path (contains '/' or ends with '.md').
|
|
173
|
+
*/
|
|
174
|
+
function isFilePath(name) {
|
|
175
|
+
return name.includes("/") || name.endsWith(".md");
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Build the ---RESULT_START--- / ---RESULT_END--- extraction instructions
|
|
179
|
+
* for a given Valibot schema.
|
|
180
|
+
*/
|
|
181
|
+
function buildResultInstructions(schema) {
|
|
182
|
+
const { $schema: _, ...schemaWithoutMeta } = toJsonSchema(schema, { errorMode: "ignore" });
|
|
183
|
+
return [
|
|
184
|
+
"",
|
|
185
|
+
"When complete, you MUST output your result between these exact delimiters conforming to this schema:",
|
|
186
|
+
"```json",
|
|
187
|
+
JSON.stringify(schemaWithoutMeta, null, 2),
|
|
188
|
+
"```",
|
|
189
|
+
"",
|
|
190
|
+
"Example: (Object)",
|
|
191
|
+
"---RESULT_START---",
|
|
192
|
+
"{\"key\": \"value\"}",
|
|
193
|
+
"---RESULT_END---",
|
|
194
|
+
"",
|
|
195
|
+
"Example: (String)",
|
|
196
|
+
"---RESULT_START---",
|
|
197
|
+
"Hello, world!",
|
|
198
|
+
"---RESULT_END---"
|
|
199
|
+
].join("\n");
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Build the prompt text for a skill invocation.
|
|
203
|
+
*
|
|
204
|
+
* If `name` looks like a file path (contains '/' or ends with '.md'), the
|
|
205
|
+
* prompt instructs the agent to read and follow that file under
|
|
206
|
+
* `.agents/skills/`. Otherwise, it instructs the agent to use the named
|
|
207
|
+
* skill.
|
|
208
|
+
*
|
|
209
|
+
* @param name - A skill name or a file path relative to .agents/skills/.
|
|
210
|
+
* @param args - Key-value arguments to include in the prompt.
|
|
211
|
+
* @param schema - Optional Valibot schema for result extraction.
|
|
212
|
+
* @returns The complete prompt string.
|
|
213
|
+
*/
|
|
214
|
+
function buildSkillPrompt(name, args, schema) {
|
|
215
|
+
const parts = [
|
|
216
|
+
HEADLESS_PREAMBLE,
|
|
217
|
+
"",
|
|
218
|
+
isFilePath(name) ? `Read the file .agents/skills/${name} directly from disk (do not use the skill tool) and follow it as your skill instructions.` : `Use the ${name} skill.`
|
|
219
|
+
];
|
|
220
|
+
if (args && Object.keys(args).length > 0) parts.push(`\nArguments:\n${JSON.stringify(args, null, 2)}`);
|
|
221
|
+
if (schema) parts.push(buildResultInstructions(schema));
|
|
222
|
+
return parts.join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
165
225
|
//#endregion
|
|
166
226
|
//#region src/shell.ts
|
|
167
227
|
async function runShell(command, options) {
|
|
@@ -202,41 +262,6 @@ async function runShell(command, options) {
|
|
|
202
262
|
});
|
|
203
263
|
}
|
|
204
264
|
|
|
205
|
-
//#endregion
|
|
206
|
-
//#region src/prompt.ts
|
|
207
|
-
/**
|
|
208
|
-
* Checks if a skill name is a file path (contains '/' or ends with '.md').
|
|
209
|
-
*/
|
|
210
|
-
function isFilePath(name) {
|
|
211
|
-
return name.includes("/") || name.endsWith(".md");
|
|
212
|
-
}
|
|
213
|
-
/**
|
|
214
|
-
* Build the prompt text for a skill invocation.
|
|
215
|
-
*
|
|
216
|
-
* If `name` looks like a file path (contains '/' or ends with '.md'), the
|
|
217
|
-
* prompt instructs the agent to read and follow that file under
|
|
218
|
-
* `.agents/skills/`. Otherwise, it instructs the agent to use the named
|
|
219
|
-
* skill.
|
|
220
|
-
*
|
|
221
|
-
* @param name - A skill name or a file path relative to .agents/skills/.
|
|
222
|
-
* @param args - Key-value arguments to include in the prompt.
|
|
223
|
-
* @param schema - Optional Valibot schema for result extraction.
|
|
224
|
-
* @returns The complete prompt string.
|
|
225
|
-
*/
|
|
226
|
-
function buildSkillPrompt(name, args, schema) {
|
|
227
|
-
const parts = [
|
|
228
|
-
"You are running in headless mode with no human operator. Work autonomously — never ask questions, never wait for user input, never use the question tool. Make your best judgment and proceed independently.",
|
|
229
|
-
"",
|
|
230
|
-
isFilePath(name) ? `Read and use the .agents/skills/${name} skill.` : `Use the ${name} skill.`
|
|
231
|
-
];
|
|
232
|
-
if (args && Object.keys(args).length > 0) parts.push(`\nArguments:\n${JSON.stringify(args, null, 2)}`);
|
|
233
|
-
if (schema) {
|
|
234
|
-
const { $schema: _, ...schemaWithoutMeta } = toJsonSchema(schema, { errorMode: "ignore" });
|
|
235
|
-
parts.push("\nWhen complete, you MUST output your result between these exact delimiters conforming to this schema:", "```json", JSON.stringify(schemaWithoutMeta, null, 2), "```", "", "Example: (Object)", "---RESULT_START---", "{\"key\": \"value\"}", "---RESULT_END---", "", "Example: (String)", "---RESULT_START---", "Hello, world!", "---RESULT_END---");
|
|
236
|
-
}
|
|
237
|
-
return parts.join("\n");
|
|
238
|
-
}
|
|
239
|
-
|
|
240
265
|
//#endregion
|
|
241
266
|
//#region src/result.ts
|
|
242
267
|
/**
|
|
@@ -307,26 +332,29 @@ const MAX_EMPTY_POLLS = 60;
|
|
|
307
332
|
/** Max time to poll before timing out (ms) - 45 minutes. */
|
|
308
333
|
const MAX_POLL_TIME = 2700 * 1e3;
|
|
309
334
|
/**
|
|
310
|
-
*
|
|
335
|
+
* Low-level primitive: send a fully-formed prompt to OpenCode, poll until
|
|
336
|
+
* idle, and optionally extract a typed result.
|
|
337
|
+
*
|
|
338
|
+
* Both `flu.prompt()` and `flu.skill()` delegate to this function after
|
|
339
|
+
* constructing their own prompt text.
|
|
311
340
|
*/
|
|
312
|
-
async function
|
|
313
|
-
const {
|
|
314
|
-
|
|
315
|
-
console.log(`[flue]
|
|
316
|
-
console.log(`[flue] skill("${name}"): creating session`);
|
|
341
|
+
async function runPrompt(client, workdir, label, prompt, options) {
|
|
342
|
+
const { result: schema, model } = options ?? {};
|
|
343
|
+
console.log(`[flue] ${label}: starting`);
|
|
344
|
+
console.log(`[flue] ${label}: creating session`);
|
|
317
345
|
const session = await client.session.create({
|
|
318
|
-
body: { title:
|
|
346
|
+
body: { title: label },
|
|
319
347
|
query: { directory: workdir }
|
|
320
348
|
});
|
|
321
|
-
console.log(`[flue]
|
|
349
|
+
console.log(`[flue] ${label}: session created`, {
|
|
322
350
|
hasData: !!session.data,
|
|
323
351
|
sessionId: session.data?.id,
|
|
324
352
|
error: session.error
|
|
325
353
|
});
|
|
326
|
-
if (!session.data) throw new Error(`Failed to create OpenCode session for
|
|
354
|
+
if (!session.data) throw new Error(`Failed to create OpenCode session for "${label}".`);
|
|
327
355
|
const sessionId = session.data.id;
|
|
328
356
|
const promptStart = Date.now();
|
|
329
|
-
console.log(`[flue]
|
|
357
|
+
console.log(`[flue] ${label}: sending prompt async`);
|
|
330
358
|
const asyncResult = await client.session.promptAsync({
|
|
331
359
|
path: { id: sessionId },
|
|
332
360
|
query: { directory: workdir },
|
|
@@ -338,31 +366,43 @@ async function runSkill(client, workdir, name, options) {
|
|
|
338
366
|
}]
|
|
339
367
|
}
|
|
340
368
|
});
|
|
341
|
-
console.log(`[flue]
|
|
369
|
+
console.log(`[flue] ${label}: prompt sent`, {
|
|
342
370
|
hasError: !!asyncResult.error,
|
|
343
371
|
error: asyncResult.error,
|
|
344
372
|
data: asyncResult.data
|
|
345
373
|
});
|
|
346
|
-
if (asyncResult.error) throw new Error(`Failed to send prompt for
|
|
347
|
-
await confirmSessionStarted(client, sessionId, workdir,
|
|
348
|
-
console.log(`[flue]
|
|
349
|
-
const parts = await pollUntilIdle(client, sessionId, workdir,
|
|
374
|
+
if (asyncResult.error) throw new Error(`Failed to send prompt for "${label}" (session ${sessionId}): ${JSON.stringify(asyncResult.error)}`);
|
|
375
|
+
await confirmSessionStarted(client, sessionId, workdir, label);
|
|
376
|
+
console.log(`[flue] ${label}: starting polling`);
|
|
377
|
+
const parts = await pollUntilIdle(client, sessionId, workdir, label, promptStart);
|
|
350
378
|
const promptElapsed = ((Date.now() - promptStart) / 1e3).toFixed(1);
|
|
351
|
-
console.log(`[flue]
|
|
379
|
+
console.log(`[flue] ${label}: completed (${promptElapsed}s)`);
|
|
352
380
|
if (!schema) return;
|
|
353
381
|
return extractResult(parts, schema, sessionId);
|
|
354
382
|
}
|
|
355
383
|
/**
|
|
384
|
+
* Run a named skill: builds the skill prompt from the name + args + schema,
|
|
385
|
+
* then delegates to runPrompt().
|
|
386
|
+
*/
|
|
387
|
+
async function runSkill(client, workdir, name, options) {
|
|
388
|
+
const { args, result: schema, model } = options ?? {};
|
|
389
|
+
const prompt = buildSkillPrompt(name, args, schema);
|
|
390
|
+
return runPrompt(client, workdir, `skill("${name}")`, prompt, {
|
|
391
|
+
result: schema,
|
|
392
|
+
model
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
356
396
|
* After promptAsync, confirm that OpenCode actually started processing the session.
|
|
357
397
|
* Polls quickly (1s) to detect the session appearing as "busy" or a user message being recorded.
|
|
358
398
|
* Fails fast (~15s) instead of letting the poll loop run for 5 minutes.
|
|
359
399
|
*/
|
|
360
|
-
async function confirmSessionStarted(client, sessionId, workdir,
|
|
400
|
+
async function confirmSessionStarted(client, sessionId, workdir, label) {
|
|
361
401
|
const maxAttempts = 15;
|
|
362
402
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
363
403
|
await sleep(1e3);
|
|
364
404
|
if (((await client.session.status({ query: { directory: workdir } })).data?.[sessionId])?.type === "busy") {
|
|
365
|
-
console.log(`[flue]
|
|
405
|
+
console.log(`[flue] ${label}: session confirmed running`);
|
|
366
406
|
return;
|
|
367
407
|
}
|
|
368
408
|
const messages = (await client.session.messages({
|
|
@@ -370,20 +410,20 @@ async function confirmSessionStarted(client, sessionId, workdir, skillName) {
|
|
|
370
410
|
query: { directory: workdir }
|
|
371
411
|
})).data;
|
|
372
412
|
if (messages && messages.length > 0) {
|
|
373
|
-
console.log(`[flue]
|
|
413
|
+
console.log(`[flue] ${label}: session confirmed (${messages.length} messages)`);
|
|
374
414
|
return;
|
|
375
415
|
}
|
|
376
416
|
}
|
|
377
|
-
throw new Error(`
|
|
417
|
+
throw new Error(`"${label}" failed to start: session ${sessionId} has no messages after 15s.\nThe prompt was accepted but OpenCode never began processing it.\nThis usually means no model is configured. Pass --model to the flue CLI or set "model" in opencode.json.`);
|
|
378
418
|
}
|
|
379
|
-
async function pollUntilIdle(client, sessionId, workdir,
|
|
419
|
+
async function pollUntilIdle(client, sessionId, workdir, label, startTime) {
|
|
380
420
|
let emptyPolls = 0;
|
|
381
421
|
let pollCount = 0;
|
|
382
422
|
for (;;) {
|
|
383
423
|
await sleep(POLL_INTERVAL);
|
|
384
424
|
pollCount++;
|
|
385
425
|
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(0);
|
|
386
|
-
if (Date.now() - startTime > MAX_POLL_TIME) throw new Error(`
|
|
426
|
+
if (Date.now() - startTime > MAX_POLL_TIME) throw new Error(`"${label}" timed out after ${elapsed}s. Session never went idle. This may indicate a stuck session or OpenCode bug.`);
|
|
387
427
|
const statusResult = await client.session.status({ query: { directory: workdir } });
|
|
388
428
|
const sessionStatus = statusResult.data?.[sessionId];
|
|
389
429
|
if (!sessionStatus || sessionStatus.type === "idle") {
|
|
@@ -391,31 +431,31 @@ async function pollUntilIdle(client, sessionId, workdir, skillName, startTime) {
|
|
|
391
431
|
if (parts.length === 0) {
|
|
392
432
|
emptyPolls++;
|
|
393
433
|
if (emptyPolls % 12 === 0) {
|
|
394
|
-
console.log(`[flue]
|
|
434
|
+
console.log(`[flue] ${label}: status result: ${JSON.stringify({
|
|
395
435
|
hasData: !!statusResult.data,
|
|
396
436
|
sessionIds: statusResult.data ? Object.keys(statusResult.data) : [],
|
|
397
437
|
error: statusResult.error
|
|
398
438
|
})}`);
|
|
399
|
-
console.log(`[flue]
|
|
439
|
+
console.log(`[flue] ${label}: sessionStatus for ${sessionId}: ${JSON.stringify(sessionStatus)}`);
|
|
400
440
|
}
|
|
401
441
|
if (emptyPolls >= MAX_EMPTY_POLLS) {
|
|
402
442
|
const allMessages = await client.session.messages({
|
|
403
443
|
path: { id: sessionId },
|
|
404
444
|
query: { directory: workdir }
|
|
405
445
|
});
|
|
406
|
-
console.error(`[flue]
|
|
446
|
+
console.error(`[flue] ${label}: TIMEOUT DIAGNOSTICS`, JSON.stringify({
|
|
407
447
|
sessionId,
|
|
408
448
|
statusData: statusResult.data,
|
|
409
449
|
messageCount: Array.isArray(allMessages.data) ? allMessages.data.length : 0,
|
|
410
450
|
messages: allMessages.data
|
|
411
451
|
}, null, 2));
|
|
412
|
-
throw new Error(`
|
|
452
|
+
throw new Error(`"${label}" produced no output after ${elapsed}s and ${emptyPolls} empty polls. The agent may have failed to start — check model ID and API key.`);
|
|
413
453
|
}
|
|
414
454
|
continue;
|
|
415
455
|
}
|
|
416
456
|
return parts;
|
|
417
457
|
}
|
|
418
|
-
if (pollCount % 12 === 0) console.log(`[flue]
|
|
458
|
+
if (pollCount % 12 === 0) console.log(`[flue] ${label}: running (${elapsed}s)`);
|
|
419
459
|
}
|
|
420
460
|
}
|
|
421
461
|
/**
|
|
@@ -471,12 +511,19 @@ var Flue = class {
|
|
|
471
511
|
return runSkill(this.client, this.workdir, name, mergedOptions);
|
|
472
512
|
}
|
|
473
513
|
async prompt(promptText, options) {
|
|
474
|
-
const
|
|
514
|
+
const schema = options?.result;
|
|
515
|
+
const parts = [
|
|
516
|
+
HEADLESS_PREAMBLE,
|
|
517
|
+
"",
|
|
518
|
+
promptText
|
|
519
|
+
];
|
|
520
|
+
if (schema) parts.push(buildResultInstructions(schema));
|
|
521
|
+
const fullPrompt = parts.join("\n");
|
|
522
|
+
const label = `prompt("${promptText.length > 40 ? promptText.slice(0, 40) + "…" : promptText}")`;
|
|
523
|
+
return runPrompt(this.client, this.workdir, label, fullPrompt, {
|
|
475
524
|
result: options?.result,
|
|
476
|
-
model: options?.model ?? this.model
|
|
477
|
-
|
|
478
|
-
};
|
|
479
|
-
return runSkill(this.client, this.workdir, "__inline__", mergedOptions);
|
|
525
|
+
model: options?.model ?? this.model
|
|
526
|
+
});
|
|
480
527
|
}
|
|
481
528
|
/** Execute a shell command with scoped environment variables. */
|
|
482
529
|
async shell(command, options) {
|