@almyty/chat 1.2.0 → 1.4.0

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/dist/args.d.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Command line for `almyty chat`.
3
+ *
4
+ * Parsing lives apart from the entry point so the surface can be tested
5
+ * without a terminal, and so `--help` is generated from the same table
6
+ * the parser reads. A flag that exists and is undocumented, or is
7
+ * documented and does not exist, is a bug either way.
8
+ */
9
+ export interface ChatArgs {
10
+ help: boolean;
11
+ version: boolean;
12
+ /** `<org>/<agent-slug>`, a bare slug, or undefined for the picker. */
13
+ ref?: string;
14
+ resume?: string;
15
+ /** One-shot message. Answers and exits, no REPL. */
16
+ message?: string;
17
+ /** Read the message from stdin rather than a tty. */
18
+ stdin: boolean;
19
+ json: boolean;
20
+ /** False renders the answer once it is complete instead of token by token. */
21
+ stream: boolean;
22
+ /** Explicit colour choice; undefined means "decide from the terminal". */
23
+ color?: boolean;
24
+ maxSteps?: number;
25
+ maxCostCents?: number;
26
+ /** A parse problem worth exiting on, rather than guessing. */
27
+ error?: string;
28
+ }
29
+ export declare function parseArgs(argv: string[]): ChatArgs;
30
+ /**
31
+ * The agent to open: the argument, else $ALMYTY_AGENT.
32
+ *
33
+ * The environment variable is how a compiled terminal app knows which
34
+ * agent it is, since the build compiles this client unchanged and the
35
+ * recipient should not have to type an agent reference.
36
+ */
37
+ export declare function resolveRef(args: ChatArgs, env?: Record<string, string | undefined>): string | undefined;
38
+ /** Split `<org>/<agent>` into its parts; a bare slug leaves org unset. */
39
+ export declare function splitRef(ref: string): {
40
+ orgSlug?: string;
41
+ agentSlug: string;
42
+ };
43
+ /**
44
+ * Whether to run without a terminal UI.
45
+ *
46
+ * A CLI that only works on a tty is half a CLI: piping in a question or
47
+ * out to jq has to work, and ink cannot draw to a pipe.
48
+ */
49
+ export declare function isNonInteractive(args: ChatArgs, io?: {
50
+ stdinTty?: boolean;
51
+ stdoutTty?: boolean;
52
+ }): boolean;
53
+ /**
54
+ * Whether to emit colour.
55
+ *
56
+ * Honours the NO_COLOR convention and FORCE_COLOR, and never colours a
57
+ * pipe unless asked to.
58
+ */
59
+ export declare function useColor(args: ChatArgs, env?: Record<string, string | undefined>, stdoutTty?: boolean): boolean;
60
+ /**
61
+ * `--help`, built from the same command table the REPL resolves
62
+ * against, so a new slash command cannot go undocumented.
63
+ */
64
+ export declare function helpText(version: string): string;
package/dist/args.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Command line for `almyty chat`.
3
+ *
4
+ * Parsing lives apart from the entry point so the surface can be tested
5
+ * without a terminal, and so `--help` is generated from the same table
6
+ * the parser reads. A flag that exists and is undocumented, or is
7
+ * documented and does not exist, is a bug either way.
8
+ */
9
+ import { SLASH_COMMANDS, COMMAND_DESCS } from './commands.js';
10
+ import { EXIT_CODE_HELP } from './exit-codes.js';
11
+ /** Flags that take no value. */
12
+ const BOOLEAN_FLAGS = new Set([
13
+ '--help', '-h', '--version', '-v', '--json', '--stdin',
14
+ '--no-stream', '--stream', '--no-color', '--color',
15
+ ]);
16
+ /** Flags that consume the next argument. */
17
+ const VALUE_FLAGS = new Set(['--resume', '--message', '-m', '--max-steps', '--max-cost-cents']);
18
+ function positiveInt(raw, flag) {
19
+ const n = Number(raw);
20
+ if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) {
21
+ return { error: `${flag} needs a positive whole number, got "${raw}"` };
22
+ }
23
+ return { value: n };
24
+ }
25
+ export function parseArgs(argv) {
26
+ const args = { help: false, version: false, stdin: false, json: false, stream: true };
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const arg = argv[i];
29
+ if (VALUE_FLAGS.has(arg)) {
30
+ const value = argv[i + 1];
31
+ if (value === undefined || value.startsWith('-')) {
32
+ return { ...args, error: `${arg} needs a value` };
33
+ }
34
+ i++;
35
+ switch (arg) {
36
+ case '--resume':
37
+ args.resume = value;
38
+ break;
39
+ case '--message':
40
+ case '-m':
41
+ args.message = value;
42
+ break;
43
+ case '--max-steps': {
44
+ const { value: n, error } = positiveInt(value, arg);
45
+ if (error)
46
+ return { ...args, error };
47
+ args.maxSteps = n;
48
+ break;
49
+ }
50
+ case '--max-cost-cents': {
51
+ const { value: n, error } = positiveInt(value, arg);
52
+ if (error)
53
+ return { ...args, error };
54
+ args.maxCostCents = n;
55
+ break;
56
+ }
57
+ }
58
+ continue;
59
+ }
60
+ if (BOOLEAN_FLAGS.has(arg)) {
61
+ switch (arg) {
62
+ case '--help':
63
+ case '-h':
64
+ args.help = true;
65
+ break;
66
+ case '--version':
67
+ case '-v':
68
+ args.version = true;
69
+ break;
70
+ case '--json':
71
+ args.json = true;
72
+ break;
73
+ case '--stdin':
74
+ args.stdin = true;
75
+ break;
76
+ case '--stream':
77
+ args.stream = true;
78
+ break;
79
+ case '--no-stream':
80
+ args.stream = false;
81
+ break;
82
+ case '--color':
83
+ args.color = true;
84
+ break;
85
+ case '--no-color':
86
+ args.color = false;
87
+ break;
88
+ }
89
+ continue;
90
+ }
91
+ if (arg.startsWith('-')) {
92
+ // Silently ignoring an unknown flag is how `--jsonl` spends an
93
+ // afternoon looking like a broken --json.
94
+ return { ...args, error: `Unknown option: ${arg}\nRun with --help to see what this accepts.` };
95
+ }
96
+ if (args.ref === undefined)
97
+ args.ref = arg;
98
+ else
99
+ return { ...args, error: `Unexpected argument: ${arg}. Pass a message with --message instead.` };
100
+ }
101
+ return args;
102
+ }
103
+ /**
104
+ * The agent to open: the argument, else $ALMYTY_AGENT.
105
+ *
106
+ * The environment variable is how a compiled terminal app knows which
107
+ * agent it is, since the build compiles this client unchanged and the
108
+ * recipient should not have to type an agent reference.
109
+ */
110
+ export function resolveRef(args, env = process.env) {
111
+ return args.ref ?? (env.ALMYTY_AGENT || undefined);
112
+ }
113
+ /** Split `<org>/<agent>` into its parts; a bare slug leaves org unset. */
114
+ export function splitRef(ref) {
115
+ const slash = ref.indexOf('/');
116
+ if (slash === -1)
117
+ return { agentSlug: ref };
118
+ return { orgSlug: ref.slice(0, slash), agentSlug: ref.slice(slash + 1) };
119
+ }
120
+ /**
121
+ * Whether to run without a terminal UI.
122
+ *
123
+ * A CLI that only works on a tty is half a CLI: piping in a question or
124
+ * out to jq has to work, and ink cannot draw to a pipe.
125
+ */
126
+ export function isNonInteractive(args, io = {}) {
127
+ if (args.message !== undefined || args.json || args.stdin)
128
+ return true;
129
+ return io.stdinTty === false || io.stdoutTty === false;
130
+ }
131
+ /**
132
+ * Whether to emit colour.
133
+ *
134
+ * Honours the NO_COLOR convention and FORCE_COLOR, and never colours a
135
+ * pipe unless asked to.
136
+ */
137
+ export function useColor(args, env = process.env, stdoutTty = true) {
138
+ if (args.color !== undefined)
139
+ return args.color;
140
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== '')
141
+ return false;
142
+ if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== '0')
143
+ return true;
144
+ if (env.TERM === 'dumb')
145
+ return false;
146
+ return stdoutTty;
147
+ }
148
+ /**
149
+ * `--help`, built from the same command table the REPL resolves
150
+ * against, so a new slash command cannot go undocumented.
151
+ */
152
+ export function helpText(version) {
153
+ const commands = SLASH_COMMANDS.map((cmd) => ` /${cmd.padEnd(11)}${COMMAND_DESCS[cmd] ?? ''}`).join('\n');
154
+ return `almyty chat v${version} — an interactive REPL for your almyty agents.
155
+
156
+ Usage:
157
+ almyty chat [<org>/<agent-slug>] [options]
158
+
159
+ With no agent reference it lists the agents you can reach and asks.
160
+ A bare slug uses the organization on your credentials.
161
+
162
+ Options:
163
+ -m, --message <text> Ask one question, print the answer, exit.
164
+ --stdin Read the question from stdin (for pipes).
165
+ --resume <id> Continue a previous conversation.
166
+ --json One JSON object per answer. Implies non-interactive.
167
+ --no-stream Wait for the whole answer instead of streaming it.
168
+ --no-color Never colour the output. NO_COLOR is honoured too.
169
+ --max-steps <n> Autonomous runs: cap the number of steps.
170
+ --max-cost-cents <n> Autonomous runs: cap the spend, in cents.
171
+ -h, --help Show this.
172
+ -v, --version Print the version.
173
+
174
+ Slash commands, inside the REPL:
175
+ ${commands}
176
+
177
+ Commands take unique prefixes and aliases: /q for /quit, /sw for /agents.
178
+ Tab completes, up and down walk your input history.
179
+
180
+ Keys:
181
+ Ctrl-C Cancel the running answer (server-side too). Again to exit.
182
+ Ctrl-D Exit.
183
+ Enter Send. End a line with \\ to keep typing on the next one.
184
+
185
+ Non-interactive:
186
+ almyty chat acme/support-bot -m "what is our refund window?"
187
+ echo "summarise today's errors" | almyty chat acme/ops --stdin
188
+ almyty chat acme/ops -m "check the deploy" --json | jq .output
189
+
190
+ The answer goes to stdout and the attribution to stderr, so a pipe
191
+ stays clean.
192
+
193
+ Exit codes:
194
+ ${EXIT_CODE_HELP}
195
+
196
+ Environment:
197
+ ALMYTY_TOKEN Token override, instead of ~/.almyty/credentials.json.
198
+ ALMYTY_URL API URL override.
199
+ ALMYTY_AGENT Default agent reference, used when none is given.
200
+ ALMYTY_APP_URL Dashboard URL used in error messages.
201
+ ALMYTY_CHAT_HISTORY Input history file, instead of ~/.almyty/chat-history.
202
+ NO_COLOR Set to anything to disable colour.
203
+
204
+ Login:
205
+ npx @almyty/auth login
206
+ `;
207
+ }
@@ -1,9 +1,29 @@
1
1
  import type { RunnerSummary } from '@almyty/client';
2
- export declare const SLASH_COMMANDS: readonly ["agents", "tools", "runners", "code", "code-stop", "esc", "help", "clear", "quit"];
2
+ export declare const SLASH_COMMANDS: readonly ["agents", "model", "tools", "cost", "trace", "resume", "new", "runners", "code", "code-stop", "esc", "help", "clear", "quit"];
3
3
  export declare const ALIASES: Record<string, string>;
4
4
  export declare const COMMAND_DESCS: Record<string, string>;
5
5
  export declare function resolveSlash(input: string): string | null;
6
6
  export declare function getSuggestion(partial: string): string;
7
+ /**
8
+ * Whether a submitted line asks to keep typing.
9
+ *
10
+ * A single-line prompt still has to accept a paragraph, so a trailing
11
+ * backslash continues onto the next line the way a shell does. Returns
12
+ * the text without the continuation marker, or null when the line is
13
+ * complete.
14
+ */
15
+ export declare function continuationOf(value: string): string | null;
16
+ /**
17
+ * A pasted block, as one message.
18
+ *
19
+ * A multi-line paste used to submit on its first newline and hand the
20
+ * rest to the prompt a line at a time — so a pasted stack trace became
21
+ * a dozen messages, and any line of it starting with `/` ran as a
22
+ * command. Newlines inside a submitted value are content.
23
+ */
24
+ export declare function joinSubmission(lines: string[]): string;
25
+ /** Whether a submission should be read as a slash command. */
26
+ export declare function isSlashCommand(value: string): boolean;
7
27
  export type InputRoute = 'command' | 'coding' | 'chat';
8
28
  /**
9
29
  * Where a submitted line goes: slash commands are always commands; when a
package/dist/commands.js CHANGED
@@ -1,11 +1,16 @@
1
1
  // ── Slash command resolution ────────────────────────────────────
2
2
  export const SLASH_COMMANDS = [
3
- 'agents', 'tools', 'runners', 'code', 'code-stop', 'esc', 'help', 'clear', 'quit',
3
+ 'agents', 'model', 'tools', 'cost', 'trace', 'resume', 'new',
4
+ 'runners', 'code', 'code-stop', 'esc', 'help', 'clear', 'quit',
4
5
  ];
5
6
  export const ALIASES = {
6
7
  agent: 'agents', ag: 'agents', switch: 'agents', sw: 'agents',
7
8
  tool: 'tools', t: 'tools',
8
- runner: 'runners',
9
+ usage: 'cost', spend: 'cost',
10
+ steps: 'trace', last: 'trace',
11
+ reset: 'new',
12
+ // `r` predates /resume and kept meaning runners; /res resolves resume.
13
+ runner: 'runners', r: 'runners',
9
14
  stop: 'code-stop',
10
15
  detach: 'esc',
11
16
  h: 'help', '?': 'help',
@@ -14,13 +19,18 @@ export const ALIASES = {
14
19
  };
15
20
  export const COMMAND_DESCS = {
16
21
  agents: 'browse and switch agents',
22
+ model: 'show the model and routing policy in use',
17
23
  tools: 'show available tools',
24
+ cost: 'show tokens and spend for this session',
25
+ trace: "show the last run's steps",
26
+ resume: 'print the command that resumes this conversation',
27
+ new: 'start a fresh conversation with the same agent',
18
28
  runners: 'list your runners + coding CLIs',
19
29
  code: 'run a coding task on a runner',
20
30
  'code-stop': 'stop the active coding session',
21
31
  esc: 'leave coding mode (session keeps running)',
22
32
  help: 'show commands',
23
- clear: 'clear conversation',
33
+ clear: 'clear the transcript on screen',
24
34
  quit: 'exit',
25
35
  };
26
36
  export function resolveSlash(input) {
@@ -43,13 +53,49 @@ export function getSuggestion(partial) {
43
53
  const match = SLASH_COMMANDS.find(c => c.startsWith(p) && c !== p);
44
54
  return match ? `/${match}` : '';
45
55
  }
56
+ // ── Multi-line input ────────────────────────────────────────────
57
+ /**
58
+ * Whether a submitted line asks to keep typing.
59
+ *
60
+ * A single-line prompt still has to accept a paragraph, so a trailing
61
+ * backslash continues onto the next line the way a shell does. Returns
62
+ * the text without the continuation marker, or null when the line is
63
+ * complete.
64
+ */
65
+ export function continuationOf(value) {
66
+ if (!/\\$/.test(value))
67
+ return null;
68
+ // An escaped backslash at the end is a literal one, not a hinge.
69
+ const trailing = value.length - value.replace(/\\+$/, '').length;
70
+ if (trailing % 2 === 0)
71
+ return null;
72
+ return value.slice(0, -1);
73
+ }
74
+ /**
75
+ * A pasted block, as one message.
76
+ *
77
+ * A multi-line paste used to submit on its first newline and hand the
78
+ * rest to the prompt a line at a time — so a pasted stack trace became
79
+ * a dozen messages, and any line of it starting with `/` ran as a
80
+ * command. Newlines inside a submitted value are content.
81
+ */
82
+ export function joinSubmission(lines) {
83
+ return lines.join('\n').replace(/\s+$/, '');
84
+ }
85
+ /** Whether a submission should be read as a slash command. */
86
+ export function isSlashCommand(value) {
87
+ const trimmed = value.trim();
88
+ // Only a single-line submission can be a command: a pasted block that
89
+ // happens to begin with a slash is text.
90
+ return trimmed.startsWith('/') && !trimmed.includes('\n');
91
+ }
46
92
  /**
47
93
  * Where a submitted line goes: slash commands are always commands; when a
48
94
  * coding session is active, everything else routes to the session's stdin;
49
95
  * otherwise it's a normal chat message.
50
96
  */
51
97
  export function classifyInput(value, codingActive) {
52
- if (value.trim().startsWith('/'))
98
+ if (isSlashCommand(value))
53
99
  return 'command';
54
100
  return codingActive ? 'coding' : 'chat';
55
101
  }
@@ -13,13 +13,19 @@ export declare function InlineFormat({ text }: {
13
13
  text: string;
14
14
  }): React.JSX.Element;
15
15
  export declare function boldify(text: string): React.ReactElement;
16
+ /**
17
+ * Re-exported so callers do not have to know the arithmetic moved.
18
+ * Takes a message rather than text, which is how it has always read.
19
+ */
16
20
  export declare function estimateLines(msg: Message, cols: number): number;
17
- export declare function MessageWindow({ messages, loading, loadingLabel, maxRows, scrollOffset }: {
21
+ export declare function MessageWindow({ messages, loading, loadingLabel, maxRows, scrollOffset, streaming }: {
18
22
  messages: Message[];
19
23
  loading: boolean;
20
24
  loadingLabel: string;
21
25
  maxRows: number;
22
26
  scrollOffset?: number;
27
+ /** Assistant text arriving right now, drawn below the transcript. */
28
+ streaming?: string;
23
29
  }): React.JSX.Element;
24
30
  export declare function Header({ agent, conversationId }: {
25
31
  agent: AgentInfo;
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import Spinner from 'ink-spinner';
5
+ import { columns, estimateLines as estimateMessageLines, selectWindow } from './viewport.js';
5
6
  // ── Simple markdown rendering ───────────────────────────────────
6
7
  export function MarkdownText({ children, prefix, prefixColor }) {
7
8
  const lines = children.split('\n');
@@ -90,35 +91,26 @@ export function boldify(text) {
90
91
  }
91
92
  return _jsx(_Fragment, { children: parts });
92
93
  }
93
- // ── Message window (manual scroll) ──────────────────────────────
94
+ // ── Message window (bounded to the terminal) ────────────────────
95
+ /**
96
+ * Re-exported so callers do not have to know the arithmetic moved.
97
+ * Takes a message rather than text, which is how it has always read.
98
+ */
94
99
  export function estimateLines(msg, cols) {
95
- const textWidth = Math.max(cols - 10, 20);
96
- const lines = msg.text.split('\n');
97
- let total = 1; // margin
98
- for (const line of lines) {
99
- total += Math.max(1, Math.ceil(Math.max(line.length, 1) / textWidth));
100
- }
101
- return total;
100
+ return estimateMessageLines(msg.text, cols);
102
101
  }
103
- export function MessageWindow({ messages, loading, loadingLabel, maxRows, scrollOffset = 0 }) {
104
- const cols = process.stdout.columns || 80;
105
- const available = Math.max(maxRows, 5);
106
- // Calculate the end of the visible window (shifted by scrollOffset)
107
- const endIdx = Math.max(0, messages.length - scrollOffset);
108
- // Walk backwards from endIdx to find how many messages fit
109
- let usedRows = loading && scrollOffset === 0 ? 2 : 0;
110
- let startIdx = endIdx;
111
- for (let i = endIdx - 1; i >= 0; i--) {
112
- const est = estimateLines(messages[i], cols);
113
- if (usedRows + est > available && i < endIdx - 1)
114
- break;
115
- usedRows += est;
116
- startIdx = i;
117
- }
102
+ export function MessageWindow({ messages, loading, loadingLabel, maxRows, scrollOffset = 0, streaming }) {
103
+ const cols = columns(process.stdout.columns);
104
+ const streamRows = streaming ? estimateMessageLines(streaming, cols) : 0;
105
+ const { startIdx, endIdx, hiddenBefore, hiddenAfter } = selectWindow(messages, {
106
+ rows: maxRows,
107
+ cols,
108
+ // The spinner and the text still arriving both need room.
109
+ reservedRows: (loading && scrollOffset === 0 ? 2 : 0) + streamRows,
110
+ scrollOffset,
111
+ });
118
112
  const visible = messages.slice(startIdx, endIdx);
119
- const hasEarlier = startIdx > 0;
120
- const hasLater = endIdx < messages.length;
121
- return (_jsxs(Box, { flexDirection: "column", children: [hasEarlier && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2191 ", startIdx, " earlier \u00B7 scroll to see more"] }) })), visible.map((msg, i) => (_jsx(MessageView, { msg: msg }, startIdx + i))), loading && scrollOffset === 0 && _jsx(LoadingIndicator, { label: loadingLabel }), hasLater && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2193 ", messages.length - endIdx, " newer \u00B7 scroll to see more"] }) }))] }));
113
+ return (_jsxs(Box, { flexDirection: "column", children: [hiddenBefore > 0 && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2191 ", hiddenBefore, " earlier \u00B7 scroll to see more"] }) })), visible.map((msg, i) => (_jsx(MessageView, { msg: msg }, startIdx + i))), streaming && scrollOffset === 0 && _jsx(MessageView, { msg: { role: 'agent', text: streaming } }), loading && scrollOffset === 0 && _jsx(LoadingIndicator, { label: loadingLabel }), hiddenAfter > 0 && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2193 ", hiddenAfter, " newer \u00B7 scroll to see more"] }) }))] }));
122
114
  }
123
115
  // ── Components ──────────────────────────────────────────────────
124
116
  export function Header({ agent, conversationId }) {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * One sentence a user can act on, for every way a chat session fails.
3
+ *
4
+ * A REPL that answers "API error 400: {"success":false,...}" has told the
5
+ * user nothing. Every failure a session can reach has a cause and a next
6
+ * step, and both are known here rather than left for the reader to infer
7
+ * from a status code.
8
+ *
9
+ * Kept free of ink and of the network client so it can be tested as the
10
+ * pure mapping it is.
11
+ */
12
+ /** What the session knows about itself when something fails. */
13
+ export interface ErrorContext {
14
+ /** `<org>/<agent-slug>`, as the user typed it. */
15
+ agentRef?: string;
16
+ /** API base URL, for "cannot reach" messages. */
17
+ apiUrl?: string;
18
+ /** Dashboard URL, for "fix it here" messages. */
19
+ appUrl?: string;
20
+ /** What the session was doing: shapes the timeout wording. */
21
+ what?: 'run' | 'info' | 'history' | 'stream' | 'cancel';
22
+ }
23
+ export declare const DEFAULT_APP_URL = "https://app.almyty.com";
24
+ interface Failure {
25
+ status?: number;
26
+ code?: string;
27
+ serverMessage?: string;
28
+ message: string;
29
+ network: boolean;
30
+ aborted: boolean;
31
+ pollTimeout: boolean;
32
+ }
33
+ /** Pull everything useful off a thrown value, whatever shape it has. */
34
+ export declare function inspectError(err: unknown): Failure;
35
+ /** The model name out of a MODEL_NOT_FOUND message, when it names one. */
36
+ export declare function extractModelName(message: string): string | null;
37
+ /**
38
+ * Turn a thrown value into one sentence, plus the command or URL that
39
+ * fixes it.
40
+ */
41
+ export declare function explainError(err: unknown, ctx?: ErrorContext): string;
42
+ /**
43
+ * Why `<org>/<agent>` could not be opened at startup.
44
+ *
45
+ * Startup is the one place a bad reference and a bad login look the
46
+ * same, and where "Agent not found" was printed for both.
47
+ */
48
+ export declare function explainStartupFailure(err: unknown, ctx?: ErrorContext): string;
49
+ export {};
package/dist/errors.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * One sentence a user can act on, for every way a chat session fails.
3
+ *
4
+ * A REPL that answers "API error 400: {"success":false,...}" has told the
5
+ * user nothing. Every failure a session can reach has a cause and a next
6
+ * step, and both are known here rather than left for the reader to infer
7
+ * from a status code.
8
+ *
9
+ * Kept free of ink and of the network client so it can be tested as the
10
+ * pure mapping it is.
11
+ */
12
+ export const DEFAULT_APP_URL = 'https://app.almyty.com';
13
+ /** Pull everything useful off a thrown value, whatever shape it has. */
14
+ export function inspectError(err) {
15
+ const e = (err ?? {});
16
+ const message = typeof e.message === 'string' && e.message ? e.message : String(err ?? 'Unknown error');
17
+ let status = typeof e.status === 'number' ? e.status : undefined;
18
+ if (status === undefined) {
19
+ // Errors that crossed a package boundary as plain strings.
20
+ const m = message.match(/^(?:API error|SSE) (\d{3})/);
21
+ if (m)
22
+ status = Number(m[1]);
23
+ }
24
+ let code;
25
+ let serverMessage;
26
+ const body = typeof e.body === 'string' ? e.body : message;
27
+ const jsonStart = body.indexOf('{');
28
+ if (jsonStart !== -1) {
29
+ try {
30
+ const parsed = JSON.parse(body.slice(jsonStart));
31
+ if (parsed && typeof parsed === 'object') {
32
+ if (typeof parsed.error === 'string')
33
+ code = parsed.error;
34
+ if (typeof parsed.code === 'string')
35
+ code = parsed.code;
36
+ if (typeof parsed.message === 'string')
37
+ serverMessage = parsed.message;
38
+ }
39
+ }
40
+ catch {
41
+ /* not JSON; the raw message is all there is */
42
+ }
43
+ }
44
+ if (!code && typeof e.code === 'string' && /^[A-Z_]+$/.test(e.code))
45
+ code = e.code;
46
+ const errno = typeof e.code === 'string' ? e.code : e.cause?.code;
47
+ const network = e.networkError === true ||
48
+ ['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'UND_ERR_SOCKET'].includes(errno) ||
49
+ /fetch failed|network|socket hang up|ECONNRESET/i.test(message);
50
+ return {
51
+ status,
52
+ code,
53
+ serverMessage,
54
+ message,
55
+ network,
56
+ aborted: e.name === 'AbortError' || e.code === 'ABORT_ERR',
57
+ pollTimeout: e.pollTimeout === true || /did not finish within/.test(message),
58
+ };
59
+ }
60
+ /** The model name out of a MODEL_NOT_FOUND message, when it names one. */
61
+ export function extractModelName(message) {
62
+ const quoted = message.match(/"([^"]{2,80})"\s+is not available/) || message.match(/model\s+"([^"]{2,80})"/i);
63
+ return quoted ? quoted[1] : null;
64
+ }
65
+ /**
66
+ * Turn a thrown value into one sentence, plus the command or URL that
67
+ * fixes it.
68
+ */
69
+ export function explainError(err, ctx = {}) {
70
+ const f = inspectError(err);
71
+ const ref = ctx.agentRef ?? 'this agent';
72
+ const app = ctx.appUrl ?? DEFAULT_APP_URL;
73
+ if (f.aborted)
74
+ return 'Cancelled.';
75
+ if (f.network) {
76
+ const host = ctx.apiUrl ?? 'the almyty API';
77
+ return `Cannot reach ${host}. Check your connection, or ALMYTY_URL if you are pointing at your own deployment.`;
78
+ }
79
+ // Codes first: the server names the cause, so use its word for it.
80
+ switch (f.code) {
81
+ case 'AGENT_NOT_ACTIVE':
82
+ return `${ref} is not active, so it will not answer. Open ${app}/agents, activate it, and try again.`;
83
+ case 'AGENT_AUTH_REQUIRED':
84
+ case 'AGENT_AUTH_INVALID':
85
+ return 'Not authenticated. Run: npx @almyty/auth login';
86
+ case 'AGENT_AUTH_EXPIRED':
87
+ return 'Your credentials have expired. Run: npx @almyty/auth login';
88
+ case 'AGENT_AUTH_FORBIDDEN':
89
+ return `Your account has no access to the organization that owns ${ref}. Check the org in the reference, or log in as an account that does: npx @almyty/auth login`;
90
+ case 'MODEL_NOT_FOUND': {
91
+ const model = extractModelName(f.serverMessage ?? f.message);
92
+ return `${ref} is configured to use ${model ? `the model ${model}, which` : 'a model that'} its provider no longer serves. Pick a model that is available at ${app}/agents — scheduled runs stay paused until you do.`;
93
+ }
94
+ case 'BUDGET_EXCEEDED':
95
+ case 'SPEND_LIMIT_EXCEEDED':
96
+ return `This organization has reached its spend cap, so no new model calls will run. Raise it or wait for the period to reset at ${app}/settings.`;
97
+ }
98
+ const body = f.serverMessage ?? f.message;
99
+ if (/MODEL_NOT_FOUND|is not available/i.test(body)) {
100
+ const model = extractModelName(body);
101
+ return `${ref} is configured to use ${model ? `the model ${model}, which` : 'a model that'} its provider no longer serves. Pick a model that is available at ${app}/agents — scheduled runs stay paused until you do.`;
102
+ }
103
+ if (/no LLM provider configured|modelConfig\.providerId/i.test(body)) {
104
+ return `${ref} has no model configured. Open it at ${app}/agents and choose a model or a routing policy.`;
105
+ }
106
+ if (/not autonomous/i.test(body)) {
107
+ return `${ref} is a workflow agent, so it has no multi-turn run to continue. It answers one message at a time and that is what this session will do.`;
108
+ }
109
+ if (/budget|spend (cap|limit)|quota/i.test(body)) {
110
+ return `This organization has reached its spend cap, so no new model calls will run. Raise it or wait for the period to reset at ${app}/settings.`;
111
+ }
112
+ if (f.pollTimeout) {
113
+ return 'The run is taking longer than this session will wait. It is still going server-side — reconnect with --resume once it finishes, or stop it from the dashboard.';
114
+ }
115
+ switch (f.status) {
116
+ case 400:
117
+ return `${ref} rejected the request: ${body}`;
118
+ case 401:
119
+ return 'Not authenticated, or the stored token has expired. Run: npx @almyty/auth login';
120
+ case 403:
121
+ return `Your account has no access to ${ref}. Check the organization in the reference, or log in as an account that does: npx @almyty/auth login`;
122
+ case 404:
123
+ return `No agent at ${ref}. See what you can reach with: npx @almyty/agents list`;
124
+ case 409:
125
+ return `${ref} is busy with a conflicting request: ${body}`;
126
+ case 429:
127
+ return 'Rate limited. Wait a few seconds and send it again.';
128
+ case 500:
129
+ case 502:
130
+ case 503:
131
+ case 504:
132
+ return `almyty's API answered ${f.status}. This is server-side — retry in a moment, and check status if it keeps happening.`;
133
+ }
134
+ return body;
135
+ }
136
+ /**
137
+ * Why `<org>/<agent>` could not be opened at startup.
138
+ *
139
+ * Startup is the one place a bad reference and a bad login look the
140
+ * same, and where "Agent not found" was printed for both.
141
+ */
142
+ export function explainStartupFailure(err, ctx = {}) {
143
+ return explainError(err, { ...ctx, what: 'info' });
144
+ }