@flue/client 0.0.15 → 0.0.17

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 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
@@ -90,12 +90,12 @@ function transformEvent(raw) {
90
90
  return null;
91
91
  }
92
92
  if (part.type === "text") {
93
- const delta = raw.properties?.delta;
94
- if (delta) return {
93
+ const text = raw.properties?.delta ?? part.text;
94
+ if (text) return {
95
95
  timestamp: now,
96
96
  sessionId,
97
97
  type: "text",
98
- text: delta
98
+ text
99
99
  };
100
100
  return null;
101
101
  }
@@ -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 and use the .agents/skills/${name} skill.` : `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
- * Run a named skill via the OpenCode client and optionally extract a typed result.
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 runSkill(client, workdir, name, options) {
313
- const { args, result: schema, model, prompt: promptOverride } = options ?? {};
314
- const prompt = promptOverride ?? buildSkillPrompt(name, args, schema);
315
- console.log(`[flue] skill("${name}"): starting`);
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: name },
346
+ body: { title: label },
319
347
  query: { directory: workdir }
320
348
  });
321
- console.log(`[flue] skill("${name}"): session created`, {
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 skill "${name}".`);
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] skill("${name}"): sending prompt async`);
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] skill("${name}"): prompt sent`, {
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 skill "${name}" (session ${sessionId}): ${JSON.stringify(asyncResult.error)}`);
347
- await confirmSessionStarted(client, sessionId, workdir, name);
348
- console.log(`[flue] skill("${name}"): starting polling`);
349
- const parts = await pollUntilIdle(client, sessionId, workdir, name, promptStart);
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] skill("${name}"): completed (${promptElapsed}s)`);
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, skillName) {
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] skill("${skillName}"): session confirmed running`);
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] skill("${skillName}"): session confirmed (${messages.length} messages)`);
413
+ console.log(`[flue] ${label}: session confirmed (${messages.length} messages)`);
374
414
  return;
375
415
  }
376
416
  }
377
- throw new Error(`Skill "${skillName}" 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.`);
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, skillName, startTime) {
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(`Skill "${skillName}" timed out after ${elapsed}s. Session never went idle. This may indicate a stuck session or OpenCode bug.`);
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] skill("${skillName}"): status result: ${JSON.stringify({
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] skill("${skillName}"): sessionStatus for ${sessionId}: ${JSON.stringify(sessionStatus)}`);
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] skill("${skillName}"): TIMEOUT DIAGNOSTICS`, JSON.stringify({
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(`Skill "${skillName}" produced no output after ${elapsed}s and ${emptyPolls} empty polls. The agent may have failed to start — check model ID and API key.`);
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] skill("${skillName}"): running (${elapsed}s)`);
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 mergedOptions = {
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
- prompt: promptText
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flue/client",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "exports": {