@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/README.md +163 -24
- package/dist/app.d.ts +3 -1
- package/dist/app.js +283 -177
- package/dist/args.d.ts +64 -0
- package/dist/args.js +207 -0
- package/dist/commands.d.ts +21 -1
- package/dist/commands.js +50 -4
- package/dist/components.d.ts +7 -1
- package/dist/components.js +18 -26
- package/dist/errors.d.ts +49 -0
- package/dist/errors.js +144 -0
- package/dist/exit-codes.d.ts +32 -0
- package/dist/exit-codes.js +65 -0
- package/dist/headless.d.ts +44 -0
- package/dist/headless.js +106 -0
- package/dist/history.d.ts +51 -0
- package/dist/history.js +146 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +112 -40
- package/dist/stream.d.ts +95 -0
- package/dist/stream.js +276 -0
- package/dist/turn.d.ts +53 -0
- package/dist/turn.js +147 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +27 -0
- package/dist/viewport.d.ts +61 -0
- package/dist/viewport.js +118 -0
- package/package.json +9 -5
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const EXIT: {
|
|
2
|
+
/** Success. */
|
|
3
|
+
readonly OK: 0;
|
|
4
|
+
/** Unexpected failure (a thrown error with no better classification). */
|
|
5
|
+
readonly ERROR: 1;
|
|
6
|
+
/** Bad or missing arguments, or an unknown command. */
|
|
7
|
+
readonly USAGE: 2;
|
|
8
|
+
/** No stored credential, or the API rejected the one we had. */
|
|
9
|
+
readonly AUTH: 3;
|
|
10
|
+
/** The named agent / gateway / skill / run does not exist. */
|
|
11
|
+
readonly NOT_FOUND: 4;
|
|
12
|
+
/** The command ran; the operation it asked for failed. */
|
|
13
|
+
readonly FAILED: 5;
|
|
14
|
+
};
|
|
15
|
+
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
|
|
16
|
+
/** One line per code, for `--help` output and READMEs. */
|
|
17
|
+
export declare const EXIT_CODE_HELP: string;
|
|
18
|
+
/**
|
|
19
|
+
* Classify a thrown value into an exit code.
|
|
20
|
+
*
|
|
21
|
+
* The sibling CLIs read the status back out of the message string.
|
|
22
|
+
* This one does not have to: the shared client now puts `status` on
|
|
23
|
+
* the error it throws, which `inspectError` reads.
|
|
24
|
+
*/
|
|
25
|
+
export declare function exitCodeForError(err: unknown): ExitCode;
|
|
26
|
+
/**
|
|
27
|
+
* The exit code a finished turn earns, so a chat call can gate a shell
|
|
28
|
+
* script: a run that ran and failed is 5, not 1, so
|
|
29
|
+
* `almyty chat deploy-check -m "ok?" || case $? in 5) ...` can tell a
|
|
30
|
+
* failed answer from a broken invocation.
|
|
31
|
+
*/
|
|
32
|
+
export declare function exitCodeForStatus(status: 'completed' | 'failed' | 'cancelled' | 'waiting_input'): ExitCode;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes shared by every almyty CLI.
|
|
3
|
+
*
|
|
4
|
+
* Scripts need to tell "you are not logged in" apart from "that agent
|
|
5
|
+
* does not exist" apart from "the run you asked for failed" without
|
|
6
|
+
* grepping stderr. Every almyty CLI uses this same table, so
|
|
7
|
+
* `almyty agents run x || case $? in 3) almyty login;; esac` behaves
|
|
8
|
+
* the same whichever binary produced the code.
|
|
9
|
+
*/
|
|
10
|
+
import { inspectError } from './errors.js';
|
|
11
|
+
export const EXIT = {
|
|
12
|
+
/** Success. */
|
|
13
|
+
OK: 0,
|
|
14
|
+
/** Unexpected failure (a thrown error with no better classification). */
|
|
15
|
+
ERROR: 1,
|
|
16
|
+
/** Bad or missing arguments, or an unknown command. */
|
|
17
|
+
USAGE: 2,
|
|
18
|
+
/** No stored credential, or the API rejected the one we had. */
|
|
19
|
+
AUTH: 3,
|
|
20
|
+
/** The named agent / gateway / skill / run does not exist. */
|
|
21
|
+
NOT_FOUND: 4,
|
|
22
|
+
/** The command ran; the operation it asked for failed. */
|
|
23
|
+
FAILED: 5,
|
|
24
|
+
};
|
|
25
|
+
/** One line per code, for `--help` output and READMEs. */
|
|
26
|
+
export const EXIT_CODE_HELP = [
|
|
27
|
+
' 0 success',
|
|
28
|
+
' 1 unexpected error',
|
|
29
|
+
' 2 usage error (bad flags, unknown command)',
|
|
30
|
+
' 3 not authenticated — run `almyty login`',
|
|
31
|
+
' 4 not found (agent, gateway, skill, or run)',
|
|
32
|
+
' 5 the operation ran and failed',
|
|
33
|
+
].join('\n');
|
|
34
|
+
/**
|
|
35
|
+
* Classify a thrown value into an exit code.
|
|
36
|
+
*
|
|
37
|
+
* The sibling CLIs read the status back out of the message string.
|
|
38
|
+
* This one does not have to: the shared client now puts `status` on
|
|
39
|
+
* the error it throws, which `inspectError` reads.
|
|
40
|
+
*/
|
|
41
|
+
export function exitCodeForError(err) {
|
|
42
|
+
const f = inspectError(err);
|
|
43
|
+
if (f.aborted)
|
|
44
|
+
return EXIT.FAILED;
|
|
45
|
+
if (f.network)
|
|
46
|
+
return EXIT.ERROR;
|
|
47
|
+
if (f.code === 'AGENT_AUTH_REQUIRED' || f.code === 'AGENT_AUTH_INVALID' || f.code === 'AGENT_AUTH_EXPIRED')
|
|
48
|
+
return EXIT.AUTH;
|
|
49
|
+
if (f.status === 401 || f.status === 403 || f.code === 'AGENT_AUTH_FORBIDDEN')
|
|
50
|
+
return EXIT.AUTH;
|
|
51
|
+
if (f.status === 404)
|
|
52
|
+
return EXIT.NOT_FOUND;
|
|
53
|
+
if (f.status !== undefined && f.status >= 400)
|
|
54
|
+
return EXIT.FAILED;
|
|
55
|
+
return EXIT.ERROR;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The exit code a finished turn earns, so a chat call can gate a shell
|
|
59
|
+
* script: a run that ran and failed is 5, not 1, so
|
|
60
|
+
* `almyty chat deploy-check -m "ok?" || case $? in 5) ...` can tell a
|
|
61
|
+
* failed answer from a broken invocation.
|
|
62
|
+
*/
|
|
63
|
+
export function exitCodeForStatus(status) {
|
|
64
|
+
return status === 'completed' || status === 'waiting_input' ? EXIT.OK : EXIT.FAILED;
|
|
65
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chat without a terminal.
|
|
3
|
+
*
|
|
4
|
+
* A CLI that only draws to a tty cannot be piped, scripted or put in
|
|
5
|
+
* CI, which is half of what a CLI is for. This path answers one
|
|
6
|
+
* question — from --message, or from stdin — writes the answer to
|
|
7
|
+
* stdout, writes attribution to stderr so stdout stays clean for a
|
|
8
|
+
* pipe, and exits 0 or 1 on whether the run actually completed.
|
|
9
|
+
*
|
|
10
|
+
* `--json` prints one object per answer instead, with the text, the
|
|
11
|
+
* cost and the ids needed to resume.
|
|
12
|
+
*/
|
|
13
|
+
import type { AgentInfo, RunLimits } from '@almyty/client';
|
|
14
|
+
import { type ErrorContext } from './errors.js';
|
|
15
|
+
import { type TurnResult, type TurnTarget } from './turn.js';
|
|
16
|
+
export interface HeadlessIO {
|
|
17
|
+
out(text: string): void;
|
|
18
|
+
err(text: string): void;
|
|
19
|
+
}
|
|
20
|
+
export interface HeadlessOptions {
|
|
21
|
+
message: string;
|
|
22
|
+
agent: AgentInfo;
|
|
23
|
+
target: TurnTarget;
|
|
24
|
+
json: boolean;
|
|
25
|
+
/** False buffers the answer and prints it once. */
|
|
26
|
+
stream: boolean;
|
|
27
|
+
conversationId?: string;
|
|
28
|
+
limits?: RunLimits;
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
io: HeadlessIO;
|
|
31
|
+
errorContext?: ErrorContext;
|
|
32
|
+
}
|
|
33
|
+
/** Everything --json promises, in one object. */
|
|
34
|
+
export declare function jsonResult(result: TurnResult, agent: AgentInfo): Record<string, unknown>;
|
|
35
|
+
/** Read all of stdin, for `echo "..." | almyty chat <agent> --stdin`. */
|
|
36
|
+
export declare function readStdin(stream: AsyncIterable<string | Buffer>): Promise<string>;
|
|
37
|
+
/**
|
|
38
|
+
* Answer once and return the process exit code.
|
|
39
|
+
*
|
|
40
|
+
* Streaming to a pipe writes the tokens as they arrive, which is what
|
|
41
|
+
* makes `almyty chat bot -m "..." | less` feel alive; --json buffers,
|
|
42
|
+
* because half a JSON object is not JSON.
|
|
43
|
+
*/
|
|
44
|
+
export declare function runHeadless(options: HeadlessOptions): Promise<number>;
|
package/dist/headless.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chat without a terminal.
|
|
3
|
+
*
|
|
4
|
+
* A CLI that only draws to a tty cannot be piped, scripted or put in
|
|
5
|
+
* CI, which is half of what a CLI is for. This path answers one
|
|
6
|
+
* question — from --message, or from stdin — writes the answer to
|
|
7
|
+
* stdout, writes attribution to stderr so stdout stays clean for a
|
|
8
|
+
* pipe, and exits 0 or 1 on whether the run actually completed.
|
|
9
|
+
*
|
|
10
|
+
* `--json` prints one object per answer instead, with the text, the
|
|
11
|
+
* cost and the ids needed to resume.
|
|
12
|
+
*/
|
|
13
|
+
import { explainError } from './errors.js';
|
|
14
|
+
import { exitCodeForError, exitCodeForStatus } from './exit-codes.js';
|
|
15
|
+
import { formatUsage } from './stream.js';
|
|
16
|
+
import { runTurn } from './turn.js';
|
|
17
|
+
/** Everything --json promises, in one object. */
|
|
18
|
+
export function jsonResult(result, agent) {
|
|
19
|
+
return {
|
|
20
|
+
status: result.status,
|
|
21
|
+
output: result.text,
|
|
22
|
+
agent: { id: agent.id, name: agent.name, slug: agent.slug, mode: agent.mode },
|
|
23
|
+
model: result.usage.model ?? null,
|
|
24
|
+
routing: result.usage.model
|
|
25
|
+
? { model: result.usage.model, rationale: result.usage.rationale ?? null, attempt: result.usage.attempt ?? null }
|
|
26
|
+
: null,
|
|
27
|
+
usage: { cost: result.usage.cost, tokens: result.usage.tokens, steps: result.usage.steps },
|
|
28
|
+
runId: result.runId ?? null,
|
|
29
|
+
conversationId: result.conversationId ?? null,
|
|
30
|
+
...(result.error ? { error: result.error } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Read all of stdin, for `echo "..." | almyty chat <agent> --stdin`. */
|
|
34
|
+
export async function readStdin(stream) {
|
|
35
|
+
const parts = [];
|
|
36
|
+
for await (const chunk of stream)
|
|
37
|
+
parts.push(typeof chunk === 'string' ? chunk : chunk.toString('utf-8'));
|
|
38
|
+
return parts.join('').trim();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Answer once and return the process exit code.
|
|
42
|
+
*
|
|
43
|
+
* Streaming to a pipe writes the tokens as they arrive, which is what
|
|
44
|
+
* makes `almyty chat bot -m "..." | less` feel alive; --json buffers,
|
|
45
|
+
* because half a JSON object is not JSON.
|
|
46
|
+
*/
|
|
47
|
+
export async function runHeadless(options) {
|
|
48
|
+
const { io, json, agent } = options;
|
|
49
|
+
let written = 0;
|
|
50
|
+
try {
|
|
51
|
+
const result = await runTurn(options.target, options.message, {
|
|
52
|
+
mode: agent.mode,
|
|
53
|
+
conversationId: options.conversationId,
|
|
54
|
+
limits: options.limits,
|
|
55
|
+
signal: options.signal,
|
|
56
|
+
hooks: json || !options.stream
|
|
57
|
+
? undefined
|
|
58
|
+
: {
|
|
59
|
+
partial: (text) => {
|
|
60
|
+
// Only the new tail: the reducer hands back the whole
|
|
61
|
+
// buffer each time.
|
|
62
|
+
if (text.length > written) {
|
|
63
|
+
io.out(text.slice(written));
|
|
64
|
+
written = text.length;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (json) {
|
|
70
|
+
io.out(JSON.stringify(jsonResult(result, agent), null, 2) + '\n');
|
|
71
|
+
return exitCodeForStatus(result.status);
|
|
72
|
+
}
|
|
73
|
+
if (options.stream) {
|
|
74
|
+
// Anything the stream did not already print (a non-streaming
|
|
75
|
+
// provider, or a final output that replaced the buffer).
|
|
76
|
+
if (result.text.length > written)
|
|
77
|
+
io.out(result.text.slice(written));
|
|
78
|
+
if (result.text)
|
|
79
|
+
io.out('\n');
|
|
80
|
+
}
|
|
81
|
+
else if (result.text) {
|
|
82
|
+
io.out(result.text + '\n');
|
|
83
|
+
}
|
|
84
|
+
const attribution = formatUsage(result.usage);
|
|
85
|
+
if (attribution)
|
|
86
|
+
io.err(attribution + '\n');
|
|
87
|
+
if (result.status === 'failed')
|
|
88
|
+
io.err(`Run failed: ${result.error ?? 'no reason given'}\n`);
|
|
89
|
+
if (result.status === 'cancelled')
|
|
90
|
+
io.err('Cancelled.\n');
|
|
91
|
+
if (result.status === 'waiting_input' && result.conversationId) {
|
|
92
|
+
io.err(`The agent asked a question. Continue with: almyty chat ${agent.slug ?? agent.name} --resume ${result.conversationId}\n`);
|
|
93
|
+
}
|
|
94
|
+
return exitCodeForStatus(result.status);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
const message = explainError(err, options.errorContext);
|
|
98
|
+
if (json) {
|
|
99
|
+
io.out(JSON.stringify({ status: 'error', error: message }, null, 2) + '\n');
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
io.err(message + '\n');
|
|
103
|
+
}
|
|
104
|
+
return exitCodeForError(err);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input history that survives the session.
|
|
3
|
+
*
|
|
4
|
+
* History used to be derived from the messages on screen, so it emptied
|
|
5
|
+
* on /clear and every new session started with nothing to press up
|
|
6
|
+
* into. It is kept in a file next to the credentials instead, one entry
|
|
7
|
+
* per line, oldest first.
|
|
8
|
+
*
|
|
9
|
+
* Nothing the agent says is written here — only what the user typed —
|
|
10
|
+
* and slash commands are kept because re-running one is the common case.
|
|
11
|
+
*/
|
|
12
|
+
/** How many lines are kept. Beyond this the oldest are dropped. */
|
|
13
|
+
export declare const HISTORY_LIMIT = 500;
|
|
14
|
+
/** Longest line kept, so a pasted file cannot bloat the file. */
|
|
15
|
+
export declare const HISTORY_MAX_LINE = 4000;
|
|
16
|
+
export declare function historyFile(env?: Record<string, string | undefined>): string;
|
|
17
|
+
/** Newlines are the record separator, so they are escaped on the way in. */
|
|
18
|
+
export declare function encodeEntry(value: string): string;
|
|
19
|
+
export declare function decodeEntry(value: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* How many appends go by before the file is trimmed.
|
|
22
|
+
*
|
|
23
|
+
* Trimming reads and rewrites the whole file, so doing it on every
|
|
24
|
+
* message put two extra filesystem round-trips in front of every turn.
|
|
25
|
+
* The file is allowed to run a little over the limit between trims.
|
|
26
|
+
*/
|
|
27
|
+
export declare const TRIM_EVERY = 50;
|
|
28
|
+
/** Forget the in-process memo. Tests use this; nothing else needs it. */
|
|
29
|
+
export declare function resetHistoryState(): void;
|
|
30
|
+
/** Oldest first. Never throws: no history is a worse day, not a crash. */
|
|
31
|
+
export declare function loadHistory(file?: string): string[];
|
|
32
|
+
/** Cut the file back to the newest HISTORY_LIMIT entries. */
|
|
33
|
+
export declare function trimHistory(file?: string): void;
|
|
34
|
+
/**
|
|
35
|
+
* Append one entry, skipping a repeat of the line before it.
|
|
36
|
+
*
|
|
37
|
+
* One filesystem write on the common path: this runs on the keystroke
|
|
38
|
+
* that submits a message, so it is not the place for a read, a mkdir
|
|
39
|
+
* and a rewrite.
|
|
40
|
+
*/
|
|
41
|
+
export declare function appendHistory(entry: string, file?: string): void;
|
|
42
|
+
/**
|
|
43
|
+
* Where up/down land, given how far back the cursor already is.
|
|
44
|
+
*
|
|
45
|
+
* `idx` is -1 for "at the live prompt" and counts backwards from the
|
|
46
|
+
* newest entry. Returns the new index and the text to show.
|
|
47
|
+
*/
|
|
48
|
+
export declare function walkHistory(history: string[], idx: number, direction: 'up' | 'down'): {
|
|
49
|
+
idx: number;
|
|
50
|
+
value: string;
|
|
51
|
+
};
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input history that survives the session.
|
|
3
|
+
*
|
|
4
|
+
* History used to be derived from the messages on screen, so it emptied
|
|
5
|
+
* on /clear and every new session started with nothing to press up
|
|
6
|
+
* into. It is kept in a file next to the credentials instead, one entry
|
|
7
|
+
* per line, oldest first.
|
|
8
|
+
*
|
|
9
|
+
* Nothing the agent says is written here — only what the user typed —
|
|
10
|
+
* and slash commands are kept because re-running one is the common case.
|
|
11
|
+
*/
|
|
12
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
/** How many lines are kept. Beyond this the oldest are dropped. */
|
|
16
|
+
export const HISTORY_LIMIT = 500;
|
|
17
|
+
/** Longest line kept, so a pasted file cannot bloat the file. */
|
|
18
|
+
export const HISTORY_MAX_LINE = 4000;
|
|
19
|
+
export function historyFile(env = process.env) {
|
|
20
|
+
if (env.ALMYTY_CHAT_HISTORY)
|
|
21
|
+
return env.ALMYTY_CHAT_HISTORY;
|
|
22
|
+
return join(homedir(), '.almyty', 'chat-history');
|
|
23
|
+
}
|
|
24
|
+
/** Newlines are the record separator, so they are escaped on the way in. */
|
|
25
|
+
export function encodeEntry(value) {
|
|
26
|
+
return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
|
|
27
|
+
}
|
|
28
|
+
export function decodeEntry(value) {
|
|
29
|
+
let out = '';
|
|
30
|
+
for (let i = 0; i < value.length; i++) {
|
|
31
|
+
if (value[i] === '\\' && i + 1 < value.length) {
|
|
32
|
+
const next = value[i + 1];
|
|
33
|
+
if (next === 'n') {
|
|
34
|
+
out += '\n';
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (next === '\\') {
|
|
39
|
+
out += '\\';
|
|
40
|
+
i++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
out += value[i];
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* How many appends go by before the file is trimmed.
|
|
50
|
+
*
|
|
51
|
+
* Trimming reads and rewrites the whole file, so doing it on every
|
|
52
|
+
* message put two extra filesystem round-trips in front of every turn.
|
|
53
|
+
* The file is allowed to run a little over the limit between trims.
|
|
54
|
+
*/
|
|
55
|
+
export const TRIM_EVERY = 50;
|
|
56
|
+
/** Last entry written per file, so a repeat costs no read. */
|
|
57
|
+
const lastAppended = new Map();
|
|
58
|
+
const appendsSinceTrim = new Map();
|
|
59
|
+
/** Forget the in-process memo. Tests use this; nothing else needs it. */
|
|
60
|
+
export function resetHistoryState() {
|
|
61
|
+
lastAppended.clear();
|
|
62
|
+
appendsSinceTrim.clear();
|
|
63
|
+
}
|
|
64
|
+
/** Oldest first. Never throws: no history is a worse day, not a crash. */
|
|
65
|
+
export function loadHistory(file = historyFile()) {
|
|
66
|
+
try {
|
|
67
|
+
return readFileSync(file, 'utf-8')
|
|
68
|
+
.split('\n')
|
|
69
|
+
.filter((line) => line.length > 0)
|
|
70
|
+
.map(decodeEntry);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Cut the file back to the newest HISTORY_LIMIT entries. */
|
|
77
|
+
export function trimHistory(file = historyFile()) {
|
|
78
|
+
try {
|
|
79
|
+
const entries = loadHistory(file);
|
|
80
|
+
if (entries.length <= HISTORY_LIMIT)
|
|
81
|
+
return;
|
|
82
|
+
writeFileSync(file, entries.slice(entries.length - HISTORY_LIMIT).map(encodeEntry).join('\n') + '\n', 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* a history file we cannot rewrite is not worth failing a turn over */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Append one entry, skipping a repeat of the line before it.
|
|
90
|
+
*
|
|
91
|
+
* One filesystem write on the common path: this runs on the keystroke
|
|
92
|
+
* that submits a message, so it is not the place for a read, a mkdir
|
|
93
|
+
* and a rewrite.
|
|
94
|
+
*/
|
|
95
|
+
export function appendHistory(entry, file = historyFile()) {
|
|
96
|
+
const value = entry.trim();
|
|
97
|
+
if (!value || value.length > HISTORY_MAX_LINE)
|
|
98
|
+
return;
|
|
99
|
+
if (lastAppended.get(file) === value)
|
|
100
|
+
return;
|
|
101
|
+
try {
|
|
102
|
+
const line = encodeEntry(value) + '\n';
|
|
103
|
+
try {
|
|
104
|
+
appendFileSync(file, line, 'utf-8');
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (err?.code !== 'ENOENT')
|
|
108
|
+
throw err;
|
|
109
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
110
|
+
appendFileSync(file, line, 'utf-8');
|
|
111
|
+
}
|
|
112
|
+
lastAppended.set(file, value);
|
|
113
|
+
const count = (appendsSinceTrim.get(file) ?? 0) + 1;
|
|
114
|
+
if (count >= TRIM_EVERY) {
|
|
115
|
+
appendsSinceTrim.set(file, 0);
|
|
116
|
+
trimHistory(file);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
appendsSinceTrim.set(file, count);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* history is a convenience, never a reason to fail a turn */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Where up/down land, given how far back the cursor already is.
|
|
128
|
+
*
|
|
129
|
+
* `idx` is -1 for "at the live prompt" and counts backwards from the
|
|
130
|
+
* newest entry. Returns the new index and the text to show.
|
|
131
|
+
*/
|
|
132
|
+
export function walkHistory(history, idx, direction) {
|
|
133
|
+
if (direction === 'up') {
|
|
134
|
+
if (!history.length)
|
|
135
|
+
return { idx, value: '' };
|
|
136
|
+
const next = Math.min(idx + 1, history.length - 1);
|
|
137
|
+
return { idx: next, value: history[history.length - 1 - next] };
|
|
138
|
+
}
|
|
139
|
+
if (idx > 0) {
|
|
140
|
+
const next = idx - 1;
|
|
141
|
+
return { idx: next, value: history[history.length - 1 - next] };
|
|
142
|
+
}
|
|
143
|
+
if (idx === 0)
|
|
144
|
+
return { idx: -1, value: '' };
|
|
145
|
+
return { idx, value: '' };
|
|
146
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,60 +1,93 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { render, Box, Text } from 'ink';
|
|
4
|
-
import { AlmytyClient,
|
|
4
|
+
import { AlmytyClient, resolveCredentials, getOrgSlugFromToken } from '@almyty/client';
|
|
5
5
|
import { AgentSelector } from './components.js';
|
|
6
6
|
import { ChatApp, exitMessage } from './app.js';
|
|
7
|
-
|
|
7
|
+
import { helpText, isNonInteractive, parseArgs, resolveRef, splitRef, useColor } from './args.js';
|
|
8
|
+
import { explainError, DEFAULT_APP_URL } from './errors.js';
|
|
9
|
+
import { EXIT, exitCodeForError } from './exit-codes.js';
|
|
10
|
+
import { readStdin, runHeadless } from './headless.js';
|
|
11
|
+
import { VERSION } from './version.js';
|
|
12
|
+
export { VERSION };
|
|
13
|
+
function limitsFrom(args) {
|
|
14
|
+
if (args.maxSteps === undefined && args.maxCostCents === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
return {
|
|
17
|
+
...(args.maxSteps !== undefined ? { maxSteps: args.maxSteps } : {}),
|
|
18
|
+
...(args.maxCostCents !== undefined ? { maxCostCents: args.maxCostCents } : {}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
8
21
|
// ── Entry point ─────────────────────────────────────────────────
|
|
9
22
|
async function main() {
|
|
10
|
-
const
|
|
11
|
-
if (
|
|
23
|
+
const args = parseArgs(process.argv.slice(2));
|
|
24
|
+
if (args.error) {
|
|
25
|
+
console.error(args.error);
|
|
26
|
+
process.exit(EXIT.USAGE);
|
|
27
|
+
}
|
|
28
|
+
if (args.version) {
|
|
12
29
|
console.log(VERSION);
|
|
13
30
|
return;
|
|
14
31
|
}
|
|
15
|
-
if (
|
|
16
|
-
console.log(
|
|
32
|
+
if (args.help) {
|
|
33
|
+
console.log(helpText(VERSION));
|
|
17
34
|
return;
|
|
18
35
|
}
|
|
19
|
-
const creds =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
console.error('--resume requires a conversation id');
|
|
27
|
-
process.exit(1);
|
|
28
|
-
}
|
|
36
|
+
const creds = resolveCredentials();
|
|
37
|
+
if (!creds) {
|
|
38
|
+
// Said once, in full, rather than as a 401 three calls later.
|
|
39
|
+
console.error('Not authenticated. Run one of:');
|
|
40
|
+
console.error(' npx @almyty/auth login');
|
|
41
|
+
console.error(' export ALMYTY_TOKEN=<your-token>');
|
|
42
|
+
process.exit(EXIT.AUTH);
|
|
29
43
|
}
|
|
30
|
-
const
|
|
31
|
-
|
|
44
|
+
const client = new AlmytyClient(creds.url, creds.token);
|
|
45
|
+
const appUrl = process.env.ALMYTY_APP_URL || creds.frontendUrl || DEFAULT_APP_URL;
|
|
46
|
+
const headless = isNonInteractive(args, {
|
|
47
|
+
stdinTty: process.stdin.isTTY === true,
|
|
48
|
+
stdoutTty: process.stdout.isTTY === true,
|
|
49
|
+
});
|
|
50
|
+
const ref = resolveRef(args);
|
|
32
51
|
const defaultOrg = getOrgSlugFromToken(creds.token);
|
|
33
52
|
let orgSlug;
|
|
34
53
|
let agentSlug;
|
|
35
|
-
if (ref
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
if (ref) {
|
|
55
|
+
const parts = splitRef(ref);
|
|
56
|
+
if (parts.orgSlug) {
|
|
57
|
+
orgSlug = parts.orgSlug;
|
|
58
|
+
agentSlug = parts.agentSlug;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
if (!defaultOrg) {
|
|
62
|
+
console.error('Cannot tell which organization to use. Pass <org>/<agent-slug>, or log in again: npx @almyty/auth login');
|
|
63
|
+
process.exit(EXIT.USAGE);
|
|
64
|
+
}
|
|
65
|
+
orgSlug = defaultOrg;
|
|
66
|
+
agentSlug = parts.agentSlug;
|
|
43
67
|
}
|
|
44
|
-
|
|
45
|
-
|
|
68
|
+
}
|
|
69
|
+
else if (headless) {
|
|
70
|
+
// There is nobody to answer a picker on a pipe.
|
|
71
|
+
console.error('No agent given. Pass <org>/<agent-slug>, or set ALMYTY_AGENT.');
|
|
72
|
+
process.exit(EXIT.USAGE);
|
|
46
73
|
}
|
|
47
74
|
else {
|
|
48
|
-
// No arg — interactive picker
|
|
49
75
|
if (!defaultOrg) {
|
|
50
|
-
console.error('Usage:
|
|
51
|
-
process.exit(
|
|
76
|
+
console.error('Usage: almyty chat <org>/<agent-slug>');
|
|
77
|
+
process.exit(EXIT.USAGE);
|
|
52
78
|
}
|
|
53
79
|
orgSlug = defaultOrg;
|
|
54
|
-
|
|
80
|
+
let agents;
|
|
81
|
+
try {
|
|
82
|
+
agents = await client.listAgents();
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
console.error(explainError(err, { apiUrl: creds.url, appUrl }));
|
|
86
|
+
process.exit(exitCodeForError(err));
|
|
87
|
+
}
|
|
55
88
|
if (!agents.length) {
|
|
56
|
-
console.error(
|
|
57
|
-
process.exit(
|
|
89
|
+
console.error(`No agents in this organization yet. Create one at ${appUrl}/agents`);
|
|
90
|
+
process.exit(EXIT.NOT_FOUND);
|
|
58
91
|
}
|
|
59
92
|
if (agents.length === 1) {
|
|
60
93
|
agentSlug = agents[0].slug || agents[0].name.toLowerCase().replace(/\s+/g, '-');
|
|
@@ -69,21 +102,60 @@ async function main() {
|
|
|
69
102
|
}
|
|
70
103
|
}
|
|
71
104
|
const gw = client.gateway(orgSlug, agentSlug);
|
|
105
|
+
const errorContext = { agentRef: `${orgSlug}/${agentSlug}`, apiUrl: creds.url, appUrl };
|
|
72
106
|
let agent;
|
|
73
107
|
try {
|
|
74
108
|
agent = await gw.getInfo();
|
|
75
109
|
}
|
|
76
|
-
catch {
|
|
77
|
-
|
|
78
|
-
|
|
110
|
+
catch (err) {
|
|
111
|
+
// "Agent not found" used to be printed for a bad login, a wrong
|
|
112
|
+
// org, a draft agent and an unreachable API alike.
|
|
113
|
+
console.error(explainError(err, { ...errorContext, what: 'info' }));
|
|
114
|
+
process.exit(exitCodeForError(err));
|
|
115
|
+
}
|
|
116
|
+
if (headless) {
|
|
117
|
+
const message = args.message ?? (await readStdin(process.stdin));
|
|
118
|
+
if (!message) {
|
|
119
|
+
console.error('Nothing to ask. Pass --message "<question>", or pipe it in.');
|
|
120
|
+
process.exit(EXIT.USAGE);
|
|
121
|
+
}
|
|
122
|
+
// Ctrl-C on a pipe cancels the run rather than orphaning it.
|
|
123
|
+
const ac = new AbortController();
|
|
124
|
+
const onSigint = () => ac.abort();
|
|
125
|
+
process.on('SIGINT', onSigint);
|
|
126
|
+
const code = await runHeadless({
|
|
127
|
+
message,
|
|
128
|
+
agent,
|
|
129
|
+
target: gw,
|
|
130
|
+
json: args.json,
|
|
131
|
+
stream: args.stream && !args.json,
|
|
132
|
+
conversationId: args.resume,
|
|
133
|
+
limits: limitsFrom(args),
|
|
134
|
+
signal: ac.signal,
|
|
135
|
+
errorContext,
|
|
136
|
+
io: {
|
|
137
|
+
out: (text) => process.stdout.write(text),
|
|
138
|
+
err: (text) => process.stderr.write(text),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
process.off('SIGINT', onSigint);
|
|
142
|
+
process.exit(code);
|
|
143
|
+
}
|
|
144
|
+
// Colour is decided once, here, so NO_COLOR reaches ink's own
|
|
145
|
+
// detection rather than being re-derived per component.
|
|
146
|
+
if (!useColor(args, process.env, process.stdout.isTTY === true)) {
|
|
147
|
+
process.env.FORCE_COLOR = '0';
|
|
79
148
|
}
|
|
80
|
-
const { waitUntilExit } = render(_jsx(ChatApp, { client: client, initialAgent: agent, gw: gw, resumeConversationId:
|
|
149
|
+
const { waitUntilExit } = render(_jsx(ChatApp, { client: client, initialAgent: agent, gw: gw, resumeConversationId: args.resume, errorContext: errorContext }),
|
|
150
|
+
// Ctrl-C is handled inside the app: the first press cancels the
|
|
151
|
+
// run server-side, and only then does a second one exit.
|
|
152
|
+
{ exitOnCtrlC: false });
|
|
81
153
|
await waitUntilExit();
|
|
82
154
|
if (exitMessage) {
|
|
83
155
|
process.stdout.write(exitMessage);
|
|
84
156
|
}
|
|
85
157
|
}
|
|
86
158
|
main().catch(err => {
|
|
87
|
-
console.error(err
|
|
88
|
-
process.exit(
|
|
159
|
+
console.error(explainError(err));
|
|
160
|
+
process.exit(exitCodeForError(err));
|
|
89
161
|
});
|