chatbase 0.0.1 → 0.1.1
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/LICENSE +21 -0
- package/README.md +1651 -6
- package/bin/run.js +4 -0
- package/dist/base/agent-command.js +40 -0
- package/dist/base/agent-ref.js +16 -0
- package/dist/base/assert-file.js +21 -0
- package/dist/base/base-command.js +203 -0
- package/dist/base/body-input.js +77 -0
- package/dist/base/list-command.js +16 -0
- package/dist/base/sources.js +33 -0
- package/dist/client/chat-helpers.js +173 -0
- package/dist/client/client.js +175 -0
- package/dist/client/files.js +64 -0
- package/dist/client/paginate.js +40 -0
- package/dist/client/pairing.js +75 -0
- package/dist/client/retry.js +40 -0
- package/dist/client/signals.js +43 -0
- package/dist/client/stream.js +79 -0
- package/dist/commands/agents/auto-retrain.js +46 -0
- package/dist/commands/agents/clone.js +28 -0
- package/dist/commands/agents/create.js +43 -0
- package/dist/commands/agents/delete.js +41 -0
- package/dist/commands/agents/get.js +52 -0
- package/dist/commands/agents/list.js +48 -0
- package/dist/commands/agents/styles.js +44 -0
- package/dist/commands/agents/train.js +31 -0
- package/dist/commands/agents/update.js +47 -0
- package/dist/commands/api.js +69 -0
- package/dist/commands/auth/login.js +128 -0
- package/dist/commands/auth/logout.js +40 -0
- package/dist/commands/auth/status.js +88 -0
- package/dist/commands/chat/index.js +210 -0
- package/dist/commands/chat/retry.js +49 -0
- package/dist/commands/config/get.js +40 -0
- package/dist/commands/config/list.js +34 -0
- package/dist/commands/config/set.js +106 -0
- package/dist/commands/conversations/export.js +75 -0
- package/dist/commands/conversations/get.js +69 -0
- package/dist/commands/conversations/list.js +75 -0
- package/dist/commands/conversations/tool-result.js +74 -0
- package/dist/commands/health.js +22 -0
- package/dist/commands/helpdesk/statuses.js +31 -0
- package/dist/commands/helpdesk/teams.js +25 -0
- package/dist/commands/messages/feedback.js +64 -0
- package/dist/commands/messages/list.js +55 -0
- package/dist/commands/sources/create.js +134 -0
- package/dist/commands/sources/delete.js +28 -0
- package/dist/commands/sources/get.js +35 -0
- package/dist/commands/sources/list.js +47 -0
- package/dist/commands/sources/restore.js +22 -0
- package/dist/commands/sources/summary.js +33 -0
- package/dist/commands/sources/update.js +76 -0
- package/dist/commands/tickets/create.js +58 -0
- package/dist/commands/tickets/get.js +44 -0
- package/dist/commands/tickets/list.js +96 -0
- package/dist/commands/tickets/messages.js +87 -0
- package/dist/commands/tickets/reply.js +66 -0
- package/dist/commands/tickets/search.js +53 -0
- package/dist/commands/tickets/update.js +38 -0
- package/dist/commands/whatsapp/send-template.js +94 -0
- package/dist/commands/whatsapp/templates.js +55 -0
- package/dist/config/paths.js +22 -0
- package/dist/config/resolve.js +37 -0
- package/dist/config/store.js +38 -0
- package/dist/errors/errors.js +81 -0
- package/dist/hooks/chat-message-hint.js +21 -0
- package/dist/output/color.js +30 -0
- package/dist/output/mode.js +7 -0
- package/dist/output/render.js +0 -0
- package/dist/output/spinner.js +37 -0
- package/dist/repl/chat-repl.js +129 -0
- package/dist/version.js +3 -0
- package/oclif.manifest.json +4264 -0
- package/package.json +96 -4
- package/spec/openapi.json +11880 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import createClient from 'openapi-fetch';
|
|
3
|
+
import { EnvHttpProxyAgent, getGlobalDispatcher, fetch as undiciFetch } from 'undici';
|
|
4
|
+
import { resolveTimeoutMs } from '../config/resolve.js';
|
|
5
|
+
import { parseErrorResponse } from '../errors/errors.js';
|
|
6
|
+
import { VERSION } from '../version.js';
|
|
7
|
+
import { computeRetryDelayMs, shouldRetry } from './retry.js';
|
|
8
|
+
import { getSigintSignal } from './signals.js';
|
|
9
|
+
export const DEFAULT_BASE_URL = 'https://www.chatbase.co/api/v2';
|
|
10
|
+
/**
|
|
11
|
+
* Base-URL resolution: explicit option > CHATBASE_API_URL env > production.
|
|
12
|
+
* The env override exists for developing against a local API server.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveBaseUrl(explicit) {
|
|
15
|
+
if (explicit)
|
|
16
|
+
return explicit;
|
|
17
|
+
const env = process.env.CHATBASE_API_URL;
|
|
18
|
+
if (env && env.length > 0)
|
|
19
|
+
return env;
|
|
20
|
+
return DEFAULT_BASE_URL;
|
|
21
|
+
}
|
|
22
|
+
export function buildUserAgent() {
|
|
23
|
+
return `chatbase-cli/${VERSION} (${os.platform()}-${os.arch()}; node/${process.versions.node})`;
|
|
24
|
+
}
|
|
25
|
+
const hasProxyEnv = () => [
|
|
26
|
+
'HTTP_PROXY',
|
|
27
|
+
'HTTPS_PROXY',
|
|
28
|
+
'http_proxy',
|
|
29
|
+
'https_proxy',
|
|
30
|
+
'ALL_PROXY'
|
|
31
|
+
].some((k) => process.env[k] && process.env[k].length > 0);
|
|
32
|
+
let proxyAgent;
|
|
33
|
+
export function dispatcher() {
|
|
34
|
+
// Node's fetch ignores HTTP(S)_PROXY by default; EnvHttpProxyAgent honors it.
|
|
35
|
+
if (!hasProxyEnv())
|
|
36
|
+
return getGlobalDispatcher();
|
|
37
|
+
proxyAgent ??= new EnvHttpProxyAgent();
|
|
38
|
+
return proxyAgent;
|
|
39
|
+
}
|
|
40
|
+
/** setTimeout that also rejects the moment `signal` aborts — the retry
|
|
41
|
+
* backoff must honor Ctrl-C / per-call cancels, not wait out the delay. */
|
|
42
|
+
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
43
|
+
if (signal?.aborted)
|
|
44
|
+
return reject(signal.reason);
|
|
45
|
+
const timer = setTimeout(() => resolve(), ms);
|
|
46
|
+
signal?.addEventListener('abort', () => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
reject(signal.reason);
|
|
49
|
+
}, { once: true });
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* Node's global `Request` and the undici package's `Request` are two copies
|
|
53
|
+
* of the same class — and undici rejects objects made by the other copy
|
|
54
|
+
* (it stringifies them and tries to use "[object Request]" as the URL).
|
|
55
|
+
* openapi-fetch hands us Node-flavored Request objects, so we unpack them
|
|
56
|
+
* into plain values (url, method, headers, body) that can't fail any
|
|
57
|
+
* identity check. Also accepts plain URL strings (the rawApiFetch path),
|
|
58
|
+
* normalizing both shapes into one.
|
|
59
|
+
*
|
|
60
|
+
* When touching this: url, METHOD, headers, and body must ALL survive the
|
|
61
|
+
* unpacking — method was once dropped here, silently turning every request
|
|
62
|
+
* into a GET (caught by review; wire-level tests now pin it).
|
|
63
|
+
*/
|
|
64
|
+
async function toPlainRequestInit(input, init) {
|
|
65
|
+
if (typeof input === 'string' || input instanceof URL) {
|
|
66
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
67
|
+
return {
|
|
68
|
+
url: String(input),
|
|
69
|
+
method,
|
|
70
|
+
requestInit: { ...init, method },
|
|
71
|
+
signal: init?.signal
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const method = (init?.method ?? input.method ?? 'GET').toUpperCase();
|
|
75
|
+
const headers = {};
|
|
76
|
+
for (const [key, value] of input.headers)
|
|
77
|
+
headers[key] = value;
|
|
78
|
+
const body = input.body ? Buffer.from(await input.arrayBuffer()) : undefined;
|
|
79
|
+
return {
|
|
80
|
+
url: input.url,
|
|
81
|
+
method,
|
|
82
|
+
requestInit: { headers, body, ...init, method },
|
|
83
|
+
// Per-request cancel (e.g. Ctrl-C cancels one chat response without
|
|
84
|
+
// killing the interactive session). Always defined, inert when unused.
|
|
85
|
+
signal: input.signal
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function makeFetch(opts) {
|
|
89
|
+
const timeoutMs = opts.timeoutMs ?? resolveTimeoutMs();
|
|
90
|
+
return async (input, init) => {
|
|
91
|
+
const { url, method, requestInit, signal } = await toPlainRequestInit(input, init);
|
|
92
|
+
for (let attempt = 1;; attempt++) {
|
|
93
|
+
const attemptSignal = AbortSignal.any([
|
|
94
|
+
getSigintSignal(),
|
|
95
|
+
...(signal ? [signal] : [AbortSignal.timeout(timeoutMs)])
|
|
96
|
+
]);
|
|
97
|
+
if (opts.verbose) {
|
|
98
|
+
const retryTag = attempt > 1 ? ` (attempt ${attempt})` : '';
|
|
99
|
+
process.stderr.write(`» ${method} ${url}${retryTag}\n`);
|
|
100
|
+
}
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
const response = await undiciFetch(url, {
|
|
103
|
+
...requestInit,
|
|
104
|
+
dispatcher: dispatcher(),
|
|
105
|
+
signal: attemptSignal
|
|
106
|
+
});
|
|
107
|
+
if (opts.verbose) {
|
|
108
|
+
const requestId = response.headers.get('x-request-id');
|
|
109
|
+
process.stderr.write(`« ${response.status}${requestId ? ` (request-id: ${requestId})` : ''} in ${Date.now() - started}ms\n`);
|
|
110
|
+
}
|
|
111
|
+
if (response.ok || !shouldRetry(response.status, method, attempt))
|
|
112
|
+
return response;
|
|
113
|
+
// Draining before the retry avoids leaking the unread response
|
|
114
|
+
// body's underlying connection while we sleep and loop.
|
|
115
|
+
await response.body?.cancel();
|
|
116
|
+
// Same signal as the fetch above, so Ctrl-C (or the REPL's
|
|
117
|
+
// per-call cancel) interrupts the backoff wait too instead of
|
|
118
|
+
// only taking effect on the next attempt.
|
|
119
|
+
await sleep(computeRetryDelayMs(attempt, response.headers.get('x-ratelimit-reset'), Date.now()), attemptSignal);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function createApiClient(opts = {}) {
|
|
124
|
+
const client = createClient({
|
|
125
|
+
baseUrl: resolveBaseUrl(opts.baseUrl),
|
|
126
|
+
fetch: makeFetch(opts)
|
|
127
|
+
});
|
|
128
|
+
client.use({
|
|
129
|
+
onRequest({ request }) {
|
|
130
|
+
request.headers.set('User-Agent', buildUserAgent());
|
|
131
|
+
if (opts.apiKey)
|
|
132
|
+
request.headers.set('Authorization', `Bearer ${opts.apiKey}`);
|
|
133
|
+
return request;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
return client;
|
|
137
|
+
}
|
|
138
|
+
export function throwIfError(response, errorBody) {
|
|
139
|
+
if (response.ok)
|
|
140
|
+
return;
|
|
141
|
+
throw parseErrorResponse(response.status, errorBody, response.headers.get('x-request-id') ?? undefined);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Untyped HTTP call — the `chatbase api` escape hatch (like `gh api` in
|
|
145
|
+
* GitHub's CLI: any method, any path, JSON in/out). Uses the same
|
|
146
|
+
* proxy/timeout/retry/SIGINT wrappers as the typed client.
|
|
147
|
+
*/
|
|
148
|
+
export async function rawApiFetch(method, path, opts = {}) {
|
|
149
|
+
const url = new URL(`${resolveBaseUrl(opts.baseUrl)}${path}`);
|
|
150
|
+
for (const [key, value] of opts.query ?? []) {
|
|
151
|
+
url.searchParams.append(key, value);
|
|
152
|
+
}
|
|
153
|
+
const hasBody = opts.body !== undefined;
|
|
154
|
+
const response = await makeFetch(opts)(url.toString(), {
|
|
155
|
+
method,
|
|
156
|
+
headers: {
|
|
157
|
+
'User-Agent': buildUserAgent(),
|
|
158
|
+
...(opts.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : {}),
|
|
159
|
+
...(hasBody ? { 'Content-Type': 'application/json' } : {})
|
|
160
|
+
},
|
|
161
|
+
...(hasBody ? { body: JSON.stringify(opts.body) } : {})
|
|
162
|
+
});
|
|
163
|
+
let body;
|
|
164
|
+
try {
|
|
165
|
+
body = await response.json();
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
body = undefined;
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
status: response.status,
|
|
172
|
+
requestId: response.headers.get('x-request-id') ?? undefined,
|
|
173
|
+
body
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { FormData as UndiciFormData } from 'undici';
|
|
4
|
+
import { parseErrorResponse } from '../errors/errors.js';
|
|
5
|
+
import { buildUserAgent, makeFetch } from './client.js';
|
|
6
|
+
/** File upload host — documented at chatbase.co/docs/api-v2/sources/create-file-source */
|
|
7
|
+
export const FILES_BASE_URL = 'https://files.chatbase.co/api/v2';
|
|
8
|
+
export function resolveFilesBaseUrl(explicit) {
|
|
9
|
+
if (explicit)
|
|
10
|
+
return explicit;
|
|
11
|
+
const env = process.env.CHATBASE_FILES_URL;
|
|
12
|
+
if (env && env.length > 0)
|
|
13
|
+
return env;
|
|
14
|
+
return FILES_BASE_URL;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The cross-environment trap: CHATBASE_API_URL points somewhere non-default
|
|
18
|
+
* (a preview/staging deployment) while file uploads still go to the
|
|
19
|
+
* production files host — silently sending that environment's credential to
|
|
20
|
+
* a different one. Returns the warning to print, or null when the setup is
|
|
21
|
+
* consistent (no API override, or an explicit CHATBASE_FILES_URL).
|
|
22
|
+
*/
|
|
23
|
+
export function filesHostMismatchWarning() {
|
|
24
|
+
const apiOverride = process.env.CHATBASE_API_URL;
|
|
25
|
+
const filesOverride = process.env.CHATBASE_FILES_URL;
|
|
26
|
+
if (!apiOverride || filesOverride)
|
|
27
|
+
return null;
|
|
28
|
+
return `! File uploads go to ${FILES_BASE_URL} while CHATBASE_API_URL is overridden — set CHATBASE_FILES_URL if this environment has its own files host.`;
|
|
29
|
+
}
|
|
30
|
+
export async function uploadFileSource(opts) {
|
|
31
|
+
// Must use undici's FormData, not the global — global silently sends
|
|
32
|
+
// "[object FormData]" as plain text instead of real multipart. No error.
|
|
33
|
+
const form = new UndiciFormData();
|
|
34
|
+
const buffer = fs.readFileSync(opts.filePath);
|
|
35
|
+
const filename = path.basename(opts.filePath);
|
|
36
|
+
form.set('name', opts.name ?? filename);
|
|
37
|
+
form.set('file', new Blob([buffer]), filename);
|
|
38
|
+
const base = resolveFilesBaseUrl(opts.baseUrl);
|
|
39
|
+
const url = opts.sourceId
|
|
40
|
+
? `${base}/agents/${opts.agentId}/sources/${opts.sourceId}`
|
|
41
|
+
: `${base}/agents/${opts.agentId}/sources`;
|
|
42
|
+
const method = opts.sourceId ? 'PUT' : 'POST';
|
|
43
|
+
const response = await makeFetch({
|
|
44
|
+
timeoutMs: opts.timeoutMs,
|
|
45
|
+
verbose: opts.verbose
|
|
46
|
+
})(url, {
|
|
47
|
+
method,
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
50
|
+
'User-Agent': buildUserAgent()
|
|
51
|
+
},
|
|
52
|
+
body: form
|
|
53
|
+
});
|
|
54
|
+
// Success body is the source object itself: { id, type, name, size, ... }
|
|
55
|
+
const body = (await response.json().catch(() => undefined));
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw parseErrorResponse(response.status, body, response.headers.get('x-request-id') ?? undefined);
|
|
58
|
+
}
|
|
59
|
+
const id = body?.id;
|
|
60
|
+
if (!id) {
|
|
61
|
+
throw new Error('Upload succeeded but the response did not contain a source ID');
|
|
62
|
+
}
|
|
63
|
+
return { id };
|
|
64
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { throwIfError } from './client.js';
|
|
2
|
+
/** Pause between pages on `--all` so a long crawl is less likely to share
|
|
3
|
+
* a contested API key's rate-limit window with other traffic. Solo use is
|
|
4
|
+
* well under the v2 limit (1000/10s); this is a courtesy, not a hard pace. */
|
|
5
|
+
const PAGE_DELAY_MS = 200;
|
|
6
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
7
|
+
/**
|
|
8
|
+
* Fetches one page — or, with `opts.all`, every page — of a cursor-paginated
|
|
9
|
+
* endpoint, following `pagination.cursor` until `hasMore` is false.
|
|
10
|
+
*
|
|
11
|
+
* Returns both the raw `pages` (list commands need these verbatim for
|
|
12
|
+
* `--json`, where `--all` must merge `data` across pages but keep the API's
|
|
13
|
+
* envelope shape for the single-page case) and the flattened `items`
|
|
14
|
+
* (display rows, and callers like resolveAgentRef/listAllSources that only
|
|
15
|
+
* need "every item").
|
|
16
|
+
*
|
|
17
|
+
* The caller supplies a `fetcher` that wraps `client.GET(path, { params })`
|
|
18
|
+
* for its specific endpoint and path params — this helper never sees path
|
|
19
|
+
* templates or param shapes, only the `{cursor, limit}` query it asks for
|
|
20
|
+
* and the `{data, error, response}` result every generated GET returns.
|
|
21
|
+
*/
|
|
22
|
+
export async function fetchPages(fetcher, opts = {}) {
|
|
23
|
+
const pages = [];
|
|
24
|
+
let cursor = opts.cursor;
|
|
25
|
+
let hasMore = false;
|
|
26
|
+
do {
|
|
27
|
+
if (pages.length > 0)
|
|
28
|
+
await sleep(PAGE_DELAY_MS);
|
|
29
|
+
const { data, error, response } = await fetcher({
|
|
30
|
+
cursor,
|
|
31
|
+
limit: opts.limit
|
|
32
|
+
});
|
|
33
|
+
throwIfError(response, error);
|
|
34
|
+
const page = data;
|
|
35
|
+
pages.push(page);
|
|
36
|
+
cursor = page.pagination.cursor ?? undefined;
|
|
37
|
+
hasMore = page.pagination.hasMore;
|
|
38
|
+
} while (opts.all && hasMore && cursor);
|
|
39
|
+
return { pages, items: pages.flatMap((p) => p.data) };
|
|
40
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-side of the browser pairing login flow:
|
|
3
|
+
* 1. POST /api/cli-pairing/create → get user_code + device_code
|
|
4
|
+
* 2. User approves at verification_uri
|
|
5
|
+
* 3. Poll POST /api/cli-pairing/exchange until approved
|
|
6
|
+
* 4. Receive the minted API key + workspace info
|
|
7
|
+
*
|
|
8
|
+
*/
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import { parseErrorResponse, UsageError } from '../errors/errors.js';
|
|
11
|
+
import { rawApiFetch, resolveBaseUrl } from './client.js';
|
|
12
|
+
import { wasInterrupted } from './signals.js';
|
|
13
|
+
export function pairingBaseUrl(baseUrl) {
|
|
14
|
+
return new URL(resolveBaseUrl(baseUrl)).origin;
|
|
15
|
+
}
|
|
16
|
+
export async function startPairing(opts) {
|
|
17
|
+
const res = await rawApiFetch('POST', '/api/cli-pairing/create', {
|
|
18
|
+
baseUrl: pairingBaseUrl(opts?.baseUrl),
|
|
19
|
+
body: { device_name: os.hostname() }
|
|
20
|
+
});
|
|
21
|
+
if (res.status >= 400) {
|
|
22
|
+
throw parseErrorResponse(res.status, res.body);
|
|
23
|
+
}
|
|
24
|
+
const d = res.body;
|
|
25
|
+
return {
|
|
26
|
+
deviceCode: d.device_code,
|
|
27
|
+
userCode: d.user_code,
|
|
28
|
+
verificationUri: d.verification_uri,
|
|
29
|
+
expiresIn: d.expires_in,
|
|
30
|
+
interval: d.interval
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export async function pollExchange(deviceCode, opts) {
|
|
34
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
35
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
36
|
+
const baseUrl = pairingBaseUrl(opts.baseUrl);
|
|
37
|
+
for (;;) {
|
|
38
|
+
opts.onPoll?.();
|
|
39
|
+
const attempt = await rawApiFetch('POST', '/api/cli-pairing/exchange', { baseUrl, body: { device_code: deviceCode } }).then((r) => ({
|
|
40
|
+
ok: true,
|
|
41
|
+
status: r.status,
|
|
42
|
+
body: r.body
|
|
43
|
+
}), (cause) => ({ ok: false, cause }));
|
|
44
|
+
if (!attempt.ok) {
|
|
45
|
+
const name = attempt.cause?.name;
|
|
46
|
+
if (name === 'AbortError' && wasInterrupted())
|
|
47
|
+
throw attempt.cause;
|
|
48
|
+
if (Date.now() >= deadline) {
|
|
49
|
+
throw new UsageError('Pairing request expired. Run `chatbase auth login` to try again.');
|
|
50
|
+
}
|
|
51
|
+
await sleep(opts.intervalMs);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (attempt.status < 400) {
|
|
55
|
+
const result = attempt.body;
|
|
56
|
+
return {
|
|
57
|
+
apiKey: result.api_key,
|
|
58
|
+
workspace: result.workspace
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const errorBody = attempt.body;
|
|
62
|
+
const code = errorBody?.error?.code;
|
|
63
|
+
if (code === 'PAIRING_PENDING' || code === 'PAIRING_SLOW_DOWN') {
|
|
64
|
+
if (Date.now() >= deadline) {
|
|
65
|
+
throw new UsageError('Pairing request expired. Run `chatbase auth login` to try again.');
|
|
66
|
+
}
|
|
67
|
+
const delay = code === 'PAIRING_SLOW_DOWN'
|
|
68
|
+
? opts.intervalMs * 2
|
|
69
|
+
: opts.intervalMs;
|
|
70
|
+
await sleep(delay);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
throw parseErrorResponse(attempt.status, attempt.body);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry policy: 429 (rate limit) retries up to 3 times for ANY method —
|
|
3
|
+
* a rate-limited request was never executed, so repeating it is safe.
|
|
4
|
+
* 5xx retries once, GETs only: reads are safe to repeat, writes are not
|
|
5
|
+
* (the server may have half-done the work before failing).
|
|
6
|
+
*/
|
|
7
|
+
export function shouldRetry(status, method, attempt) {
|
|
8
|
+
if (status === 429)
|
|
9
|
+
return attempt <= 3;
|
|
10
|
+
if (status >= 500 && method.toUpperCase() === 'GET')
|
|
11
|
+
return attempt <= 1;
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
// Epoch timestamps come in seconds (~1.7e9 today) or milliseconds (~1.7e12);
|
|
15
|
+
// the number's size reveals its unit. Anything under 1e11 can only be
|
|
16
|
+
// seconds (1e11 seconds = year 5138; 1e11 ms = 1973, long past). Our API
|
|
17
|
+
// sends seconds — this tolerates ms too in case that ever changes.
|
|
18
|
+
const SECONDS_MS_THRESHOLD = 1e11;
|
|
19
|
+
/**
|
|
20
|
+
* How long to sleep before a retry.
|
|
21
|
+
*
|
|
22
|
+
* Preferred: the X-RateLimit-Reset header says exactly when the rate window
|
|
23
|
+
* reopens — wait precisely until then (capped at 60s so a bad header can't
|
|
24
|
+
* stall the CLI; a past timestamp means the window is already open).
|
|
25
|
+
*
|
|
26
|
+
* Fallback (no usable header): exponential backoff — 500ms, 1s, 2s — plus
|
|
27
|
+
* 0-250ms random jitter so many clients rate-limited together don't all
|
|
28
|
+
* retry at the same instant and collide again.
|
|
29
|
+
*/
|
|
30
|
+
export function computeRetryDelayMs(attempt, resetHeader, nowMs) {
|
|
31
|
+
if (resetHeader && /^\d+$/.test(resetHeader)) {
|
|
32
|
+
const raw = Number(resetHeader);
|
|
33
|
+
const resetMs = raw < SECONDS_MS_THRESHOLD ? raw * 1000 : raw;
|
|
34
|
+
const wait = resetMs - nowMs;
|
|
35
|
+
if (wait > 0)
|
|
36
|
+
return Math.min(wait, 60_000);
|
|
37
|
+
}
|
|
38
|
+
const base = 500 * 2 ** (attempt - 1);
|
|
39
|
+
return base + Math.floor(Math.random() * 250);
|
|
40
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Ctrl-C contract. Registering a SIGINT listener replaces Node's
|
|
3
|
+
* die-instantly default, so this file owns the guarantee that Ctrl-C
|
|
4
|
+
* always works:
|
|
5
|
+
*
|
|
6
|
+
* 1st Ctrl-C → say "Interrupted", abort the shared signal (every
|
|
7
|
+
* in-flight request is wired to it via AbortSignal.any),
|
|
8
|
+
* let the code unwind → catch() exits 130 silently.
|
|
9
|
+
* 2nd Ctrl-C → exit immediately, skipping even fast teardown.
|
|
10
|
+
*
|
|
11
|
+
* 130 = 128 + SIGINT's signal number (2) — the Unix convention shells and
|
|
12
|
+
* CI check for "user cancelled", as distinct from "failed".
|
|
13
|
+
*/
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
let interrupts = 0;
|
|
16
|
+
let installed = false;
|
|
17
|
+
export function getSigintSignal() {
|
|
18
|
+
return controller.signal;
|
|
19
|
+
}
|
|
20
|
+
/** True once SIGINT has been received at least once (see installSigintHandler). */
|
|
21
|
+
export function wasInterrupted() {
|
|
22
|
+
return interrupts > 0;
|
|
23
|
+
}
|
|
24
|
+
/** Idempotent — every command's init() calls this; only one listener ever. */
|
|
25
|
+
export function installSigintHandler() {
|
|
26
|
+
if (installed)
|
|
27
|
+
return;
|
|
28
|
+
installed = true;
|
|
29
|
+
process.on('SIGINT', () => {
|
|
30
|
+
interrupts += 1;
|
|
31
|
+
if (interrupts === 1) {
|
|
32
|
+
process.stderr.write('\nInterrupted\n');
|
|
33
|
+
controller.abort();
|
|
34
|
+
// Insurance: if graceful teardown hangs, force-exit in 2s.
|
|
35
|
+
// unref() is load-bearing — without it this timer would keep
|
|
36
|
+
// the process alive 2s on EVERY Ctrl-C, even after clean exit.
|
|
37
|
+
setTimeout(() => process.exit(130), 2000).unref();
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
process.exit(130);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-rolled SSE parser (~50 lines) instead of a library (e.g. eventsource-parser)
|
|
3
|
+
* because SSE is trivial (`data: <json>\n\n`) and the real work is Chatbase-specific:
|
|
4
|
+
* idle-timeout racing and mapping our event types (text-delta, message-metadata).
|
|
5
|
+
* A library would replace ~15 lines of string splitting and add a dependency to maintain.
|
|
6
|
+
*/
|
|
7
|
+
export async function parseSseStream(body, onEvent, opts = {}) {
|
|
8
|
+
const idleMs = opts.idleTimeoutMs ?? 60_000;
|
|
9
|
+
const reader = body.getReader();
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
let buffer = '';
|
|
12
|
+
try {
|
|
13
|
+
for (;;) {
|
|
14
|
+
let timer;
|
|
15
|
+
const idle = new Promise((_, reject) => {
|
|
16
|
+
timer = setTimeout(() => {
|
|
17
|
+
const seconds = Math.round(idleMs / 1000);
|
|
18
|
+
reject(new Error(`Stream idle timeout — no data for ${seconds}s`));
|
|
19
|
+
}, idleMs);
|
|
20
|
+
});
|
|
21
|
+
const result = await Promise.race([reader.read(), idle]).finally(() => clearTimeout(timer));
|
|
22
|
+
const { done, value } = result;
|
|
23
|
+
if (done)
|
|
24
|
+
return;
|
|
25
|
+
buffer += decoder.decode(value, { stream: true });
|
|
26
|
+
for (;;) {
|
|
27
|
+
const idx = buffer.indexOf('\n\n');
|
|
28
|
+
if (idx === -1)
|
|
29
|
+
break;
|
|
30
|
+
const block = buffer.slice(0, idx);
|
|
31
|
+
buffer = buffer.slice(idx + 2);
|
|
32
|
+
for (const line of block.split('\n')) {
|
|
33
|
+
if (!line.startsWith('data: '))
|
|
34
|
+
continue;
|
|
35
|
+
const payload = line.slice(6);
|
|
36
|
+
if (payload === '[DONE]') {
|
|
37
|
+
onEvent({ type: 'done' });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
let part;
|
|
41
|
+
try {
|
|
42
|
+
part = JSON.parse(payload);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
onEvent({
|
|
46
|
+
type: 'warning',
|
|
47
|
+
message: 'Skipped an unparseable stream chunk'
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (part.type === 'text-delta' &&
|
|
52
|
+
typeof part.delta === 'string') {
|
|
53
|
+
onEvent({ type: 'text', text: part.delta });
|
|
54
|
+
}
|
|
55
|
+
else if (part.type === 'error') {
|
|
56
|
+
onEvent({
|
|
57
|
+
type: 'error',
|
|
58
|
+
message: typeof part.errorText === 'string'
|
|
59
|
+
? part.errorText
|
|
60
|
+
: 'The agent stopped with an error'
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
else if (part.type === 'message-metadata' ||
|
|
64
|
+
part.type === 'finish') {
|
|
65
|
+
const meta = (part.messageMetadata ?? {});
|
|
66
|
+
onEvent({
|
|
67
|
+
type: 'metadata',
|
|
68
|
+
conversationId: meta.conversationId,
|
|
69
|
+
messageId: meta.messageId
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
await reader.cancel().catch(() => { });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { AgentCommand } from '../../base/agent-command.js';
|
|
3
|
+
import { throwIfError } from '../../client/client.js';
|
|
4
|
+
import { UsageError } from '../../errors/errors.js';
|
|
5
|
+
export default class AgentsAutoRetrain extends AgentCommand {
|
|
6
|
+
static description = 'Enable or disable automatic retraining for an agent';
|
|
7
|
+
static examples = [
|
|
8
|
+
'<%= config.bin %> agents auto-retrain agt_123 --enabled',
|
|
9
|
+
'<%= config.bin %> agents auto-retrain --enabled'
|
|
10
|
+
];
|
|
11
|
+
static args = {
|
|
12
|
+
agentId: Args.string({
|
|
13
|
+
required: false,
|
|
14
|
+
description: 'Agent ID'
|
|
15
|
+
})
|
|
16
|
+
};
|
|
17
|
+
static flags = {
|
|
18
|
+
...AgentCommand.baseFlags,
|
|
19
|
+
enabled: Flags.boolean({
|
|
20
|
+
description: 'Enable automatic retraining',
|
|
21
|
+
exactlyOne: ['enabled', 'disabled']
|
|
22
|
+
}),
|
|
23
|
+
disabled: Flags.boolean({
|
|
24
|
+
description: 'Disable automatic retraining',
|
|
25
|
+
exactlyOne: ['enabled', 'disabled']
|
|
26
|
+
})
|
|
27
|
+
};
|
|
28
|
+
async run() {
|
|
29
|
+
const { args, flags } = await this.parse(AgentsAutoRetrain);
|
|
30
|
+
if (args.agentId && (flags.agent || flags['agent-name'])) {
|
|
31
|
+
throw new UsageError('Pass the agent ID either positionally or via -a/--agent-name, not both.');
|
|
32
|
+
}
|
|
33
|
+
const body = {
|
|
34
|
+
enabled: flags.enabled === true
|
|
35
|
+
};
|
|
36
|
+
const client = this.apiClient(flags);
|
|
37
|
+
const agentId = args.agentId ?? (await this.agentId(flags, client));
|
|
38
|
+
const { error, response } = await client.PUT('/agents/{agentId}/auto-retrain', {
|
|
39
|
+
params: { path: { agentId } },
|
|
40
|
+
body
|
|
41
|
+
});
|
|
42
|
+
throwIfError(response, error);
|
|
43
|
+
const status = flags.enabled ? 'enabled' : 'disabled';
|
|
44
|
+
this.success(flags, `Auto-retrain ${status} for ${agentId}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../../base/base-command.js';
|
|
3
|
+
import { throwIfError } from '../../client/client.js';
|
|
4
|
+
export default class AgentsClone extends BaseCommand {
|
|
5
|
+
static description = 'Clone an agent, including all its sources (excluding Notion)';
|
|
6
|
+
static examples = ['<%= config.bin %> agents clone agt_123'];
|
|
7
|
+
static args = {
|
|
8
|
+
agentId: Args.string({
|
|
9
|
+
required: true,
|
|
10
|
+
description: 'Agent ID to clone'
|
|
11
|
+
})
|
|
12
|
+
};
|
|
13
|
+
static flags = { ...BaseCommand.baseFlags };
|
|
14
|
+
async run() {
|
|
15
|
+
const { args, flags } = await this.parse(AgentsClone);
|
|
16
|
+
const client = this.apiClient(flags);
|
|
17
|
+
const { data, error, response } = await client.POST('/agents/{agentId}/clone', { params: { path: { agentId: args.agentId } } });
|
|
18
|
+
throwIfError(response, error);
|
|
19
|
+
const id = data.id;
|
|
20
|
+
if (flags.json) {
|
|
21
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
this.success(flags, `Cloned agent ${args.agentId} → ${id}`);
|
|
25
|
+
process.stdout.write(`${id}\n`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { BaseCommand, bodyFieldFlags } from '../../base/base-command.js';
|
|
3
|
+
import { readBodyData } from '../../base/body-input.js';
|
|
4
|
+
import { throwIfError } from '../../client/client.js';
|
|
5
|
+
export default class AgentsCreate extends BaseCommand {
|
|
6
|
+
static description = 'Create a new agent';
|
|
7
|
+
static examples = [
|
|
8
|
+
'<%= config.bin %> agents create --name "Support Bot" --instructions "Be helpful"',
|
|
9
|
+
'<%= config.bin %> agents create --data @agent.json'
|
|
10
|
+
];
|
|
11
|
+
static flags = {
|
|
12
|
+
...BaseCommand.baseFlags,
|
|
13
|
+
...bodyFieldFlags,
|
|
14
|
+
name: Flags.string({ description: 'Agent name' }),
|
|
15
|
+
instructions: Flags.string({ description: 'System instructions' }),
|
|
16
|
+
model: Flags.string({ description: 'Model ID' }),
|
|
17
|
+
data: Flags.string({
|
|
18
|
+
description: 'JSON body (@file, @-, or inline). Fields: name, instructions, model, visibility, temp'
|
|
19
|
+
})
|
|
20
|
+
};
|
|
21
|
+
async run() {
|
|
22
|
+
const { flags } = await this.parse(AgentsCreate);
|
|
23
|
+
const body = {
|
|
24
|
+
...(await readBodyData(flags.data, flags.field)),
|
|
25
|
+
...(flags.name ? { name: flags.name } : {}),
|
|
26
|
+
...(flags.instructions ? { instructions: flags.instructions } : {}),
|
|
27
|
+
...(flags.model ? { model: flags.model } : {})
|
|
28
|
+
};
|
|
29
|
+
const client = this.apiClient(flags);
|
|
30
|
+
const { data, error, response } = await client.POST('/agents', {
|
|
31
|
+
body: body
|
|
32
|
+
});
|
|
33
|
+
throwIfError(response, error);
|
|
34
|
+
const id = data.id;
|
|
35
|
+
if (flags.json) {
|
|
36
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
this.success(flags, `Created agent ${id}`);
|
|
40
|
+
process.stdout.write(`${id}\n`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|