@2kw/ai 6.3.0-dev.24 → 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 |
@@ -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));
@@ -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
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ import { makeContextCommand } from "./commands/context.js";
22
22
  import { makeExperimentsCommand } from "./commands/experiments.js";
23
23
  import { makeTracingCommand } from "./commands/tracing.js";
24
24
  import { makeSettingsCommand } from "./commands/settings.js";
25
+ import { makeMemoryCommand } from "./commands/memory.js";
25
26
  import { makeEvaluatorsCommand } from "./commands/evaluators.js";
26
27
  import { makeScoresCommand } from "./commands/scores.js";
27
28
  import { makeQueuesCommand } from "./commands/queues.js";
@@ -64,6 +65,7 @@ program.addCommand(makeScoresCommand());
64
65
  program.addCommand(makeQueuesCommand());
65
66
  program.addCommand(makeTracingCommand());
66
67
  program.addCommand(makeSettingsCommand());
68
+ program.addCommand(makeMemoryCommand());
67
69
  program.addCommand(makeAgentsCommand());
68
70
  program.addCommand(makeConversationsCommand());
69
71
  program.addCommand(makeKnowledgeCommand());
@@ -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
  /**
@@ -85,6 +85,21 @@ export function sessionAuthMiddleware(config, deps = {}) {
85
85
  },
86
86
  };
87
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
+ };
88
103
  /**
89
104
  * The middleware stack for a resolved config, in registration order.
90
105
  *
@@ -125,6 +140,8 @@ export function getClient(command) {
125
140
  baseUrl: config.baseUrl.replace(/\/+$/, ""),
126
141
  });
127
142
  client.use(...buildMiddleware(config));
143
+ if (memoryEnabled())
144
+ client.use(memoryHeaderMiddleware);
128
145
  return client;
129
146
  }
130
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2kw/ai",
3
- "version": "6.3.0-dev.24",
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",