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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1651 -6
  3. package/bin/run.js +4 -0
  4. package/dist/base/agent-command.js +40 -0
  5. package/dist/base/agent-ref.js +16 -0
  6. package/dist/base/assert-file.js +21 -0
  7. package/dist/base/base-command.js +203 -0
  8. package/dist/base/body-input.js +77 -0
  9. package/dist/base/list-command.js +16 -0
  10. package/dist/base/sources.js +33 -0
  11. package/dist/client/chat-helpers.js +173 -0
  12. package/dist/client/client.js +175 -0
  13. package/dist/client/files.js +64 -0
  14. package/dist/client/paginate.js +40 -0
  15. package/dist/client/pairing.js +75 -0
  16. package/dist/client/retry.js +40 -0
  17. package/dist/client/signals.js +43 -0
  18. package/dist/client/stream.js +79 -0
  19. package/dist/commands/agents/auto-retrain.js +46 -0
  20. package/dist/commands/agents/clone.js +28 -0
  21. package/dist/commands/agents/create.js +43 -0
  22. package/dist/commands/agents/delete.js +41 -0
  23. package/dist/commands/agents/get.js +52 -0
  24. package/dist/commands/agents/list.js +48 -0
  25. package/dist/commands/agents/styles.js +44 -0
  26. package/dist/commands/agents/train.js +31 -0
  27. package/dist/commands/agents/update.js +47 -0
  28. package/dist/commands/api.js +69 -0
  29. package/dist/commands/auth/login.js +128 -0
  30. package/dist/commands/auth/logout.js +40 -0
  31. package/dist/commands/auth/status.js +88 -0
  32. package/dist/commands/chat/index.js +210 -0
  33. package/dist/commands/chat/retry.js +49 -0
  34. package/dist/commands/config/get.js +40 -0
  35. package/dist/commands/config/list.js +34 -0
  36. package/dist/commands/config/set.js +106 -0
  37. package/dist/commands/conversations/export.js +75 -0
  38. package/dist/commands/conversations/get.js +69 -0
  39. package/dist/commands/conversations/list.js +75 -0
  40. package/dist/commands/conversations/tool-result.js +74 -0
  41. package/dist/commands/health.js +22 -0
  42. package/dist/commands/helpdesk/statuses.js +31 -0
  43. package/dist/commands/helpdesk/teams.js +25 -0
  44. package/dist/commands/messages/feedback.js +64 -0
  45. package/dist/commands/messages/list.js +55 -0
  46. package/dist/commands/sources/create.js +134 -0
  47. package/dist/commands/sources/delete.js +28 -0
  48. package/dist/commands/sources/get.js +35 -0
  49. package/dist/commands/sources/list.js +47 -0
  50. package/dist/commands/sources/restore.js +22 -0
  51. package/dist/commands/sources/summary.js +33 -0
  52. package/dist/commands/sources/update.js +76 -0
  53. package/dist/commands/tickets/create.js +58 -0
  54. package/dist/commands/tickets/get.js +44 -0
  55. package/dist/commands/tickets/list.js +96 -0
  56. package/dist/commands/tickets/messages.js +87 -0
  57. package/dist/commands/tickets/reply.js +66 -0
  58. package/dist/commands/tickets/search.js +53 -0
  59. package/dist/commands/tickets/update.js +38 -0
  60. package/dist/commands/whatsapp/send-template.js +94 -0
  61. package/dist/commands/whatsapp/templates.js +55 -0
  62. package/dist/config/paths.js +22 -0
  63. package/dist/config/resolve.js +37 -0
  64. package/dist/config/store.js +38 -0
  65. package/dist/errors/errors.js +81 -0
  66. package/dist/hooks/chat-message-hint.js +21 -0
  67. package/dist/output/color.js +30 -0
  68. package/dist/output/mode.js +7 -0
  69. package/dist/output/render.js +0 -0
  70. package/dist/output/spinner.js +37 -0
  71. package/dist/repl/chat-repl.js +129 -0
  72. package/dist/version.js +3 -0
  73. package/oclif.manifest.json +4264 -0
  74. package/package.json +96 -4
  75. package/spec/openapi.json +11880 -0
package/bin/run.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { execute } from '@oclif/core'
3
+
4
+ await execute({ dir: import.meta.url })
@@ -0,0 +1,40 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { resolveAgent } from '../config/resolve.js';
3
+ import { UsageError } from '../errors/errors.js';
4
+ import { resolveAgentRef } from './agent-ref.js';
5
+ import { BaseCommand } from './base-command.js';
6
+ export class AgentCommand extends BaseCommand {
7
+ static baseFlags = {
8
+ ...BaseCommand.baseFlags,
9
+ agent: Flags.string({
10
+ char: 'a',
11
+ description: 'Agent ID (or set CHATBASE_AGENT_ID)'
12
+ }),
13
+ 'agent-name': Flags.string({
14
+ description: 'Agent display name (looked up to an ID)',
15
+ exclusive: ['agent']
16
+ })
17
+ };
18
+ /**
19
+ * -a is always an ID (no API call). --agent-name resolves via the
20
+ * agents list. The two flags are mutually exclusive.
21
+ */
22
+ async agentId(flags, client) {
23
+ if (flags['agent-name']) {
24
+ const id = await resolveAgentRef(client, flags['agent-name']);
25
+ this.note(flags, `→ ${id}`);
26
+ return id;
27
+ }
28
+ const resolved = resolveAgent(flags.agent);
29
+ if (!resolved) {
30
+ throw new UsageError('No agent specified. Pass -a <agentId>, --agent-name <name>, or set CHATBASE_AGENT_ID.');
31
+ }
32
+ return resolved.value;
33
+ }
34
+ /** The "resume with: ..." trailer both chat commands print after a turn. */
35
+ printConversationHint(flags, agentId, conversationId) {
36
+ if (!conversationId)
37
+ return;
38
+ this.note(flags, `Conversation: ${conversationId} — resume with: chatbase chat -a ${agentId} --conversation ${conversationId} --resume`);
39
+ }
40
+ }
@@ -0,0 +1,16 @@
1
+ import { fetchPages } from '../client/paginate.js';
2
+ import { UsageError } from '../errors/errors.js';
3
+ /** Resolve an --agent-name value to an ID. Fetches all pages to detect ambiguity. */
4
+ export async function resolveAgentRef(client, name) {
5
+ const { items: agents } = await fetchPages((query) => client.GET('/agents', { params: { query } }), { all: true });
6
+ const matches = agents.filter((a) => a.name === name);
7
+ if (matches.length === 1)
8
+ return matches[0].id;
9
+ if (matches.length > 1) {
10
+ const candidates = matches
11
+ .map((a) => ` ${a.name} (${a.id})`)
12
+ .join('\n');
13
+ throw new UsageError(`Multiple agents are named "${name}":\n${candidates}\nUse -a with the agent ID instead.`);
14
+ }
15
+ throw new UsageError(`No agent named "${name}". Run \`chatbase agents list\` to see available agents.`);
16
+ }
@@ -0,0 +1,21 @@
1
+ import fs from 'node:fs';
2
+ import { UsageError } from '../errors/errors.js';
3
+ /** Throws a UsageError (no network) unless filePath exists, is a regular file, and is readable. */
4
+ export function assertFileReadable(filePath) {
5
+ let stat;
6
+ try {
7
+ stat = fs.statSync(filePath);
8
+ }
9
+ catch {
10
+ throw new UsageError(`File not found: ${filePath}`);
11
+ }
12
+ if (!stat.isFile()) {
13
+ throw new UsageError(`Not a regular file: ${filePath}`);
14
+ }
15
+ try {
16
+ fs.accessSync(filePath, fs.constants.R_OK);
17
+ }
18
+ catch {
19
+ throw new UsageError(`File is not readable: ${filePath}`);
20
+ }
21
+ }
@@ -0,0 +1,203 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Command, Errors, Flags } from '@oclif/core';
4
+ import { createApiClient, DEFAULT_BASE_URL, resolveBaseUrl } from '../client/client.js';
5
+ import { installSigintHandler, wasInterrupted } from '../client/signals.js';
6
+ import { logsDir } from '../config/paths.js';
7
+ import { resolveApiKey, resolveTimeoutMs } from '../config/resolve.js';
8
+ import { ApiError, formatApiError, UsageError } from '../errors/errors.js';
9
+ import { colorEnabled, paint } from '../output/color.js';
10
+ import { selectMode } from '../output/mode.js';
11
+ import { renderPlain, renderTable } from '../output/render.js';
12
+ const ISSUES_URL = 'https://github.com/Chatbase-co/chatbase-cli/issues/new';
13
+ /** True for parser/validation errors oclif itself raises (unknown flag, bad --limit, etc). */
14
+ function isCliError(err) {
15
+ if (err instanceof Errors.CLIError)
16
+ return true;
17
+ const withOclif = err;
18
+ return typeof withOclif?.oclif?.exit === 'number';
19
+ }
20
+ function isFetchFailure(err) {
21
+ return err instanceof TypeError && /fetch failed/i.test(err.message);
22
+ }
23
+ function networkErrorCode(err) {
24
+ const cause = err.cause;
25
+ return cause?.code ?? cause?.errors?.[0]?.code;
26
+ }
27
+ /** True for fetch's AbortSignal.timeout() firing (a TimeoutError DOMException). */
28
+ function isTimeoutError(err) {
29
+ const e = err;
30
+ if (e?.name === 'TimeoutError')
31
+ return true;
32
+ if (typeof DOMException !== 'undefined' && err instanceof DOMException) {
33
+ return /timeout/i.test(e?.message ?? '');
34
+ }
35
+ return false;
36
+ }
37
+ export function classifyError(err) {
38
+ if (err instanceof UsageError)
39
+ return { kind: 'usage', error: err };
40
+ if (err instanceof ApiError)
41
+ return { kind: 'api', error: err };
42
+ if (isCliError(err))
43
+ return { kind: 'cli', error: err };
44
+ if (isTimeoutError(err))
45
+ return { kind: 'timeout' };
46
+ if (isFetchFailure(err))
47
+ return { kind: 'network', code: networkErrorCode(err) };
48
+ const name = err?.name;
49
+ if (name === 'AbortError') {
50
+ return wasInterrupted() ? { kind: 'interrupted' } : { kind: 'timeout' };
51
+ }
52
+ return { kind: 'unexpected', error: err };
53
+ }
54
+ /** -f key=value body fields — spread into the flags of commands that send a
55
+ * JSON request body (not part of baseFlags: advertising -f on body-less
56
+ * commands like `health` was just noise in --help). */
57
+ export const bodyFieldFlags = {
58
+ field: Flags.string({
59
+ char: 'f',
60
+ multiple: true,
61
+ description: 'Set a body field: -f key=value (repeatable)'
62
+ })
63
+ };
64
+ export class BaseCommand extends Command {
65
+ static baseFlags = {
66
+ json: Flags.boolean({
67
+ description: 'Output raw API JSON',
68
+ helpGroup: 'OUTPUT'
69
+ }),
70
+ plain: Flags.boolean({
71
+ description: 'Tab-separated output for scripts',
72
+ helpGroup: 'OUTPUT'
73
+ }),
74
+ quiet: Flags.boolean({
75
+ char: 'q',
76
+ description: 'Suppress non-essential output'
77
+ }),
78
+ verbose: Flags.boolean({ description: 'Verbose diagnostics' }),
79
+ 'no-input': Flags.boolean({
80
+ description: 'Never prompt; fail instead'
81
+ }),
82
+ 'no-color': Flags.boolean({ description: 'Disable colored output' })
83
+ };
84
+ requireAuth = true;
85
+ async init() {
86
+ await super.init();
87
+ installSigintHandler();
88
+ }
89
+ mode(flags) {
90
+ return selectMode(flags, process.stdout);
91
+ }
92
+ palette(flags) {
93
+ return paint(colorEnabled(process.stderr, flags['no-color']));
94
+ }
95
+ note(flags, msg) {
96
+ if (!flags.quiet)
97
+ process.stderr.write(`${msg}\n`);
98
+ }
99
+ success(flags, msg) {
100
+ this.note(flags, `${this.palette(flags).green('✓')} ${msg}`);
101
+ }
102
+ printData(flags, raw, rows, columns) {
103
+ const mode = this.mode(flags);
104
+ if (mode === 'json') {
105
+ process.stdout.write(`${JSON.stringify(raw, null, 2)}\n`);
106
+ return;
107
+ }
108
+ if (rows.length === 0) {
109
+ this.note(flags, 'No results.');
110
+ return;
111
+ }
112
+ if (mode === 'plain') {
113
+ process.stdout.write(`${renderPlain(rows, columns)}\n`);
114
+ }
115
+ else {
116
+ process.stdout.write(`${renderTable(rows, columns)}\n`);
117
+ }
118
+ }
119
+ printDetail(flags, raw, row, columns, detail) {
120
+ if (this.mode(flags) !== 'pretty') {
121
+ this.printData(flags, raw, [row], columns);
122
+ return;
123
+ }
124
+ const shown = detail.filter(([, value]) => value !== '');
125
+ const width = Math.max(...shown.map(([label]) => label.length));
126
+ process.stdout.write(`${shown.map(([label, value]) => `${label.padEnd(width + 2)}${value}`).join('\n')}\n`);
127
+ }
128
+ apiClient(flags) {
129
+ const resolved = resolveApiKey();
130
+ if (!resolved && this.requireAuth) {
131
+ throw new UsageError('Not authenticated. Run `chatbase auth login`, or set CHATBASE_API_KEY.');
132
+ }
133
+ return createApiClient({
134
+ apiKey: resolved?.value,
135
+ verbose: flags.verbose
136
+ });
137
+ }
138
+ async catch(err) {
139
+ const flags = {
140
+ 'no-color': process.argv.includes('--no-color')
141
+ };
142
+ const classified = classifyError(err);
143
+ if (classified.kind === 'usage') {
144
+ process.stderr.write(`${classified.error.message}\n`);
145
+ this.exit(2);
146
+ }
147
+ if (classified.kind === 'api') {
148
+ if (process.argv.includes('--json')) {
149
+ const { code, message, details, requestId, status } = classified.error;
150
+ process.stderr.write(`${JSON.stringify({ error: { code, message, details }, requestId, status }, null, 2)}\n`);
151
+ }
152
+ else {
153
+ process.stderr.write(`${formatApiError(classified.error, this.palette(flags))}\n`);
154
+ }
155
+ this.exit(1);
156
+ }
157
+ if (classified.kind === 'cli') {
158
+ await super.catch(classified.error);
159
+ this.exit(classified.error.oclif?.exit ?? 2);
160
+ }
161
+ if (classified.kind === 'interrupted') {
162
+ this.exit(130);
163
+ }
164
+ if (classified.kind === 'timeout') {
165
+ process.stderr.write(`✗ Request timed out after ${resolveTimeoutMs()}ms (set CHATBASE_TIMEOUT to change)\n`);
166
+ this.exit(1);
167
+ }
168
+ if (classified.kind === 'network') {
169
+ const base = resolveBaseUrl();
170
+ process.stderr.write(`✗ Network error: could not reach ${base}${classified.code ? ` (${classified.code})` : ''}\n`);
171
+ process.stderr.write(base === DEFAULT_BASE_URL
172
+ ? ' Check your internet connection and retry.\n'
173
+ : ' CHATBASE_API_URL is overriding the API base — check that value first.\n');
174
+ this.exit(1);
175
+ }
176
+ // Server returned HTML instead of JSON — usually a wrong base URL
177
+ // hitting a web page instead of the API.
178
+ const msg = err?.message ?? '';
179
+ if (err instanceof SyntaxError &&
180
+ (msg.includes('<!DOCTYPE') || msg.includes('is not valid JSON'))) {
181
+ const base = resolveBaseUrl();
182
+ process.stderr.write(`✗ The API returned an unexpected response.\n` +
183
+ ` Current base: ${base}\n` +
184
+ ` Check that CHATBASE_API_URL is correct and the endpoint exists on this host.\n`);
185
+ this.exit(1);
186
+ }
187
+ // Unexpected: short message + full detail to a log file + pre-filled issue URL.
188
+ const logFile = path.join(logsDir(), `error-${Date.now()}.log`);
189
+ try {
190
+ fs.mkdirSync(logsDir(), { recursive: true });
191
+ fs.writeFileSync(logFile, String(err?.stack ?? err));
192
+ }
193
+ catch {
194
+ /* logging must never mask the original failure */
195
+ }
196
+ const title = encodeURIComponent(`bug: ${err?.message ?? 'unexpected error'}`);
197
+ const body = encodeURIComponent(`CLI: chatbase ${this.config.version}\nOS: ${process.platform}-${process.arch}\nNode: ${process.versions.node}\nCommand: ${this.id}\n`);
198
+ process.stderr.write(`✗ Unexpected error: ${err?.message ?? err}\n`);
199
+ process.stderr.write(` details: ${logFile}\n`);
200
+ process.stderr.write(` report: ${ISSUES_URL}?title=${title}&body=${body}\n`);
201
+ this.exit(1);
202
+ }
203
+ }
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs';
2
+ import { UsageError } from '../errors/errors.js';
3
+ /** Read all of stdin as a UTF-8 string, verbatim — @- must match @file's
4
+ * fidelity (fs.readFileSync returns exact bytes too). Callers that want
5
+ * trimmed input (a pasted key, a chat message) trim it themselves. */
6
+ export async function readStdinToEnd() {
7
+ let raw = '';
8
+ // setEncoding before iterating makes Node decode UTF-8 across chunk
9
+ // boundaries; without it each Buffer chunk is coerced to a string
10
+ // independently, corrupting multi-byte characters split mid-chunk.
11
+ process.stdin.setEncoding('utf8');
12
+ for await (const chunk of process.stdin)
13
+ raw += chunk;
14
+ return raw;
15
+ }
16
+ /**
17
+ * Resolve @file, @- (stdin), or a literal string into its text content.
18
+ * Shared core for both readBodyData (JSON) and readTextInput (free text).
19
+ */
20
+ async function resolveInput(value, flagName) {
21
+ if (value === '@-') {
22
+ if (process.stdin.isTTY)
23
+ throw new UsageError(`${flagName} @- expects piped stdin.`);
24
+ return readStdinToEnd();
25
+ }
26
+ if (value.startsWith('@')) {
27
+ const filePath = value.slice(1);
28
+ if (!filePath) {
29
+ throw new UsageError(`${flagName} @ requires a filename: ${flagName} @path/to/file`);
30
+ }
31
+ return fs.readFileSync(filePath, 'utf8');
32
+ }
33
+ return value;
34
+ }
35
+ /** Parse -f key=value pairs into an object. Flat strings only. */
36
+ export function parseFields(fields) {
37
+ if (!fields?.length)
38
+ return {};
39
+ const result = {};
40
+ for (const pair of fields) {
41
+ const eq = pair.indexOf('=');
42
+ if (eq < 1) {
43
+ throw new UsageError(`-f expects key=value, got "${pair}"`);
44
+ }
45
+ result[pair.slice(0, eq)] = pair.slice(eq + 1);
46
+ }
47
+ return result;
48
+ }
49
+ /** Build the request body: --data (base) → -f fields (override). */
50
+ export async function readBodyData(data, fields) {
51
+ let base = {};
52
+ if (data) {
53
+ const raw = await resolveInput(data, '--data');
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(raw);
57
+ }
58
+ catch {
59
+ throw new UsageError('--data must be valid JSON (inline, @file, or @-).');
60
+ }
61
+ if (typeof parsed !== 'object' ||
62
+ parsed === null ||
63
+ Array.isArray(parsed)) {
64
+ const extra = parseFields(fields);
65
+ if (Object.keys(extra).length > 0) {
66
+ throw new UsageError('-f cannot be combined with a non-object --data value.');
67
+ }
68
+ return parsed;
69
+ }
70
+ base = parsed;
71
+ }
72
+ return { ...base, ...parseFields(fields) };
73
+ }
74
+ /** Resolve a flag value via the same @file/@- indirection, without JSON parsing. */
75
+ export async function readTextInput(value, flagName = '--content') {
76
+ return resolveInput(value, flagName);
77
+ }
@@ -0,0 +1,16 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { AgentCommand } from './agent-command.js';
3
+ export class ListCommand extends AgentCommand {
4
+ static baseFlags = {
5
+ ...AgentCommand.baseFlags,
6
+ limit: Flags.integer({
7
+ description: 'Maximum items per page',
8
+ min: 1,
9
+ max: 100
10
+ }),
11
+ cursor: Flags.string({
12
+ description: 'Pagination cursor from a previous page'
13
+ }),
14
+ all: Flags.boolean({ description: 'Fetch every page' })
15
+ };
16
+ }
@@ -0,0 +1,33 @@
1
+ export const SOURCE_COLUMNS = [
2
+ { key: 'id', header: 'ID' },
3
+ { key: 'name', header: 'NAME' },
4
+ { key: 'type', header: 'TYPE' },
5
+ { key: 'status', header: 'STATUS' },
6
+ { key: 'size', header: 'SIZE' }
7
+ ];
8
+ const READY = new Set(['trained']);
9
+ const PENDING = new Set(['untrained', 'updated', 'toBeDeleted']);
10
+ const REMOVED = new Set(['deleted']);
11
+ /** Pretty-mode glyph: ✓ trained, … in progress, ✗ deleted. */
12
+ export function renderStatus(status, mode) {
13
+ if (mode !== 'pretty')
14
+ return status;
15
+ const key = status.toLowerCase();
16
+ if (READY.has(key))
17
+ return `✓ ${status}`;
18
+ if (PENDING.has(key))
19
+ return `… ${status}`;
20
+ if (REMOVED.has(key))
21
+ return `✗ ${status}`;
22
+ return status;
23
+ }
24
+ /** Maps one raw API source object to a display row for SOURCE_COLUMNS. */
25
+ export function toSourceRow(s, mode) {
26
+ return {
27
+ id: String(s.id ?? ''),
28
+ name: String(s.name ?? ''),
29
+ type: String(s.type ?? ''),
30
+ status: renderStatus(String(s.status ?? ''), mode),
31
+ size: String(s.size ?? '')
32
+ };
33
+ }
@@ -0,0 +1,173 @@
1
+ import { ApiError } from '../errors/errors.js';
2
+ import { maybeSpinner } from '../output/spinner.js';
3
+ import { throwIfError } from './client.js';
4
+ import { parseSseStream } from './stream.js';
5
+ /**
6
+ * Strips C0/C1 control characters (keeping only \t and \n) from text that
7
+ * came back from the API. Agent replies and replayed user messages are
8
+ * attacker-influenceable, and raw ESC/OSC bytes reaching a terminal are an
9
+ * injection primitive (OSC 52 clipboard writes, cursor/erase spoofing, \r
10
+ * line overwrites). Safe to apply per streamed chunk: removing the ESC byte
11
+ * defuses a sequence even when it is split across two deltas — the tail
12
+ * prints as inert ASCII. `--json` output is unaffected (JSON.stringify
13
+ * already escapes these), so raw fidelity remains available.
14
+ */
15
+ export function sanitizeAgentText(text) {
16
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control characters is the point
17
+ return text.replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, '');
18
+ }
19
+ /**
20
+ * Joins every `text` part of a non-streaming ChatResponse into one string.
21
+ * Tool-call/tool-result parts are skipped.
22
+ */
23
+ export function extractText(envelope) {
24
+ return sanitizeAgentText(envelope.data.parts
25
+ .filter((part) => part.type === 'text')
26
+ .map((part) => part.text)
27
+ .join(''));
28
+ }
29
+ /** Shared response handling for both sendChat and retryChat. `emptyMessage`
30
+ * names the caller in the error for the one failure this function can't
31
+ * recover from (a 2xx non-streaming response with no body) — thrown once
32
+ * here instead of every caller re-checking `!result.raw`. */
33
+ async function handleResponse(data, error, response, stream, onText, emptyMessage) {
34
+ if (!stream) {
35
+ throwIfError(response, error);
36
+ const raw = data;
37
+ if (!raw)
38
+ throw new Error(emptyMessage);
39
+ return {
40
+ stream: false,
41
+ raw,
42
+ conversationId: raw.data?.metadata?.conversationId,
43
+ messageId: raw.data?.id
44
+ };
45
+ }
46
+ // With parseAs: 'stream', openapi-fetch drains the body into `error`
47
+ // on non-2xx — re-reading response.json() would throw (already consumed).
48
+ if (!response.ok)
49
+ throwIfError(response, error);
50
+ const body = data;
51
+ if (!body)
52
+ throw new Error('Stream response had no body');
53
+ let conversationId;
54
+ let messageId;
55
+ let streamError;
56
+ await parseSseStream(body, (event) => {
57
+ if (event.type === 'text')
58
+ onText(sanitizeAgentText(event.text));
59
+ if (event.type === 'metadata') {
60
+ conversationId = event.conversationId ?? conversationId;
61
+ messageId = event.messageId ?? messageId;
62
+ }
63
+ if (event.type === 'warning') {
64
+ process.stderr.write(`! ${event.message} — response may be incomplete\n`);
65
+ }
66
+ if (event.type === 'error')
67
+ streamError = event.message;
68
+ });
69
+ // Throwing AFTER the stream drains keeps parseSseStream's reader
70
+ // cleanup on its normal path and lets text that arrived before the
71
+ // failure still reach the user. The response was already 200 when the
72
+ // failure happened, so this mid-stream event is the only error signal.
73
+ if (streamError) {
74
+ throw new ApiError({
75
+ code: 'STREAM_ERROR',
76
+ message: sanitizeAgentText(streamError),
77
+ status: 200
78
+ });
79
+ }
80
+ return { stream: true, conversationId, messageId };
81
+ }
82
+ /**
83
+ * The one-shot chat/retry turn both commands share: spinner for the
84
+ * non-streaming wait, run `call`, then print the result — streamed tokens
85
+ * already went to stdout, so finish the line; otherwise print the JSON
86
+ * envelope or the extracted plain text.
87
+ */
88
+ export async function runChatTurn(opts) {
89
+ const stop = maybeSpinner(opts.stream || opts.quiet, 'Thinking…', 300);
90
+ let result;
91
+ try {
92
+ result = await opts.call(opts.stream ? (text) => process.stdout.write(text) : () => { });
93
+ }
94
+ finally {
95
+ stop();
96
+ }
97
+ if (result.stream) {
98
+ process.stdout.write('\n');
99
+ }
100
+ else if (opts.json) {
101
+ process.stdout.write(`${JSON.stringify(result.raw, null, 2)}\n`);
102
+ }
103
+ else {
104
+ process.stdout.write(`${extractText(result.raw)}\n`);
105
+ }
106
+ return result;
107
+ }
108
+ /** Send a message. Shared by the chat command, the REPL, and chat retry. */
109
+ export async function sendChat(opts) {
110
+ const { data, error, response } = await opts.client.POST('/agents/{agentId}/chat', {
111
+ params: { path: { agentId: opts.agentId } },
112
+ body: {
113
+ message: opts.message,
114
+ conversationId: opts.conversationId,
115
+ stream: opts.stream
116
+ },
117
+ parseAs: opts.stream ? 'stream' : 'json',
118
+ signal: opts.signal
119
+ });
120
+ return handleResponse(data, error, response, opts.stream, opts.onText, 'Chat response was empty');
121
+ }
122
+ /** Retry generating a response for a specific message. */
123
+ export async function retryChat(opts) {
124
+ const { data, error, response } = await opts.client.POST('/agents/{agentId}/conversations/{conversationId}/retry', {
125
+ params: {
126
+ path: {
127
+ agentId: opts.agentId,
128
+ conversationId: opts.conversationId
129
+ }
130
+ },
131
+ body: {
132
+ messageId: opts.messageId,
133
+ stream: opts.stream
134
+ },
135
+ parseAs: opts.stream ? 'stream' : 'json',
136
+ signal: opts.signal
137
+ });
138
+ return handleResponse(data, error, response, opts.stream, opts.onText, 'Retry response was empty');
139
+ }
140
+ /**
141
+ * Fetches the last `count` messages of a conversation, oldest first, for
142
+ * the --resume banner. Non-text parts are skipped; long messages are
143
+ * truncated — this is orientation, not a transcript.
144
+ */
145
+ export async function fetchRecentHistory(opts) {
146
+ const count = opts.count ?? 6;
147
+ const maxChars = opts.maxChars ?? 200;
148
+ const { data, error, response } = await opts.client.GET('/agents/{agentId}/conversations/{conversationId}/messages', {
149
+ params: {
150
+ path: {
151
+ agentId: opts.agentId,
152
+ conversationId: opts.conversationId
153
+ },
154
+ query: {}
155
+ }
156
+ });
157
+ throwIfError(response, error);
158
+ const items = data?.data ?? [];
159
+ return items.slice(-count).map((m) => {
160
+ const msg = m;
161
+ const text = sanitizeAgentText((msg.parts ?? [])
162
+ .filter((p) => p.type === 'text' && typeof p.text === 'string')
163
+ .map((p) => p.text)
164
+ .join(''))
165
+ .replace(/\s+/g, ' ')
166
+ .trim();
167
+ return {
168
+ id: msg.id ?? '',
169
+ role: msg.role ?? 'unknown',
170
+ text: text.length > maxChars ? `${text.slice(0, maxChars)}…` : text
171
+ };
172
+ });
173
+ }