@2kw/ai 6.4.0-dev.7 → 6.4.0-dev.8

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.
@@ -1,9 +1,8 @@
1
1
  import { Command } from "commander";
2
2
  import { readFileSync, writeFileSync } from "node:fs";
3
3
  import chalk from "chalk";
4
- import { getClient, resolveAuthHeader, runAction } from "../lib/client.js";
5
- import { resolveConfig, isJsonOutput } from "../lib/config.js";
6
- import { BackboneApiError } from "../lib/errors.js";
4
+ import { getClient, runAction } from "../lib/client.js";
5
+ import { isJsonOutput } from "../lib/config.js";
7
6
  import { formatDetail, withSpinner } from "../lib/output.js";
8
7
  import { fileToBlob, getMimeType } from "../lib/multipart.js";
9
8
  function mapFormat(format) {
@@ -136,34 +135,19 @@ function buildFormData(paths, opts) {
136
135
  formData.append("options", new Blob([JSON.stringify(pipelineOptions)], { type: "application/json" }));
137
136
  return formData;
138
137
  }
138
+ /**
139
+ * POST the multipart body through the typed client. The generated type describes `files` as string[],
140
+ * which FormData cannot match, so the placeholder body satisfies the type and bodySerializer sends the
141
+ * real parts; the client's auth and error middleware apply.
142
+ */
139
143
  async function multipartConvert(command, formData, endpoint, pipeline) {
140
- const config = resolveConfig(command);
141
- const authHeader = await resolveAuthHeader(config);
142
- const baseUrl = config.baseUrl.replace(/\/+$/, "");
143
- const url = pipeline
144
- ? `${baseUrl}${endpoint}?pipeline=${encodeURIComponent(pipeline)}`
145
- : `${baseUrl}${endpoint}`;
146
- const res = await fetch(url, {
147
- method: "POST",
148
- headers: { Authorization: authHeader },
149
- body: formData,
144
+ const client = getClient(command);
145
+ const { data } = await client.POST(endpoint, {
146
+ params: { query: { pipeline } },
147
+ body: { files: [] },
148
+ bodySerializer: () => formData,
150
149
  });
151
- if (!res.ok) {
152
- let body;
153
- try {
154
- body = await res.json();
155
- }
156
- catch {
157
- body = {
158
- error: res.statusText,
159
- message: `HTTP ${res.status}: ${res.statusText}`,
160
- status: res.status,
161
- timestamp: new Date().toISOString(),
162
- };
163
- }
164
- throw new BackboneApiError(body);
165
- }
166
- return (await res.json());
150
+ return data;
167
151
  }
168
152
  export function makeConvertCommand() {
169
153
  const cmd = new Command("convert").description("Convert documents");
@@ -149,18 +149,16 @@ export function makeSkillsCommand() {
149
149
  }
150
150
  ({ blob, filename } = fileToBlob(path));
151
151
  }
152
- const config = resolveConfig(command);
153
- const authHeader = await resolveAuthHeader(config);
154
152
  const formData = new FormData();
155
153
  formData.append("file", blob, filename);
154
+ const client = getClient(command);
156
155
  const data = await withSpinner("Importing skill...", async () => {
157
- const res = await fetch(`${config.baseUrl.replace(/\/+$/, "")}/v1/skills/import`, {
158
- method: "POST",
159
- headers: { Authorization: authHeader },
160
- body: formData,
156
+ // Placeholder body for the generated type; bodySerializer sends the real part.
157
+ const result = await client.POST("/v1/skills/import", {
158
+ body: { file: "" },
159
+ bodySerializer: () => formData,
161
160
  });
162
- await checkResponse(res);
163
- return res.json();
161
+ return result.data;
164
162
  });
165
163
  formatDetail(data, command);
166
164
  });
@@ -1,10 +1,9 @@
1
1
  import { Command } from "commander";
2
- import { resolveAuthHeader } from "../lib/client.js";
3
- import { resolveConfig, isJsonOutput } from "../lib/config.js";
2
+ import { getClient } from "../lib/client.js";
3
+ import { isJsonOutput } from "../lib/config.js";
4
4
  import { handleError } from "../lib/errors.js";
5
5
  import { formatDetail, withSpinner } from "../lib/output.js";
6
6
  import { fileToBlob } from "../lib/multipart.js";
7
- import { BackboneApiError } from "../lib/errors.js";
8
7
  export function makeTranscribeCommand() {
9
8
  const cmd = new Command("transcribe")
10
9
  .description("Transcribe an audio file")
@@ -17,8 +16,6 @@ export function makeTranscribeCommand() {
17
16
  .action(async (file, opts, command) => {
18
17
  const json = isJsonOutput(command);
19
18
  try {
20
- const config = resolveConfig(command);
21
- const authHeader = await resolveAuthHeader(config);
22
19
  const { blob, filename } = fileToBlob(file);
23
20
  const formData = new FormData();
24
21
  formData.append("file", blob, filename);
@@ -31,30 +28,15 @@ export function makeTranscribeCommand() {
31
28
  formData.append("response_format", opts.format);
32
29
  if (opts.temperature)
33
30
  formData.append("temperature", opts.temperature);
34
- const baseUrl = config.baseUrl.replace(/\/+$/, "");
35
- const url = `${baseUrl}/v1/audio/transcriptions`;
31
+ const client = getClient(command);
36
32
  const data = await withSpinner("Transcribing...", async () => {
37
- const res = await fetch(url, {
38
- method: "POST",
39
- headers: { Authorization: authHeader },
40
- body: formData,
33
+ // The generated type describes "file" as a binary string, which FormData cannot match;
34
+ // the placeholder satisfies it and bodySerializer sends the real parts.
35
+ const result = await client.POST("/v1/audio/transcriptions", {
36
+ body: { file: "", model: opts.model },
37
+ bodySerializer: () => formData,
41
38
  });
42
- if (!res.ok) {
43
- let body;
44
- try {
45
- body = await res.json();
46
- }
47
- catch {
48
- body = {
49
- error: res.statusText,
50
- message: `HTTP ${res.status}: ${res.statusText}`,
51
- status: res.status,
52
- timestamp: new Date().toISOString(),
53
- };
54
- }
55
- throw new BackboneApiError(body);
56
- }
57
- return res.json();
39
+ return result.data;
58
40
  });
59
41
  formatDetail(data, command);
60
42
  }
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import { DEFAULT_CHAT_URL } from "./config.js";
3
3
  import { connectCallIds, connectionsPhrase, pendingConnectionsOf } from "./connect-pause.js";
4
4
  import { CliUsageError } from "./errors.js";
5
+ import { previewIsIncomplete, previewLines } from "./skills-apply-preview.js";
5
6
  export const CONVERSATION_MODES = ["plan", "ask", "auto"];
6
7
  /** `--mode` help text, in the words of the embedded chat's mode chip (#655). */
7
8
  export const MODE_OPTION_HELP = "Conversation mode: plan (read-only), ask (no automatic approver; calls that need approval wait for you), " +
@@ -119,9 +120,26 @@ function relayDecideCommand(ref, responseId, calls, withApprovals) {
119
120
  return (`2kw agents decide ${shellQuote(ref)} --response ${shellQuote(responseId)}${outputs.join("")}` +
120
121
  (withApprovals ? " --approve-all" : ""));
121
122
  }
123
+ /**
124
+ * A connector tool's open approval, decoded from the standard `mcp_approval_request` item (#949). The item
125
+ * is the MCP twin of `backbone:approval_request` (#805): it names the server and the tool, carries no call
126
+ * id, policy class or status, and exists only while the decision is open, so its id doubles as the call id
127
+ * and the tool is named as the approval row stores it, `mcp__<server_label>__<tool>`.
128
+ */
129
+ function connectorApproval(i) {
130
+ return {
131
+ approvalId: String(i.id),
132
+ callId: String(i.id),
133
+ tool: `mcp__${String(i.server_label)}__${String(i.name)}`,
134
+ arguments: parseArguments(i.arguments),
135
+ policyClass: "unknown",
136
+ reason: null,
137
+ preview: null,
138
+ };
139
+ }
122
140
  /**
123
141
  * Output item types the envelope decodes, or that never hold a run: text, reasoning and finished
124
- * connector calls. A pause on anything else (a connector approval's `mcp_approval_request`) is named.
142
+ * connector calls. A pause on anything else is named.
125
143
  * `backbone:tool_image` is an image a finished `ViewImage` call attached (#1244): it never holds a run.
126
144
  */
127
145
  const DECODED_ITEM_TYPES = new Set([
@@ -132,6 +150,7 @@ const DECODED_ITEM_TYPES = new Set([
132
150
  "backbone:approval_request",
133
151
  "backbone:connector_auth_request",
134
152
  "backbone:tool_image",
153
+ "mcp_approval_request",
135
154
  "mcp_call",
136
155
  "mcp_list_tools",
137
156
  ]);
@@ -155,18 +174,21 @@ export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
155
174
  String(i.call_id),
156
175
  i.status === "incomplete" ? "incomplete" : "completed",
157
176
  ]));
158
- const pendingApprovals = output
159
- // Only an open request is pending; judge- or grant-decided requests (#634, #629) carry another status.
160
- .filter((i) => i.type === "backbone:approval_request" && (i.status === undefined || i.status === "in_progress"))
161
- .map((i) => ({
162
- approvalId: String(i.id),
163
- callId: String(i.call_id),
164
- tool: String(i.tool),
165
- arguments: parseArguments(i.arguments),
166
- policyClass: String(i.policy_class),
167
- reason: typeof i.reason === "string" && i.reason ? i.reason : null,
168
- preview: i.preview && typeof i.preview === "object" && !Array.isArray(i.preview) ? i.preview : null,
169
- }));
177
+ const pendingApprovals = [
178
+ ...output
179
+ // Only an open request is pending; judge- or grant-decided requests (#634, #629) carry another status.
180
+ .filter((i) => i.type === "backbone:approval_request" && (i.status === undefined || i.status === "in_progress"))
181
+ .map((i) => ({
182
+ approvalId: String(i.id),
183
+ callId: String(i.call_id),
184
+ tool: String(i.tool),
185
+ arguments: parseArguments(i.arguments),
186
+ policyClass: String(i.policy_class),
187
+ reason: typeof i.reason === "string" && i.reason ? i.reason : null,
188
+ preview: i.preview && typeof i.preview === "object" && !Array.isArray(i.preview) ? i.preview : null,
189
+ })),
190
+ ...output.filter((i) => i.type === "mcp_approval_request").map(connectorApproval),
191
+ ];
170
192
  // The one decoding rule (#1086): only an open request with its id, call id, label and host.
171
193
  const connectRequests = pendingConnectionsOf(output);
172
194
  const pendingConnections = connectRequests.map(({ serverLabel, host, reason, destinations }) => ({
@@ -223,6 +245,13 @@ export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
223
245
  : decide;
224
246
  }
225
247
  }
248
+ else if (status === "requires_tool_output" && pendingConnections.length > 0 && pendingApprovals.length > 0 && ref && responseId) {
249
+ // A continuation must decide every pending approval (400 incomplete_tool_outputs otherwise), so
250
+ // `--continue` would fail; decide answers them and the engine re-checks access itself (#949 review).
251
+ next =
252
+ `${connectionsSentence(pendingConnections)} in ${chatUrl}/connectors${THEN_RUN}` +
253
+ `2kw agents decide ${shellQuote(ref)} --response ${shellQuote(responseId)} --approve-all`;
254
+ }
226
255
  else if (status === "requires_tool_output" && pendingConnections.length > 0 && responseId) {
227
256
  // `agents run --continue` needs no input: the continuation re-checks access itself.
228
257
  const resume = ref
@@ -271,8 +300,15 @@ export function printRunText(env) {
271
300
  console.log(chalk.dim(` tools: ${env.toolCalls.map((c) => `${s(c.tool)} ${c.status === "incomplete" ? "✗" : "✓"}`).join(" ")}`));
272
301
  const printApprovals = () => env.pendingApprovals.forEach((a, i) => {
273
302
  console.log(` ${i + 1}. ${s(a.tool)} [${s(a.policyClass)}] ${s(a.approvalId)}`);
274
- // JSON.stringify escapes C0 controls but leaves DEL and C1 raw.
275
- console.log(chalk.dim(` ${s(JSON.stringify(a.arguments))}`));
303
+ // A SkillsApply change reads as its diff (#1028), like the interactive prompt; an incomplete
304
+ // preview keeps the arguments, the one place a member can read what would persist.
305
+ if (a.preview)
306
+ for (const line of previewLines(a.preview))
307
+ console.log(chalk.dim(` ${s(line)}`));
308
+ if (!a.preview || previewIsIncomplete(a.preview)) {
309
+ // JSON.stringify escapes C0 controls but leaves DEL and C1 raw.
310
+ console.log(chalk.dim(` ${s(JSON.stringify(a.arguments))}`));
311
+ }
276
312
  if (a.reason)
277
313
  console.log(chalk.dim(` reason: ${s(a.reason)}`));
278
314
  });
@@ -52,7 +52,9 @@ export async function promptApprovals(pending, ask, write = (line) => console.er
52
52
  const answers = new Map();
53
53
  const s = stripControl;
54
54
  for (const [index, a] of pending.entries()) {
55
- const destructive = a.policyClass.toLowerCase() === "destructive";
55
+ // A connector approval carries no policy class (#949): "unknown" may be destructive, so it is never
56
+ // offered a standing grant here; the server drops one for a destructive row anyway.
57
+ const destructive = ["destructive", "unknown"].includes(a.policyClass.toLowerCase());
56
58
  write(chalk.yellow(`\nApproval ${index + 1}/${pending.length}: ${s(a.tool)} [${s(a.policyClass)}]`));
57
59
  // JSON.stringify escapes C0 controls but leaves DEL and C1 raw.
58
60
  const request = () => write(chalk.dim(s(JSON.stringify(a.arguments, null, 2))));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2kw/ai",
3
- "version": "6.4.0-dev.7",
3
+ "version": "6.4.0-dev.8",
4
4
  "description": "CLI for 2kw.ai — schema-driven document extraction, an OpenAI-compatible EU LLM gateway, transcription, prompts, datasets, and experiments from your terminal or agentic workflows. Ships as 2kw, backbone, and bb.",
5
5
  "keywords": [
6
6
  "cli",