@2kw/ai 6.3.0-dev.3 → 6.3.0-dev.31

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/README.md CHANGED
@@ -88,6 +88,8 @@ kubectl-style contexts switch between organizations and environments:
88
88
  2kw context create staging # Create a new context
89
89
  ```
90
90
 
91
+ `2kw config set memory true` makes an API-key context send `X-Backbone-Memory: enabled` on `ai respond` and `agents run` calls, which opts those runs into agent memory; `AI_2KW_MEMORY=true|false` overrides it per shell. Runs from a browser-login (session) context are always eligible for agent memory regardless of this setting, so `memory false` only affects API-key contexts.
92
+
91
93
  ## Commands
92
94
 
93
95
  | Command | Description |
@@ -106,6 +108,7 @@ kubectl-style contexts switch between organizations and environments:
106
108
  | `evaluators` | Manage evaluator templates |
107
109
  | `scores` | Record and inspect quality scores |
108
110
  | `tracing` | Inspect request traces and tracing settings |
111
+ | `memory` | Your agent memory files (list, read, write, delete, forget); member totals and erasure for admins |
109
112
  | `providers` | Manage BYOK AI providers |
110
113
  | `analytics` | Usage analytics: spend, quality, providers, errors |
111
114
  | `billing` | Check subscription tier and usage limits |
@@ -1,6 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { getClient, runAction } from "../lib/client.js";
3
- import { formatPage, formatDetail, formatSuccess } from "../lib/output.js";
3
+ import { formatPage, formatDetail, formatList, formatSuccess } from "../lib/output.js";
4
4
  import { addPaginationOptions, paginationParams } from "../lib/pagination.js";
5
5
  import { makeAgentVersionsCommand } from "./agent-versions.js";
6
6
  import { makeAgentLabelsCommand } from "./agent-labels.js";
@@ -172,6 +172,20 @@ export function makeAgentsCommand() {
172
172
  });
173
173
  });
174
174
  cmd.addCommand(toolCatalogs);
175
+ // The skills the latest published version binds (#827) -- readable by a chat-only USER key,
176
+ // which cannot read the version itself. No published version answers [] rather than 404.
177
+ const skills = new Command("skills").description("List the skills an agent's latest published version binds");
178
+ skills.requiredOption("--agent <id>", "Agent ID");
179
+ skills.action(async (opts, command) => {
180
+ await runAction(command, async () => {
181
+ const client = getClient(command);
182
+ const { data } = await client.GET("/v1/agents/{agentId}/skills", {
183
+ params: { path: { agentId: opts.agent } },
184
+ });
185
+ formatList(data, command, ["name", "description", "versionNumber", "ref", "pluginName"]);
186
+ });
187
+ });
188
+ cmd.addCommand(skills);
175
189
  cmd.addCommand(makeAgentRunCommand());
176
190
  cmd.addCommand(makeAgentDecideCommand());
177
191
  cmd.addCommand(makeAgentApplyCommand());
@@ -160,7 +160,12 @@ async function manualLogin(opts) {
160
160
  console.error(chalk.red("API key is required."));
161
161
  process.exit(1);
162
162
  }
163
- setContext(contextName, { apiKey, baseUrl });
163
+ // Carry the memory opt-in over: re-entering a key should not silently turn it off (#721).
164
+ setContext(contextName, {
165
+ apiKey,
166
+ baseUrl,
167
+ ...(currentCtx?.memory !== undefined ? { memory: currentCtx.memory } : {}),
168
+ });
164
169
  setActiveContext(contextName);
165
170
  console.log(chalk.green("Credentials saved successfully."));
166
171
  console.log(chalk.dim(`Config stored at: ${store.path}`));
@@ -1,5 +1,5 @@
1
1
  import { Command } from "commander";
2
- declare const ALLOWED_KEYS: readonly ["apiKey", "baseUrl"];
2
+ declare const ALLOWED_KEYS: readonly ["apiKey", "baseUrl", "memory"];
3
3
  type ConfigKey = (typeof ALLOWED_KEYS)[number];
4
4
  /**
5
5
  * Write one config key onto the active context.
@@ -2,7 +2,7 @@ import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import { store, isJsonOutput, getActiveContext, getActiveContextName, getContextCount, setContext, DEFAULT_BASE_URL, } from "../lib/config.js";
4
4
  import { maskApiKey } from "../lib/redact.js";
5
- const ALLOWED_KEYS = ["apiKey", "baseUrl"];
5
+ const ALLOWED_KEYS = ["apiKey", "baseUrl", "memory"];
6
6
  const KEY_ALIASES = {
7
7
  "api-key": "apiKey",
8
8
  "base-url": "baseUrl",
@@ -43,6 +43,14 @@ export function applyConfigSet(key, value) {
43
43
  setContext(contextName, entry);
44
44
  return { clearedSession };
45
45
  }
46
+ if (key === "memory") {
47
+ if (value !== "true" && value !== "false") {
48
+ throw new Error('memory takes true or false');
49
+ }
50
+ entry.memory = value === "true";
51
+ setContext(contextName, entry);
52
+ return { clearedSession: false };
53
+ }
46
54
  entry[key] = value;
47
55
  setContext(contextName, entry);
48
56
  return { clearedSession: false };
@@ -82,7 +90,12 @@ export function makeConfigCommand() {
82
90
  const validKey = validateKey(key);
83
91
  const ctx = getActiveContext();
84
92
  const raw = ctx?.[validKey];
85
- const value = raw && validKey === "apiKey" ? maskApiKey(raw) : raw;
93
+ // A stored "" apiKey stays "" (shown as "(not set)"); only a real key is masked.
94
+ const value = raw === undefined
95
+ ? undefined
96
+ : validKey === "apiKey" && typeof raw === "string"
97
+ ? raw && maskApiKey(raw)
98
+ : String(raw);
86
99
  if (isJsonOutput(command)) {
87
100
  console.log(JSON.stringify({ key: validKey, value: value ?? null }));
88
101
  }
@@ -103,6 +116,7 @@ export function makeConfigCommand() {
103
116
  const values = {
104
117
  apiKey: ctx?.apiKey ? maskApiKey(ctx.apiKey) : undefined,
105
118
  baseUrl: ctx?.baseUrl,
119
+ memory: ctx?.memory === undefined ? undefined : String(ctx.memory),
106
120
  };
107
121
  if (isJsonOutput(command)) {
108
122
  console.log(JSON.stringify(values, null, 2));
@@ -0,0 +1,10 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * Manage the connector settings an organization decides centrally (#805).
4
+ *
5
+ * Only the approved public hosts live here: which MCP servers this organization's agents
6
+ * may reach directly. The connectors themselves are declared per agent, in the agent
7
+ * version's `tools`.
8
+ */
9
+ export declare function makeConnectorsCommand(): Command;
10
+ //# sourceMappingURL=connectors.d.ts.map
@@ -0,0 +1,97 @@
1
+ import { Command } from "commander";
2
+ import { getClient, runAction } from "../lib/client.js";
3
+ import { CliUsageError } from "../lib/errors.js";
4
+ import { formatDetail, formatList, formatSuccess } from "../lib/output.js";
5
+ /**
6
+ * These three endpoints take a member sign-in only (plan ruling R10): approving a
7
+ * destination is the human gate against exfiltration, so an API key — however admin it is —
8
+ * is refused. Said in every description, because `--help` is where a reader looks before
9
+ * the 403.
10
+ */
11
+ const SESSION_ONLY = "Signed-in admins only: run 2kw auth login and sign in through the browser; an API key is refused";
12
+ /** The backend's limit, so a too-long host fails here instead of after a round trip. */
13
+ const MAX_HOST_LENGTH = 253;
14
+ const MIN_PORT = 1;
15
+ const MAX_PORT = 65535;
16
+ /**
17
+ * Parse `--port` strictly, and only when one was given.
18
+ *
19
+ * Commander's house pattern is a bare `parseInt`, which turns `--port abc` into `NaN`,
20
+ * `JSON.stringify` turns that into `null`, and the backend reads a null port as "not
21
+ * given" and approves 443. Silently approving a port nobody asked for is not a wart worth
22
+ * inheriting on a trust decision.
23
+ *
24
+ * Validated inside the action rather than as commander's option parser, so the refusal
25
+ * travels through `runAction` and exits 2 like every other usage error.
26
+ */
27
+ function parsePort(value) {
28
+ if (value === undefined) {
29
+ return undefined;
30
+ }
31
+ // Digits only: `Number` alone accepts " 8443 ", "0x20FB" and "8.443e3".
32
+ const port = /^\d{1,5}$/.test(value) ? Number(value) : NaN;
33
+ if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) {
34
+ throw new CliUsageError(`--port must be a whole number between ${MIN_PORT} and ${MAX_PORT}, not '${value}'`);
35
+ }
36
+ return port;
37
+ }
38
+ function requireHost(host) {
39
+ if (host.length > MAX_HOST_LENGTH) {
40
+ throw new CliUsageError(`A host name is at most ${MAX_HOST_LENGTH} characters`);
41
+ }
42
+ return host;
43
+ }
44
+ /**
45
+ * Manage the connector settings an organization decides centrally (#805).
46
+ *
47
+ * Only the approved public hosts live here: which MCP servers this organization's agents
48
+ * may reach directly. The connectors themselves are declared per agent, in the agent
49
+ * version's `tools`.
50
+ */
51
+ export function makeConnectorsCommand() {
52
+ const cmd = new Command("connectors").description("Manage MCP connector settings");
53
+ const publicHosts = new Command("public-hosts").description(`Approve the public MCP hosts this organization's agents may reach. ${SESSION_ONLY}`);
54
+ publicHosts
55
+ .command("list")
56
+ .description(`List the approved public MCP hosts. ${SESSION_ONLY}`)
57
+ .action(async (_opts, command) => {
58
+ await runAction(command, async () => {
59
+ const client = getClient(command);
60
+ const { data } = await client.GET("/v1/connectors/public-hosts");
61
+ formatList(data, command, [
62
+ "id",
63
+ "host",
64
+ "port",
65
+ "createdBy",
66
+ "createdAt",
67
+ ]);
68
+ });
69
+ });
70
+ publicHosts
71
+ .command("add")
72
+ .description(`Approve a host — every path and every account on it becomes reachable. ${SESSION_ONLY}`)
73
+ .argument("<host>", "Plain host name: no scheme, path, port, wildcard or IP literal")
74
+ .option("--port <n>", "Port to approve; 443 when omitted")
75
+ .action(async (host, opts, command) => {
76
+ await runAction(command, async () => {
77
+ const body = { host: requireHost(host), port: parsePort(opts.port) };
78
+ const client = getClient(command);
79
+ const { data } = await client.POST("/v1/connectors/public-hosts", { body });
80
+ formatDetail(data, command);
81
+ });
82
+ });
83
+ publicHosts
84
+ .command("remove")
85
+ .description(`Withdraw an approved host; it takes effect on the next request. ${SESSION_ONLY}`)
86
+ .argument("<id>", "Approved host ID, from `connectors public-hosts list`")
87
+ .action(async (id, _opts, command) => {
88
+ await runAction(command, async () => {
89
+ const client = getClient(command);
90
+ await client.DELETE("/v1/connectors/public-hosts/{id}", { params: { path: { id } } });
91
+ formatSuccess(`Host ${id} withdrawn.`, command);
92
+ });
93
+ });
94
+ cmd.addCommand(publicHosts);
95
+ return cmd;
96
+ }
97
+ //# sourceMappingURL=connectors.js.map
@@ -8,40 +8,22 @@ import { formatPage, formatDetail, formatSuccess, withSpinner } from "../lib/out
8
8
  import { addPaginationOptions, paginationParams } from "../lib/pagination.js";
9
9
  import { fileToBlob } from "../lib/multipart.js";
10
10
  /**
11
- * Upload a single file with purpose=agent_input|knowledge. Bypasses the typed
12
- * client the same way convert.ts and transcribe.ts do: the generated types
13
- * describe the multipart body as an opaque `file: string`, which is not
14
- * something FormData can produce a match for.
11
+ * Upload a single file with purpose=agent_input|knowledge. The generated type
12
+ * describes the "file" part as a binary string, which FormData cannot match,
13
+ * so the placeholder body satisfies the type and bodySerializer sends the
14
+ * real part.
15
15
  */
16
16
  async function uploadFile(command, filePath, purpose) {
17
- const config = resolveConfig(command);
18
- const authHeader = await resolveAuthHeader(config);
19
17
  const { blob, filename } = fileToBlob(filePath);
20
18
  const formData = new FormData();
21
19
  formData.append("file", blob, filename);
22
- const baseUrl = config.baseUrl.replace(/\/+$/, "");
23
- const url = `${baseUrl}/v1/files?purpose=${encodeURIComponent(purpose)}`;
24
- const res = await fetch(url, {
25
- method: "POST",
26
- headers: { Authorization: authHeader },
27
- body: formData,
20
+ const client = getClient(command);
21
+ const { data } = await client.POST("/v1/files", {
22
+ params: { query: { purpose } },
23
+ body: { file: "" },
24
+ bodySerializer: () => formData,
28
25
  });
29
- if (!res.ok) {
30
- let body;
31
- try {
32
- body = await res.json();
33
- }
34
- catch {
35
- body = {
36
- error: res.statusText,
37
- message: `HTTP ${res.status}: ${res.statusText}`,
38
- status: res.status,
39
- timestamp: new Date().toISOString(),
40
- };
41
- }
42
- throw new BackboneApiError(body);
43
- }
44
- return (await res.json());
26
+ return data;
45
27
  }
46
28
  /** Stream a file's bytes from /v1/files/{fileId}/content via a raw fetch. */
47
29
  async function downloadFile(command, fileId) {
@@ -1,49 +1,29 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
- import { getClient, resolveAuthHeader, runAction } from "../lib/client.js";
4
- import { resolveConfig, isJsonOutput } from "../lib/config.js";
5
- import { BackboneApiError } from "../lib/errors.js";
3
+ import { getClient, runAction } from "../lib/client.js";
4
+ import { isJsonOutput } from "../lib/config.js";
6
5
  import { formatPage, formatDetail, formatSuccess, withSpinner } from "../lib/output.js";
7
6
  import { addPaginationOptions, paginationParams } from "../lib/pagination.js";
8
7
  import { fileToBlob } from "../lib/multipart.js";
9
8
  /**
10
- * Upload one or more local files to a knowledge base. Bypasses the typed
11
- * client the same way convert.ts's multipartConvert does: springdoc mis-shapes
12
- * this endpoint's `files` parameter as a query array of binary strings, but the
13
- * controller (`KnowledgeDocumentController.upload`) actually reads it off a
14
- * multipart/form-data body with repeated "files" parts.
9
+ * Upload one or more local files to a knowledge base as repeated "files"
10
+ * parts of a multipart/form-data body. The generated type describes each part
11
+ * as a binary string, which FormData cannot match, so the placeholder body
12
+ * satisfies the type and bodySerializer sends the real parts.
15
13
  */
16
14
  async function uploadDocuments(command, knowledgeBaseId, paths) {
17
- const config = resolveConfig(command);
18
- const authHeader = await resolveAuthHeader(config);
19
15
  const formData = new FormData();
20
16
  for (const p of paths) {
21
17
  const { blob, filename } = fileToBlob(p);
22
18
  formData.append("files", blob, filename);
23
19
  }
24
- const baseUrl = config.baseUrl.replace(/\/+$/, "");
25
- const url = `${baseUrl}/v1/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents`;
26
- const res = await fetch(url, {
27
- method: "POST",
28
- headers: { Authorization: authHeader },
29
- body: formData,
20
+ const client = getClient(command);
21
+ const { data } = await client.POST("/v1/knowledge-bases/{knowledgeBaseId}/documents", {
22
+ params: { path: { knowledgeBaseId } },
23
+ body: { files: [] },
24
+ bodySerializer: () => formData,
30
25
  });
31
- if (!res.ok) {
32
- let body;
33
- try {
34
- body = await res.json();
35
- }
36
- catch {
37
- body = {
38
- error: res.statusText,
39
- message: `HTTP ${res.status}: ${res.statusText}`,
40
- status: res.status,
41
- timestamp: new Date().toISOString(),
42
- };
43
- }
44
- throw new BackboneApiError(body);
45
- }
46
- return (await res.json());
26
+ return data ?? [];
47
27
  }
48
28
  function printUploadResults(results, command) {
49
29
  if (isJsonOutput(command)) {
@@ -0,0 +1,10 @@
1
+ import { Command } from "commander";
2
+ /** The If-Match value for a version a GET returned: the quoted ETag the API expects. */
3
+ export declare function ifMatchHeader(version: string): string;
4
+ /**
5
+ * Agent memory (agent memory spec §7.1, §7.4). `ls`, `cat`, `put`, `rm` and `forget` act on
6
+ * the caller's own memory; `members` and `erase` are for ADMIN and OWNER and never show
7
+ * paths or content. Content is lossless: `cat` prints it raw, `put` sends the file as is.
8
+ */
9
+ export declare function makeMemoryCommand(): Command;
10
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1,132 @@
1
+ import { Command } from "commander";
2
+ import { readFileSync } from "node:fs";
3
+ import { getClient, runAction } from "../lib/client.js";
4
+ import { isJsonOutput } from "../lib/config.js";
5
+ import { CliUsageError } from "../lib/errors.js";
6
+ import { formatDetail, formatList, formatSuccess } from "../lib/output.js";
7
+ /** The If-Match value for a version a GET returned: the quoted ETag the API expects. */
8
+ export function ifMatchHeader(version) {
9
+ if (!/^\d{1,18}$/.test(version)) {
10
+ throw new CliUsageError(`--if-match takes the file's version, a whole number: ${version}`);
11
+ }
12
+ return `"${version}"`;
13
+ }
14
+ function requireYes(opts, what) {
15
+ if (!opts.yes) {
16
+ throw new CliUsageError(`${what} cannot be undone. Pass --yes to confirm.`);
17
+ }
18
+ }
19
+ /**
20
+ * Agent memory (agent memory spec §7.1, §7.4). `ls`, `cat`, `put`, `rm` and `forget` act on
21
+ * the caller's own memory; `members` and `erase` are for ADMIN and OWNER and never show
22
+ * paths or content. Content is lossless: `cat` prints it raw, `put` sends the file as is.
23
+ */
24
+ export function makeMemoryCommand() {
25
+ const cmd = new Command("memory").description("Your agent memory files; member totals and erasure for admins");
26
+ cmd
27
+ .command("ls")
28
+ .description("List your memory files")
29
+ .action(async (_opts, command) => {
30
+ await runAction(command, async () => {
31
+ const client = getClient(command);
32
+ const { data } = await client.GET("/v1/memories/me/files");
33
+ formatList(data, command, [
34
+ "path",
35
+ "sizeBytes",
36
+ "version",
37
+ "updatedAt",
38
+ "lastWrittenByAgentId",
39
+ ]);
40
+ });
41
+ });
42
+ cmd
43
+ .command("cat <path>")
44
+ .description("Print one of your memory files, raw (--json for the file object)")
45
+ .action(async (path, _opts, command) => {
46
+ await runAction(command, async () => {
47
+ const client = getClient(command);
48
+ const { data } = await client.GET("/v1/memories/me/file", {
49
+ params: { query: { path } },
50
+ });
51
+ if (isJsonOutput(command)) {
52
+ formatDetail(data, command);
53
+ }
54
+ else {
55
+ process.stdout.write(data.content);
56
+ }
57
+ });
58
+ });
59
+ cmd
60
+ .command("put <path>")
61
+ .description("Create or replace one of your memory files from a local UTF-8 file")
62
+ .requiredOption("--file <file>", "Local file whose content is stored")
63
+ .option("--if-match <version>", "Only replace the file if it still has this version")
64
+ .action(async (path, opts, command) => {
65
+ await runAction(command, async () => {
66
+ // An empty --if-match (e.g. an unset shell variable) must be refused, never dropped
67
+ // into an unconditional overwrite.
68
+ const headers = opts.ifMatch !== undefined ? { "If-Match": ifMatchHeader(opts.ifMatch) } : undefined;
69
+ const content = readFileSync(opts.file, "utf8");
70
+ const client = getClient(command);
71
+ const { data } = await client.PUT("/v1/memories/me/file", {
72
+ params: { query: { path } },
73
+ body: { content },
74
+ headers,
75
+ });
76
+ formatDetail(data, command);
77
+ });
78
+ });
79
+ cmd
80
+ .command("rm <path>")
81
+ .description("Delete one of your memory files, or a directory with everything below it")
82
+ .action(async (path, _opts, command) => {
83
+ await runAction(command, async () => {
84
+ const client = getClient(command);
85
+ await client.DELETE("/v1/memories/me/file", { params: { query: { path } } });
86
+ formatSuccess(`Deleted ${path}`, command);
87
+ });
88
+ });
89
+ cmd
90
+ .command("forget")
91
+ .description("Delete your whole memory (cannot be undone)")
92
+ .option("--yes", "Confirm")
93
+ .action(async (opts, command) => {
94
+ await runAction(command, async () => {
95
+ requireYes(opts, "Forgetting your whole memory");
96
+ const client = getClient(command);
97
+ await client.DELETE("/v1/memories/me");
98
+ formatSuccess("Forgot your whole memory", command);
99
+ });
100
+ });
101
+ cmd
102
+ .command("members")
103
+ .description("Memory totals per member, without paths or content (ADMIN+)")
104
+ .action(async (_opts, command) => {
105
+ await runAction(command, async () => {
106
+ const client = getClient(command);
107
+ const { data } = await client.GET("/v1/memories/users");
108
+ formatList(data, command, [
109
+ "userId",
110
+ "fileCount",
111
+ "totalBytes",
112
+ "lastUpdatedAt",
113
+ ]);
114
+ });
115
+ });
116
+ cmd
117
+ .command("erase <userId>")
118
+ .description("Erase a member's whole memory (ADMIN+, cannot be undone)")
119
+ .option("--yes", "Confirm")
120
+ .action(async (userId, opts, command) => {
121
+ await runAction(command, async () => {
122
+ requireYes(opts, "Erasing a member's memory");
123
+ const client = getClient(command);
124
+ await client.DELETE("/v1/memories/users/{userId}", {
125
+ params: { path: { userId } },
126
+ });
127
+ formatSuccess(`Erased the memory of ${userId}`, command);
128
+ });
129
+ });
130
+ return cmd;
131
+ }
132
+ //# sourceMappingURL=memory.js.map
@@ -0,0 +1,13 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * Parses the value a setting takes: a JSON object. The server checks its shape per key
4
+ * (for agents.default: {"agentId": "<id>"}); this only refuses what can never be valid.
5
+ */
6
+ export declare function parseSettingValue(raw: string): Record<string, unknown>;
7
+ /**
8
+ * Organization and member settings (agent-first chat spec §3.1, §4). Without --org every
9
+ * write targets the caller's own member tier; --org writes the organization's, which needs
10
+ * the key's write role (ADMIN or OWNER for agents.default).
11
+ */
12
+ export declare function makeSettingsCommand(): Command;
13
+ //# sourceMappingURL=settings.d.ts.map
@@ -0,0 +1,80 @@
1
+ import { Command } from "commander";
2
+ import { getClient, runAction } from "../lib/client.js";
3
+ import { CliUsageError } from "../lib/errors.js";
4
+ import { formatDetail } from "../lib/output.js";
5
+ /**
6
+ * Parses the value a setting takes: a JSON object. The server checks its shape per key
7
+ * (for agents.default: {"agentId": "<id>"}); this only refuses what can never be valid.
8
+ */
9
+ export function parseSettingValue(raw) {
10
+ let parsed;
11
+ try {
12
+ parsed = JSON.parse(raw);
13
+ }
14
+ catch {
15
+ throw new CliUsageError(`Setting value is not valid JSON: ${raw}`);
16
+ }
17
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
18
+ throw new CliUsageError('Setting value must be a JSON object, e.g. \'{"agentId":"agt_123"}\'');
19
+ }
20
+ return parsed;
21
+ }
22
+ /**
23
+ * Organization and member settings (agent-first chat spec §3.1, §4). Without --org every
24
+ * write targets the caller's own member tier; --org writes the organization's, which needs
25
+ * the key's write role (ADMIN or OWNER for agents.default).
26
+ */
27
+ export function makeSettingsCommand() {
28
+ const cmd = new Command("settings").description("Organization and member settings (e.g. agents.default)");
29
+ cmd
30
+ .command("get <key>")
31
+ .description("Show a setting: the effective value, its source and both tiers")
32
+ .action(async (key, _opts, command) => {
33
+ await runAction(command, async () => {
34
+ const client = getClient(command);
35
+ const { data } = await client.GET("/v1/settings/{key}", {
36
+ params: { path: { key } },
37
+ });
38
+ formatDetail(data, command);
39
+ });
40
+ });
41
+ cmd
42
+ .command("set <key> <json>")
43
+ .description("Set your own value, or the organization's with --org (ADMIN+)")
44
+ .option("--org", "Write the organization's value instead of your own")
45
+ .action(async (key, json, opts, command) => {
46
+ await runAction(command, async () => {
47
+ const client = getClient(command);
48
+ const body = { value: parseSettingValue(json) };
49
+ const { data } = opts.org
50
+ ? await client.PUT("/v1/settings/{key}/org", {
51
+ params: { path: { key } },
52
+ body,
53
+ })
54
+ : await client.PUT("/v1/settings/{key}/member", {
55
+ params: { path: { key } },
56
+ body,
57
+ });
58
+ formatDetail(data, command);
59
+ });
60
+ });
61
+ cmd
62
+ .command("unset <key>")
63
+ .description("Clear your own value, or the organization's with --org (ADMIN+)")
64
+ .option("--org", "Clear the organization's value instead of your own")
65
+ .action(async (key, opts, command) => {
66
+ await runAction(command, async () => {
67
+ const client = getClient(command);
68
+ const { data } = opts.org
69
+ ? await client.DELETE("/v1/settings/{key}/org", {
70
+ params: { path: { key } },
71
+ })
72
+ : await client.DELETE("/v1/settings/{key}/member", {
73
+ params: { path: { key } },
74
+ });
75
+ formatDetail(data, command);
76
+ });
77
+ });
78
+ return cmd;
79
+ }
80
+ //# sourceMappingURL=settings.js.map
package/dist/index.js CHANGED
@@ -21,6 +21,8 @@ import { makeDocsCommand } from "./commands/docs.js";
21
21
  import { makeContextCommand } from "./commands/context.js";
22
22
  import { makeExperimentsCommand } from "./commands/experiments.js";
23
23
  import { makeTracingCommand } from "./commands/tracing.js";
24
+ import { makeSettingsCommand } from "./commands/settings.js";
25
+ import { makeMemoryCommand } from "./commands/memory.js";
24
26
  import { makeEvaluatorsCommand } from "./commands/evaluators.js";
25
27
  import { makeScoresCommand } from "./commands/scores.js";
26
28
  import { makeQueuesCommand } from "./commands/queues.js";
@@ -29,6 +31,7 @@ import { makeConversationsCommand } from "./commands/conversations.js";
29
31
  import { makeKnowledgeCommand } from "./commands/knowledge.js";
30
32
  import { makeFilesCommand } from "./commands/files.js";
31
33
  import { makeInstallationsCommand } from "./commands/installations.js";
34
+ import { makeConnectorsCommand } from "./commands/connectors.js";
32
35
  import { makeRelaysCommand } from "./commands/relays.js";
33
36
  import { checkForUpdates } from "./lib/update-notifier.js";
34
37
  const updater = checkForUpdates(pkg.version);
@@ -61,11 +64,14 @@ program.addCommand(makeEvaluatorsCommand());
61
64
  program.addCommand(makeScoresCommand());
62
65
  program.addCommand(makeQueuesCommand());
63
66
  program.addCommand(makeTracingCommand());
67
+ program.addCommand(makeSettingsCommand());
68
+ program.addCommand(makeMemoryCommand());
64
69
  program.addCommand(makeAgentsCommand());
65
70
  program.addCommand(makeConversationsCommand());
66
71
  program.addCommand(makeKnowledgeCommand());
67
72
  program.addCommand(makeFilesCommand());
68
73
  program.addCommand(makeInstallationsCommand());
74
+ program.addCommand(makeConnectorsCommand());
69
75
  program.addCommand(makeRelaysCommand());
70
76
  program.addCommand(makeDocsCommand());
71
77
  program.parseAsync().then(() => updater.notify());
@@ -39,6 +39,14 @@ export declare function apiKeyAuthMiddleware(config: ApiKeyConfig): Middleware;
39
39
  * fires and cannot be replayed safely.
40
40
  */
41
41
  export declare function sessionAuthMiddleware(config: SessionConfig, deps?: SessionAuthDeps): Middleware;
42
+ /** The per-request opt-in to agent memory for API-key runs (spec §3.3, D6, D7). */
43
+ export declare const MEMORY_HEADER = "X-Backbone-Memory";
44
+ /**
45
+ * Adds `X-Backbone-Memory: enabled` to `POST …/v1/responses`, the call that starts a run
46
+ * and that also carries approval decisions (`lib/agent-decide.ts`). No other request gets
47
+ * it; the server reads it only there.
48
+ */
49
+ export declare const memoryHeaderMiddleware: Middleware;
42
50
  /**
43
51
  * The middleware stack for a resolved config, in registration order.
44
52
  *
@@ -1,5 +1,5 @@
1
1
  import createClient from "openapi-fetch";
2
- import { resolveConfig, isJsonOutput } from "./config.js";
2
+ import { resolveConfig, isJsonOutput, memoryEnabled } from "./config.js";
3
3
  import { ensureJwt } from "./auth-session.js";
4
4
  import { BackboneApiError, handleError } from "./errors.js";
5
5
  /**
@@ -37,6 +37,8 @@ export const errorMiddleware = {
37
37
  `HTTP ${response.status}: ${response.statusText}`,
38
38
  timestamp: body.timestamp ?? new Date().toISOString(),
39
39
  code: typeof nested?.code === "string" ? nested.code : undefined,
40
+ // A problem detail names its cause in `errorCode` (GlobalExceptionHandler).
41
+ errorCode: typeof body.errorCode === "string" ? body.errorCode : undefined,
40
42
  });
41
43
  },
42
44
  };
@@ -83,6 +85,21 @@ export function sessionAuthMiddleware(config, deps = {}) {
83
85
  },
84
86
  };
85
87
  }
88
+ /** The per-request opt-in to agent memory for API-key runs (spec §3.3, D6, D7). */
89
+ export const MEMORY_HEADER = "X-Backbone-Memory";
90
+ /**
91
+ * Adds `X-Backbone-Memory: enabled` to `POST …/v1/responses`, the call that starts a run
92
+ * and that also carries approval decisions (`lib/agent-decide.ts`). No other request gets
93
+ * it; the server reads it only there.
94
+ */
95
+ export const memoryHeaderMiddleware = {
96
+ onRequest({ request }) {
97
+ if (request.method === "POST" && new URL(request.url).pathname.endsWith("/v1/responses")) {
98
+ request.headers.set(MEMORY_HEADER, "enabled");
99
+ }
100
+ return request;
101
+ },
102
+ };
86
103
  /**
87
104
  * The middleware stack for a resolved config, in registration order.
88
105
  *
@@ -123,6 +140,8 @@ export function getClient(command) {
123
140
  baseUrl: config.baseUrl.replace(/\/+$/, ""),
124
141
  });
125
142
  client.use(...buildMiddleware(config));
143
+ if (memoryEnabled())
144
+ client.use(memoryHeaderMiddleware);
126
145
  return client;
127
146
  }
128
147
  /**
@@ -14,6 +14,8 @@ export interface ContextEntry {
14
14
  cachedJwt?: string;
15
15
  /** Epoch millis when cachedJwt expires. */
16
16
  cachedJwtExp?: number;
17
+ /** Send `X-Backbone-Memory: enabled` on responses calls (`2kw config set memory true`, #721). */
18
+ memory?: boolean;
17
19
  }
18
20
  export interface BackboneConfigStore {
19
21
  activeContext: string;
@@ -55,6 +57,12 @@ export { store };
55
57
  export declare function validateContextName(name: string): void;
56
58
  export declare function getActiveContextName(): string;
57
59
  export declare function getActiveContext(): ContextEntry | undefined;
60
+ /**
61
+ * Whether responses calls opt into agent memory (spec §3.3, D6). `AI_2KW_MEMORY` wins when
62
+ * set (`true` on, anything else off), so CI can switch it without touching the store;
63
+ * otherwise the active context's `memory` key decides. Off by default.
64
+ */
65
+ export declare function memoryEnabled(env?: NodeJS.ProcessEnv): boolean;
58
66
  export declare function getAllContexts(): Record<string, ContextEntry>;
59
67
  export declare function getContextCount(): number;
60
68
  export declare function setContext(name: string, entry: ContextEntry): void;
@@ -142,6 +142,17 @@ export function getActiveContext() {
142
142
  const contexts = store.get("contexts") ?? {};
143
143
  return contexts[name];
144
144
  }
145
+ /**
146
+ * Whether responses calls opt into agent memory (spec §3.3, D6). `AI_2KW_MEMORY` wins when
147
+ * set (`true` on, anything else off), so CI can switch it without touching the store;
148
+ * otherwise the active context's `memory` key decides. Off by default.
149
+ */
150
+ export function memoryEnabled(env = process.env) {
151
+ const fromEnv = env.AI_2KW_MEMORY;
152
+ if (fromEnv !== undefined && fromEnv !== "")
153
+ return fromEnv === "true";
154
+ return getActiveContext()?.memory === true;
155
+ }
145
156
  export function getAllContexts() {
146
157
  return store.get("contexts") ?? {};
147
158
  }
@@ -10,12 +10,18 @@ export interface ApiErrorBody {
10
10
  timestamp: string;
11
11
  /** Machine-readable code from an OpenAI-style gateway error, e.g. `approval_hmac_mismatch`. */
12
12
  code?: string;
13
+ /**
14
+ * Machine-readable code from a problem detail's `errorCode`, e.g. `CONNECTOR_HOST_SIGN_IN_REQUIRED`.
15
+ * Kept apart from {@link code}: that one holds lowercase gateway codes and is printed by `--json`.
16
+ */
17
+ errorCode?: string;
13
18
  }
14
19
  export declare class BackboneApiError extends Error {
15
20
  readonly status: number;
16
21
  readonly errorType: string;
17
22
  readonly timestamp: string;
18
23
  readonly code?: string;
24
+ readonly errorCode?: string;
19
25
  constructor(body: Omit<ApiErrorBody, "error"> & {
20
26
  error: string;
21
27
  });
@@ -7,6 +7,7 @@ export class BackboneApiError extends Error {
7
7
  errorType;
8
8
  timestamp;
9
9
  code;
10
+ errorCode;
10
11
  constructor(body) {
11
12
  super(body.message);
12
13
  this.name = "BackboneApiError";
@@ -14,6 +15,7 @@ export class BackboneApiError extends Error {
14
15
  this.errorType = body.error;
15
16
  this.timestamp = body.timestamp;
16
17
  this.code = body.code;
18
+ this.errorCode = body.errorCode;
17
19
  }
18
20
  }
19
21
  /**
@@ -106,6 +108,13 @@ export function hintFor(err) {
106
108
  }
107
109
  if (err.code && CODE_HINTS[err.code])
108
110
  return CODE_HINTS[err.code];
111
+ // The 403 whose cause is the credential, not the role (#805). Without this the
112
+ // generic 403 hint tells an organization admin to check a role that is already
113
+ // right, and no role would ever have fixed it. Matched on the backend's error code,
114
+ // so a reworded message cannot silently drop the hint.
115
+ if (err.errorCode === "CONNECTOR_HOST_SIGN_IN_REQUIRED") {
116
+ return "Approved hosts need a browser sign-in: run 2kw auth login. An API key cannot manage them, whatever its role.";
117
+ }
109
118
  // Anchored on the agent service's wording: prompts and schemas send the same
110
119
  // "Label '<x>' not found" prefix, and must keep the generic 404 hint.
111
120
  if (err.status === 404 && /^Label 'latest' not found (on agent|for this agent)/.test(err.message)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2kw/ai",
3
- "version": "6.3.0-dev.3",
3
+ "version": "6.3.0-dev.31",
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",