@almyty/chat 0.1.3 → 0.2.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 CHANGED
@@ -1,33 +1,40 @@
1
1
  # @almyty/chat
2
2
 
3
- Interactive chat REPL for almyty agents. Supports both workflow and autonomous agent modes.
3
+ Interactive chat REPL for almyty agents. Built with [ink](https://github.com/vadimdemedes/ink) (React for CLI).
4
4
 
5
5
  ## Quick start
6
6
 
7
7
  ```bash
8
8
  $ npx @almyty/auth login
9
- $ npx @almyty/chat
10
- $ npx @almyty/chat my-research-bot
9
+ $ npx @almyty/chat acme/support-bot
11
10
  ```
12
11
 
13
12
  ## Usage
14
13
 
15
- Run without arguments to pick an agent from an interactive menu, or pass a name or ID to connect directly.
16
-
17
- ```bash
18
- $ npx @almyty/chat # interactive agent picker
19
- $ npx @almyty/chat <name|id> # connect to a specific agent
14
+ ```
15
+ Usage:
16
+ npx @almyty/chat <org>/<agent-slug>
17
+ npx @almyty/chat <org>/<agent-slug> --resume <conversation-id>
18
+ npx @almyty/chat # interactive agent picker
19
+
20
+ Commands:
21
+ /agents browse and switch agents
22
+ /tools show available tools
23
+ /help show commands
24
+ /clear clear conversation
25
+ /quit exit (shows resume command)
20
26
  ```
21
27
 
22
- ## REPL commands
28
+ ## Features
23
29
 
24
- | Command | Description |
25
- |---------|-------------|
26
- | `/switch <agent>` | Switch to a different agent |
27
- | `/agents` | List agents (current one marked) |
28
- | `/clear` | Clear the screen |
29
- | `/help` | Show available commands |
30
- | `/quit` | Exit the REPL |
30
+ - **Gateway routing** -- agents addressed as `<org>/<agent-slug>`
31
+ - **Resume conversations** -- `--resume <conversation-id>` picks up where you left off; `/quit` prints the resume command
32
+ - **Arrow-key agent picker** -- run without arguments to browse and select
33
+ - **Slash commands** -- tab autocomplete with fuzzy prefix matching
34
+ - **Command palette** -- arrow-key navigation through matching commands
35
+ - **Input history** -- up/down arrows cycle through previous messages (derived from conversation, including resumed history)
36
+ - **SSE streaming** -- real-time tool calls and agent responses via server-sent events
37
+ - **Markdown rendering** -- bold, inline code, code blocks, lists, headers
31
38
 
32
39
  ## Authentication
33
40
 
package/dist/app.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { AlmytyClient, GatewayClient, AgentInfo } from '@almyty/client';
2
+ import type { Message } from './components.js';
3
+ export interface AppState {
4
+ agent: AgentInfo;
5
+ messages: Message[];
6
+ loading: boolean;
7
+ loadingLabel: string;
8
+ conversationId: string | null;
9
+ pendingRunId: string | null;
10
+ }
11
+ export declare let exitMessage: string;
12
+ export declare function ChatApp({ client, initialAgent, gw, resumeConversationId }: {
13
+ client: AlmytyClient;
14
+ initialAgent: AgentInfo;
15
+ gw: GatewayClient;
16
+ resumeConversationId?: string;
17
+ }): import("react/jsx-runtime").JSX.Element;
package/dist/app.js ADDED
@@ -0,0 +1,319 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useCallback, useEffect } from 'react';
3
+ import { Box, Text, useApp, useInput } from 'ink';
4
+ import TextInput from 'ink-text-input';
5
+ import { Header, MessageView, LoadingIndicator, AgentSelector } from './components.js';
6
+ import { SLASH_COMMANDS, COMMAND_DESCS, resolveSlash, getSuggestion, ALIASES } from './commands.js';
7
+ // Mutable module-level variable; written by ChatApp, read by main() after exit
8
+ export let exitMessage = '';
9
+ // ── Chat app ────────────────────────────────────────────────────
10
+ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
11
+ const { exit } = useApp();
12
+ const [state, setState] = useState({
13
+ agent: initialAgent,
14
+ messages: [],
15
+ loading: false,
16
+ loadingLabel: 'Thinking',
17
+ conversationId: resumeConversationId ?? null,
18
+ pendingRunId: null,
19
+ });
20
+ // Load conversation history on resume
21
+ useEffect(() => {
22
+ if (!resumeConversationId)
23
+ return;
24
+ (async () => {
25
+ setState(s => ({ ...s, loading: true, loadingLabel: 'Loading history' }));
26
+ try {
27
+ const history = await gw.getConversationMessages(resumeConversationId);
28
+ const msgs = history
29
+ .filter(m => m.role === 'user' || m.role === 'assistant')
30
+ .map(m => ({
31
+ role: m.role === 'user' ? 'user' : 'agent',
32
+ text: m.content,
33
+ }));
34
+ setState(s => ({ ...s, messages: msgs, loading: false }));
35
+ }
36
+ catch {
37
+ setState(s => ({ ...s, loading: false }));
38
+ }
39
+ })();
40
+ }, [resumeConversationId]);
41
+ const [input, setInput] = useState('');
42
+ const [paletteCursor, setPaletteCursor] = useState(0);
43
+ // Input history derived from conversation — includes resumed messages
44
+ const inputHistory = state.messages.filter(m => m.role === 'user').map(m => m.text);
45
+ const [historyIdx, setHistoryIdx] = useState(-1);
46
+ const [showPicker, setShowPicker] = useState(false);
47
+ const [pickerAgents, setPickerAgents] = useState([]);
48
+ // Command palette matches
49
+ const slashMatches = input.startsWith('/') && !input.includes(' ')
50
+ ? SLASH_COMMANDS.filter(c => c.startsWith(input.slice(1).toLowerCase()))
51
+ : [];
52
+ const paletteOpen = input.startsWith('/') && slashMatches.length > 0;
53
+ useInput((ch, key) => {
54
+ if (state.loading)
55
+ return;
56
+ // Command palette navigation
57
+ if (paletteOpen) {
58
+ if (key.upArrow) {
59
+ setPaletteCursor(c => (c - 1 + slashMatches.length) % slashMatches.length);
60
+ return;
61
+ }
62
+ if (key.downArrow) {
63
+ setPaletteCursor(c => (c + 1) % slashMatches.length);
64
+ return;
65
+ }
66
+ if (key.tab) {
67
+ setInput(`/${slashMatches[paletteCursor]}`);
68
+ setPaletteCursor(0);
69
+ return;
70
+ }
71
+ return;
72
+ }
73
+ // Input history — up/down arrows always, like Claude Code
74
+ if (key.upArrow && inputHistory.length > 0) {
75
+ const newIdx = Math.min(historyIdx + 1, inputHistory.length - 1);
76
+ setHistoryIdx(newIdx);
77
+ setInput(inputHistory[inputHistory.length - 1 - newIdx]);
78
+ return;
79
+ }
80
+ if (key.downArrow) {
81
+ if (historyIdx > 0) {
82
+ const newIdx = historyIdx - 1;
83
+ setHistoryIdx(newIdx);
84
+ setInput(inputHistory[inputHistory.length - 1 - newIdx]);
85
+ }
86
+ else if (historyIdx === 0) {
87
+ setHistoryIdx(-1);
88
+ setInput('');
89
+ }
90
+ return;
91
+ }
92
+ });
93
+ // Reset palette cursor when input changes
94
+ const handleInputChange = useCallback((val) => {
95
+ setInput(val);
96
+ setPaletteCursor(0);
97
+ }, []);
98
+ const addMessage = useCallback((msg) => {
99
+ setState(s => ({ ...s, messages: [...s.messages, msg] }));
100
+ }, []);
101
+ const handleSubmit = useCallback(async (value) => {
102
+ const trimmed = value.trim();
103
+ if (!trimmed)
104
+ return;
105
+ setInput('');
106
+ setHistoryIdx(-1);
107
+ // Slash commands
108
+ if (trimmed.startsWith('/')) {
109
+ const [raw, ...args] = trimmed.slice(1).split(/\s+/);
110
+ const cmd = resolveSlash(raw);
111
+ if (!cmd) {
112
+ addMessage({ role: 'error', text: `Unknown command: /${raw}` });
113
+ addMessage({ role: 'info', text: `Commands: /help /agents /clear /quit` });
114
+ return;
115
+ }
116
+ if (cmd !== raw.toLowerCase() && !Object.entries(ALIASES).some(([k, v]) => k === raw.toLowerCase() && v === cmd)) {
117
+ addMessage({ role: 'info', text: `→ /${cmd}` });
118
+ }
119
+ switch (cmd) {
120
+ case 'quit':
121
+ const agentRef = `${gw.orgSlug}/${gw.agentSlug}`;
122
+ exitMessage = state.conversationId
123
+ ? `\nTo resume: npx @almyty/chat ${agentRef} --resume ${state.conversationId}\n`
124
+ : '';
125
+ exit();
126
+ return;
127
+ case 'clear':
128
+ setState(s => ({ ...s, messages: [] }));
129
+ return;
130
+ case 'help':
131
+ addMessage({ role: 'info', text: '/agents browse and switch agents' });
132
+ addMessage({ role: 'info', text: '/tools show available tools' });
133
+ addMessage({ role: 'info', text: '/clear clear conversation' });
134
+ addMessage({ role: 'info', text: '/help show this help' });
135
+ addMessage({ role: 'info', text: '/quit exit' });
136
+ addMessage({ role: 'info', text: 'Tab to autocomplete commands.' });
137
+ return;
138
+ case 'tools': {
139
+ const tools = state.agent.tools;
140
+ if (!tools?.length) {
141
+ addMessage({ role: 'info', text: 'No tools configured for this agent.' });
142
+ return;
143
+ }
144
+ addMessage({ role: 'info', text: `${tools.length} tool${tools.length > 1 ? 's' : ''} available:` });
145
+ for (const tool of tools) {
146
+ const desc = tool.description ? ` — ${tool.description}` : '';
147
+ addMessage({ role: 'tool', text: `${tool.name}${desc}` });
148
+ }
149
+ return;
150
+ }
151
+ case 'agents': {
152
+ const target = args.join(' ').trim();
153
+ if (target) {
154
+ setState(s => ({ ...s, loading: true, loadingLabel: 'Switching' }));
155
+ const found = await client.findAgentByNameOrId(target);
156
+ setState(s => ({ ...s, loading: false }));
157
+ if (!found) {
158
+ addMessage({ role: 'error', text: `Agent "${target}" not found` });
159
+ return;
160
+ }
161
+ setState(s => ({
162
+ ...s,
163
+ agent: found,
164
+ messages: [],
165
+ conversationId: null,
166
+ pendingRunId: null,
167
+ }));
168
+ return;
169
+ }
170
+ setState(s => ({ ...s, loading: true, loadingLabel: 'Loading' }));
171
+ const agents = await client.listAgents();
172
+ setState(s => ({ ...s, loading: false }));
173
+ setPickerAgents(agents);
174
+ setShowPicker(true);
175
+ return;
176
+ }
177
+ }
178
+ return;
179
+ }
180
+ // Regular message
181
+ addMessage({ role: 'user', text: trimmed });
182
+ setState(s => ({ ...s, loading: true, loadingLabel: 'Thinking' }));
183
+ try {
184
+ if (state.agent.mode === 'autonomous') {
185
+ let runId;
186
+ if (state.pendingRunId) {
187
+ await gw.sendRunInput(state.pendingRunId, trimmed);
188
+ runId = state.pendingRunId;
189
+ setState(s => ({ ...s, pendingRunId: null }));
190
+ }
191
+ else {
192
+ const run = await gw.startRun(trimmed, {
193
+ conversationId: state.conversationId ?? undefined,
194
+ });
195
+ runId = run.id;
196
+ if (run.conversationId) {
197
+ setState(s => ({ ...s, conversationId: run.conversationId }));
198
+ }
199
+ }
200
+ // Stream events — show tool calls, sub-agents, chunks in real time
201
+ let streamedContent = '';
202
+ const result = await gw.streamRun(runId, (event) => {
203
+ switch (event.type) {
204
+ case 'llm.started':
205
+ setState(s => ({ ...s, loadingLabel: 'Thinking' }));
206
+ break;
207
+ case 'llm.chunk': {
208
+ const chunk = event.data.content;
209
+ if (chunk)
210
+ streamedContent += chunk;
211
+ break;
212
+ }
213
+ case 'llm.response':
214
+ setState(s => ({ ...s, loadingLabel: 'Processing' }));
215
+ break;
216
+ case 'tool.started': {
217
+ const toolName = event.data.tool;
218
+ if (toolName) {
219
+ setState(s => ({ ...s, loading: false }));
220
+ addMessage({ role: 'tool', text: toolName });
221
+ setState(s => ({ ...s, loading: true, loadingLabel: `Running ${toolName}` }));
222
+ }
223
+ break;
224
+ }
225
+ case 'tool.result': {
226
+ const tool = event.data.tool;
227
+ const success = event.data.success;
228
+ if (tool) {
229
+ addMessage({ role: 'info', text: `${tool} ${success ? 'completed' : 'failed'}` });
230
+ }
231
+ break;
232
+ }
233
+ case 'step.completed':
234
+ break;
235
+ case 'run.completed': {
236
+ const output = event.data.output;
237
+ if (output) {
238
+ const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
239
+ streamedContent = text;
240
+ }
241
+ break;
242
+ }
243
+ case 'run.failed': {
244
+ const error = event.data.error;
245
+ addMessage({ role: 'error', text: error || 'Run failed' });
246
+ break;
247
+ }
248
+ }
249
+ });
250
+ setState(s => ({ ...s, loading: false }));
251
+ // If still running after stream, poll to completion
252
+ let finalResult = result;
253
+ if (result.status === 'running') {
254
+ finalResult = await gw.pollRun(runId);
255
+ }
256
+ // Show final output
257
+ if (finalResult.status === 'completed') {
258
+ const text = streamedContent
259
+ || (finalResult.output != null ? (typeof finalResult.output === 'string' ? finalResult.output : JSON.stringify(finalResult.output, null, 2)) : '');
260
+ if (text)
261
+ addMessage({ role: 'agent', text });
262
+ }
263
+ else if (finalResult.status === 'waiting_input') {
264
+ setState(s => ({ ...s, pendingRunId: runId }));
265
+ addMessage({ role: 'info', text: 'Waiting for your input' });
266
+ }
267
+ else if (finalResult.status === 'failed' && !streamedContent) {
268
+ addMessage({ role: 'error', text: finalResult.error || 'Run failed' });
269
+ }
270
+ }
271
+ else {
272
+ // Workflow: synchronous invoke
273
+ const result = await gw.invoke({ message: trimmed });
274
+ setState(s => ({ ...s, loading: false }));
275
+ const output = result?.output ?? result?.data?.output ?? result;
276
+ if (output != null) {
277
+ const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
278
+ addMessage({ role: 'agent', text });
279
+ }
280
+ }
281
+ }
282
+ catch (err) {
283
+ setState(s => ({ ...s, loading: false }));
284
+ addMessage({ role: 'error', text: err.message });
285
+ }
286
+ }, [state.agent, state.conversationId, state.pendingRunId, client, addMessage, exit]);
287
+ const handlePickerSelect = useCallback((agent) => {
288
+ setShowPicker(false);
289
+ if (agent.id === state.agent.id)
290
+ return;
291
+ setState(s => ({
292
+ ...s,
293
+ agent,
294
+ messages: [],
295
+ conversationId: null,
296
+ pendingRunId: null,
297
+ }));
298
+ }, [state.agent.id]);
299
+ const suggestion = getSuggestion(input);
300
+ if (showPicker) {
301
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { agent: state.agent, conversationId: state.conversationId }), _jsx(AgentSelector, { agents: pickerAgents, onSelect: handlePickerSelect })] }));
302
+ }
303
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { agent: state.agent, conversationId: state.conversationId }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, paddingRight: 2, overflow: "hidden", children: [state.messages.map((msg, i) => (_jsx(MessageView, { msg: msg }, i))), state.loading && _jsx(LoadingIndicator, { label: state.loadingLabel })] }), paletteOpen && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: slashMatches.map((cmd, i) => {
304
+ const active = i === paletteCursor;
305
+ const padded = `/${cmd}`.padEnd(10);
306
+ const desc = COMMAND_DESCS[cmd] ?? '';
307
+ const line = `${active ? '❯' : ' '} ${padded} ${desc}`;
308
+ return _jsx(Text, { color: active ? '#8b5cf6' : undefined, bold: active, wrap: "truncate", children: line }, cmd);
309
+ }) })), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '─'.repeat(Math.min(process.stdout.columns || 80, 120)) }) }), _jsxs(Box, { paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: "#8b5cf6", children: "\u276F " }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { value: input, onChange: handleInputChange, onSubmit: (val) => {
310
+ if (paletteOpen && slashMatches.length > 0) {
311
+ const selected = `/${slashMatches[paletteCursor]}`;
312
+ setInput('');
313
+ setPaletteCursor(0);
314
+ handleSubmit(selected);
315
+ return;
316
+ }
317
+ handleSubmit(val);
318
+ }, placeholder: "Type a message or / for commands" }) })] }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { dimColor: true, children: [state.agent.name, state.agent.tools?.length ? ` · ${state.agent.tools.length} tools` : '', state.conversationId ? ` · ${state.conversationId.slice(0, 8)}` : ''] }) })] }));
319
+ }
@@ -0,0 +1,5 @@
1
+ export declare const SLASH_COMMANDS: readonly ["agents", "tools", "help", "clear", "quit"];
2
+ export declare const ALIASES: Record<string, string>;
3
+ export declare const COMMAND_DESCS: Record<string, string>;
4
+ export declare function resolveSlash(input: string): string | null;
5
+ export declare function getSuggestion(partial: string): string;
@@ -0,0 +1,36 @@
1
+ // ── Slash command resolution ────────────────────────────────────
2
+ export const SLASH_COMMANDS = ['agents', 'tools', 'help', 'clear', 'quit'];
3
+ export const ALIASES = {
4
+ agent: 'agents', ag: 'agents', switch: 'agents', sw: 'agents',
5
+ tool: 'tools', t: 'tools',
6
+ h: 'help', '?': 'help',
7
+ cls: 'clear', c: 'clear',
8
+ exit: 'quit', q: 'quit',
9
+ };
10
+ export const COMMAND_DESCS = {
11
+ agents: 'browse and switch agents',
12
+ tools: 'show available tools',
13
+ help: 'show commands',
14
+ clear: 'clear conversation',
15
+ quit: 'exit',
16
+ };
17
+ export function resolveSlash(input) {
18
+ const name = input.toLowerCase();
19
+ if (SLASH_COMMANDS.includes(name))
20
+ return name;
21
+ if (ALIASES[name])
22
+ return ALIASES[name];
23
+ const prefixed = SLASH_COMMANDS.filter(c => c.startsWith(name));
24
+ if (prefixed.length === 1)
25
+ return prefixed[0];
26
+ return null;
27
+ }
28
+ export function getSuggestion(partial) {
29
+ if (!partial.startsWith('/') || partial.includes(' '))
30
+ return '';
31
+ const p = partial.slice(1).toLowerCase();
32
+ if (!p)
33
+ return '';
34
+ const match = SLASH_COMMANDS.find(c => c.startsWith(p) && c !== p);
35
+ return match ? `/${match}` : '';
36
+ }
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import type { AgentInfo } from '@almyty/client';
3
+ export interface Message {
4
+ role: 'user' | 'agent' | 'error' | 'info' | 'tool';
5
+ text: string;
6
+ }
7
+ export declare function MarkdownText({ children, prefix, prefixColor }: {
8
+ children: string;
9
+ prefix?: string;
10
+ prefixColor?: string;
11
+ }): import("react/jsx-runtime").JSX.Element;
12
+ export declare function InlineFormat({ text }: {
13
+ text: string;
14
+ }): import("react/jsx-runtime").JSX.Element;
15
+ export declare function boldify(text: string): React.ReactElement;
16
+ export declare function estimateLines(msg: Message, cols: number): number;
17
+ export declare function MessageWindow({ messages, loading, loadingLabel, maxRows, scrollOffset }: {
18
+ messages: Message[];
19
+ loading: boolean;
20
+ loadingLabel: string;
21
+ maxRows: number;
22
+ scrollOffset?: number;
23
+ }): import("react/jsx-runtime").JSX.Element;
24
+ export declare function Header({ agent, conversationId }: {
25
+ agent: AgentInfo;
26
+ conversationId?: string | null;
27
+ }): import("react/jsx-runtime").JSX.Element;
28
+ export declare function MessageView({ msg }: {
29
+ msg: Message;
30
+ }): import("react/jsx-runtime").JSX.Element | null;
31
+ export declare function LoadingIndicator({ label }: {
32
+ label: string;
33
+ }): import("react/jsx-runtime").JSX.Element;
34
+ export declare function AgentSelector({ agents, onSelect }: {
35
+ agents: AgentInfo[];
36
+ onSelect: (a: AgentInfo) => void;
37
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,159 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ // ── Simple markdown rendering ───────────────────────────────────
6
+ export function MarkdownText({ children, prefix, prefixColor }) {
7
+ const lines = children.split('\n');
8
+ const elements = [];
9
+ let inCode = false;
10
+ let codeLines = [];
11
+ for (let i = 0; i < lines.length; i++) {
12
+ const line = lines[i];
13
+ if (line.trimStart().startsWith('```')) {
14
+ if (!inCode) {
15
+ inCode = true;
16
+ codeLines = [];
17
+ }
18
+ else {
19
+ inCode = false;
20
+ elements.push(_jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, marginTop: 1, marginBottom: 1, children: codeLines.map((cl, j) => (_jsx(Text, { color: "white", backgroundColor: "#333", children: ` ${cl} ` }, j))) }, `code-${i}`));
21
+ }
22
+ continue;
23
+ }
24
+ if (inCode) {
25
+ codeLines.push(line);
26
+ continue;
27
+ }
28
+ // Headers
29
+ const hm = line.match(/^(#{1,3})\s+(.*)/);
30
+ if (hm) {
31
+ elements.push(_jsx(Text, { bold: true, color: "#8b5cf6", children: hm[2] }, i));
32
+ continue;
33
+ }
34
+ // Unordered list
35
+ const ulm = line.match(/^(\s*)[*-]\s+(.*)/);
36
+ if (ulm) {
37
+ elements.push(_jsxs(Text, { children: [ulm[1], _jsx(Text, { color: "#8b5cf6", children: "\u2022" }), " ", _jsx(InlineFormat, { text: ulm[2] })] }, i));
38
+ continue;
39
+ }
40
+ // Ordered list
41
+ const olm = line.match(/^(\s*)(\d+)\.\s+(.*)/);
42
+ if (olm) {
43
+ elements.push(_jsxs(Text, { children: [olm[1], _jsxs(Text, { dimColor: true, children: [olm[2], "."] }), " ", _jsx(InlineFormat, { text: olm[3] })] }, i));
44
+ continue;
45
+ }
46
+ // Blank
47
+ if (!line.trim()) {
48
+ elements.push(_jsx(Text, { children: " " }, i));
49
+ continue;
50
+ }
51
+ elements.push(_jsx(Text, { children: _jsx(InlineFormat, { text: line }) }, i));
52
+ }
53
+ return _jsx(Box, { flexDirection: "column", children: elements });
54
+ }
55
+ export function InlineFormat({ text }) {
56
+ // Simple bold/code rendering via split
57
+ const parts = [];
58
+ let remaining = text;
59
+ let idx = 0;
60
+ while (remaining) {
61
+ // Inline code
62
+ const codeMatch = remaining.match(/^(.*?)`([^`]+)`(.*)/);
63
+ if (codeMatch) {
64
+ if (codeMatch[1])
65
+ parts.push(_jsx(Text, { children: boldify(codeMatch[1]) }, idx++));
66
+ parts.push(_jsx(Text, { backgroundColor: "#333", color: "white", children: ` ${codeMatch[2]} ` }, idx++));
67
+ remaining = codeMatch[3];
68
+ continue;
69
+ }
70
+ parts.push(_jsx(Text, { children: boldify(remaining) }, idx++));
71
+ break;
72
+ }
73
+ return _jsx(_Fragment, { children: parts });
74
+ }
75
+ export function boldify(text) {
76
+ const parts = [];
77
+ let remaining = text;
78
+ let idx = 0;
79
+ while (remaining) {
80
+ const bm = remaining.match(/^(.*?)\*\*(.+?)\*\*(.*)/);
81
+ if (bm) {
82
+ if (bm[1])
83
+ parts.push(_jsx(Text, { children: bm[1] }, idx++));
84
+ parts.push(_jsx(Text, { bold: true, children: bm[2] }, idx++));
85
+ remaining = bm[3];
86
+ continue;
87
+ }
88
+ parts.push(_jsx(Text, { children: remaining }, idx++));
89
+ break;
90
+ }
91
+ return _jsx(_Fragment, { children: parts });
92
+ }
93
+ // ── Message window (manual scroll) ──────────────────────────────
94
+ 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;
102
+ }
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
+ }
118
+ 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"] }) }))] }));
122
+ }
123
+ // ── Components ──────────────────────────────────────────────────
124
+ export function Header({ agent, conversationId }) {
125
+ const convSuffix = conversationId ? ` ${conversationId.slice(0, 8)}` : '';
126
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "#555", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { wrap: "truncate", children: `⚡ almyty · ${agent.name} ${agent.mode || ''}${convSuffix}` }), agent.description && _jsx(Text, { dimColor: true, wrap: "truncate", children: agent.description })] }));
127
+ }
128
+ export function MessageView({ msg }) {
129
+ switch (msg.role) {
130
+ case 'user':
131
+ return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["\u276F ", msg.text] }) }));
132
+ case 'agent':
133
+ return (_jsxs(Box, { marginTop: 1, paddingLeft: 2, flexDirection: "column", children: [_jsx(Text, { color: "#8b5cf6", children: "\u2502" }), _jsx(Box, { paddingLeft: 2, children: _jsx(MarkdownText, { children: msg.text }) })] }));
134
+ case 'tool':
135
+ return (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2502 \u25B8 ", msg.text] }) }));
136
+ case 'error':
137
+ return (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: "red", children: ["\u2502 ", msg.text] }) }));
138
+ case 'info':
139
+ return (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2502 ", msg.text] }) }));
140
+ default:
141
+ return null;
142
+ }
143
+ }
144
+ export function LoadingIndicator({ label }) {
145
+ return (_jsxs(Box, { marginTop: 1, paddingLeft: 2, children: [_jsx(Text, { color: "#8b5cf6", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { dimColor: true, children: [" ", label] })] }));
146
+ }
147
+ // ── Agent selector ──────────────────────────────────────────────
148
+ export function AgentSelector({ agents, onSelect }) {
149
+ const [cursor, setCursor] = useState(0);
150
+ useInput((input, key) => {
151
+ if (key.upArrow)
152
+ setCursor(c => (c - 1 + agents.length) % agents.length);
153
+ if (key.downArrow)
154
+ setCursor(c => (c + 1) % agents.length);
155
+ if (key.return)
156
+ onSelect(agents[cursor]);
157
+ });
158
+ return (_jsxs(Box, { flexDirection: "column", paddingTop: 1, children: [_jsx(Text, { dimColor: true, children: " Select an agent:" }), _jsx(Text, { children: " " }), agents.map((ag, i) => (_jsxs(Box, { paddingLeft: 2, children: [_jsxs(Text, { color: i === cursor ? '#8b5cf6' : undefined, children: [i === cursor ? '❯' : ' ', ' '] }), _jsx(Text, { bold: i === cursor, children: ag.name }), _jsxs(Text, { dimColor: true, children: [" ", ag.mode] })] }, ag.id))), agents[cursor]?.description && (_jsx(Box, { paddingLeft: 4, paddingTop: 1, children: _jsx(Text, { dimColor: true, children: agents[cursor].description }) }))] }));
159
+ }
package/dist/index.d.ts CHANGED
@@ -1,20 +1,2 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * @almyty/chat — interactive REPL with almyty agents.
4
- *
5
- * npx @almyty/chat # pick from a menu
6
- * npx @almyty/chat <agent> # start chatting with that agent
7
- * npx @almyty/chat <agent> --resume <id> # resume a conversation
8
- *
9
- * Reads credentials from ~/.almyty/credentials.json (created by
10
- * `npx @almyty/auth login`).
11
- *
12
- * Slash commands inside the REPL:
13
- *
14
- * /switch <agent> switch to a different agent
15
- * /agents list agents in your organization
16
- * /clear clear the screen
17
- * /help show command list
18
- * /quit, /exit leave the REPL
19
- */
20
- export {};
2
+ export declare const VERSION = "0.2.0";
package/dist/index.js CHANGED
@@ -1,272 +1,11 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * @almyty/chat — interactive REPL with almyty agents.
4
- *
5
- * npx @almyty/chat # pick from a menu
6
- * npx @almyty/chat <agent> # start chatting with that agent
7
- * npx @almyty/chat <agent> --resume <id> # resume a conversation
8
- *
9
- * Reads credentials from ~/.almyty/credentials.json (created by
10
- * `npx @almyty/auth login`).
11
- *
12
- * Slash commands inside the REPL:
13
- *
14
- * /switch <agent> switch to a different agent
15
- * /agents list agents in your organization
16
- * /clear clear the screen
17
- * /help show command list
18
- * /quit, /exit leave the REPL
19
- */
20
- import { createInterface } from 'readline';
21
- import { AlmytyClient, resolveCredentialsOrExit } from '@almyty/client';
22
- const VERSION = '0.1.0';
23
- function printHelp() {
24
- console.log(`
25
- @almyty/chat v${VERSION}
26
-
27
- Usage:
28
- npx @almyty/chat Pick an agent from a menu
29
- npx @almyty/chat <name|id> Start chatting with that agent
30
- npx @almyty/chat <name|id> --resume <conversation-id>
31
- Resume an existing conversation
32
-
33
- Inside the REPL:
34
- /switch <agent> switch to a different agent
35
- /agents list agents in your organization
36
- /clear clear the screen
37
- /help show command list
38
- /quit, /exit leave the REPL
39
-
40
- Login:
41
- npx @almyty/auth login Browser-based login
42
- `);
43
- }
44
- function printAgentBanner(agent) {
45
- console.log('');
46
- console.log(` 💬 ${agent.name}${agent.mode ? ` [${agent.mode}]` : ''}`);
47
- if (agent.description)
48
- console.log(` ${agent.description}`);
49
- console.log('');
50
- console.log(' Type your message, or /help for commands. /quit to exit.');
51
- console.log('');
52
- }
53
- function formatStep(step) {
54
- if (!step)
55
- return null;
56
- switch (step.type) {
57
- case 'llm_call':
58
- if (step.output?.content)
59
- return ` ${step.output.content}`;
60
- return ` · llm_call (${step.tokens?.input ?? 0}↓ / ${step.tokens?.output ?? 0}↑)`;
61
- case 'tool_call':
62
- return ` · tool_call → ${step.input?.tool ?? '?'}`;
63
- case 'sub_agent_call':
64
- return ` · sub_agent_call → ${step.input?.agentId ?? '?'}`;
65
- case 'error':
66
- return ` ! error: ${step.error}`;
67
- default:
68
- return null;
69
- }
70
- }
71
- async function pickAgent(client) {
72
- const agents = await client.listAgents();
73
- if (agents.length === 0) {
74
- console.error('No agents in your organization. Create one at https://app.almyty.com/agents');
75
- return null;
76
- }
77
- if (agents.length === 1) {
78
- return agents[0];
79
- }
80
- console.log('');
81
- console.log(' Pick an agent:');
82
- agents.forEach((a, i) => {
83
- const mode = a.mode ? ` [${a.mode}]` : '';
84
- console.log(` ${i + 1}. ${a.name}${mode}`);
85
- });
86
- console.log('');
87
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88
- const answer = await new Promise((resolve) => {
89
- rl.question(' Enter number: ', (input) => {
90
- rl.close();
91
- resolve(input.trim());
92
- });
93
- });
94
- const idx = parseInt(answer, 10) - 1;
95
- if (Number.isNaN(idx) || idx < 0 || idx >= agents.length) {
96
- console.error('Invalid selection.');
97
- return null;
98
- }
99
- return agents[idx];
100
- }
101
- async function handleSlashCommand(command, state) {
102
- const [cmd, ...rest] = command.slice(1).trim().split(/\s+/);
103
- switch (cmd) {
104
- case 'quit':
105
- case 'exit':
106
- return { exit: true };
107
- case 'help':
108
- console.log('');
109
- console.log(' /switch <agent> switch to a different agent');
110
- console.log(' /agents list agents in your organization');
111
- console.log(' /clear clear the screen');
112
- console.log(' /quit, /exit leave the REPL');
113
- console.log(' /help this help');
114
- console.log('');
115
- return {};
116
- case 'clear':
117
- process.stdout.write('\x1b[2J\x1b[H');
118
- printAgentBanner(state.agent);
119
- return {};
120
- case 'agents': {
121
- const agents = await state.client.listAgents();
122
- if (agents.length === 0) {
123
- console.log(' (no agents in this organization)');
124
- return {};
125
- }
126
- console.log('');
127
- for (const a of agents) {
128
- const marker = a.id === state.agent.id ? '→' : ' ';
129
- const mode = a.mode ? ` [${a.mode}]` : '';
130
- console.log(` ${marker} ${a.name}${mode}${a.description ? ' — ' + a.description : ''}`);
131
- }
132
- console.log('');
133
- return {};
134
- }
135
- case 'switch': {
136
- const target = rest.join(' ').trim();
137
- if (!target) {
138
- console.log(' Usage: /switch <agent name or id>');
139
- return {};
140
- }
141
- const next = await state.client.findAgentByNameOrId(target);
142
- if (!next) {
143
- console.log(` Agent "${target}" not found.`);
144
- return {};
145
- }
146
- state.agent = next;
147
- state.pendingRunId = null;
148
- state.conversationId = null;
149
- printAgentBanner(state.agent);
150
- return {};
151
- }
152
- default:
153
- console.log(` Unknown command: /${cmd}. Type /help for available commands.`);
154
- return {};
155
- }
156
- }
157
- async function handleAutonomousTurn(state, message) {
158
- let runId;
159
- if (state.pendingRunId) {
160
- await state.client.sendRunInput(state.agent.id, state.pendingRunId, message);
161
- runId = state.pendingRunId;
162
- state.pendingRunId = null;
163
- }
164
- else {
165
- const run = await state.client.startRun(state.agent.id, message, {
166
- conversationId: state.conversationId ?? undefined,
167
- });
168
- runId = run.id;
169
- if (run.conversationId) {
170
- state.conversationId = run.conversationId;
171
- }
172
- }
173
- let printed = 0;
174
- const final = await state.client.pollRun(state.agent.id, runId, {
175
- onStep: (run) => {
176
- const newSteps = (run.steps ?? []).slice(printed);
177
- for (const step of newSteps) {
178
- const line = formatStep(step);
179
- if (line)
180
- console.log(line);
181
- }
182
- printed = run.steps?.length ?? printed;
183
- },
184
- });
185
- if (final.status === 'waiting_input') {
186
- state.pendingRunId = runId;
187
- console.log('');
188
- console.log(' (agent is waiting for your input — answer below)');
189
- console.log('');
190
- return;
191
- }
192
- if (final.status === 'completed') {
193
- if (final.output != null) {
194
- console.log('');
195
- console.log(typeof final.output === 'string' ? ` ${final.output}` : JSON.stringify(final.output, null, 2));
196
- }
197
- return;
198
- }
199
- console.log('');
200
- console.log(` Run ${final.status}${final.error ? `: ${final.error}` : ''}`);
201
- }
202
- async function handleWorkflowTurn(state, message) {
203
- const result = await state.client.invokeAgent(state.agent.id, { message });
204
- const output = result?.output ?? result?.data?.output ?? result;
205
- if (output == null) {
206
- console.log(' (no output)');
207
- return;
208
- }
209
- console.log('');
210
- if (typeof output === 'string') {
211
- console.log(` ${output}`);
212
- }
213
- else {
214
- console.log(JSON.stringify(output, null, 2).split('\n').map((l) => ' ' + l).join('\n'));
215
- }
216
- }
217
- async function handleUserTurn(state, message) {
218
- if (state.agent.mode === 'autonomous') {
219
- return handleAutonomousTurn(state, message);
220
- }
221
- return handleWorkflowTurn(state, message);
222
- }
223
- async function startChat(client, agent, resumeConversationId) {
224
- const state = { client, agent, pendingRunId: null, conversationId: resumeConversationId ?? null };
225
- if (resumeConversationId) {
226
- console.log(` Resuming conversation ${resumeConversationId}`);
227
- }
228
- printAgentBanner(state.agent);
229
- const rl = createInterface({
230
- input: process.stdin,
231
- output: process.stdout,
232
- prompt: '> ',
233
- });
234
- rl.prompt();
235
- rl.on('line', async (raw) => {
236
- const line = raw.trim();
237
- if (!line) {
238
- rl.prompt();
239
- return;
240
- }
241
- if (line.startsWith('/')) {
242
- try {
243
- const { exit } = await handleSlashCommand(line, state);
244
- if (exit) {
245
- rl.close();
246
- return;
247
- }
248
- }
249
- catch (err) {
250
- console.error(` Error: ${err.message}`);
251
- }
252
- rl.prompt();
253
- return;
254
- }
255
- try {
256
- await handleUserTurn(state, line);
257
- }
258
- catch (err) {
259
- console.error(` Error: ${err.message}`);
260
- }
261
- console.log('');
262
- rl.prompt();
263
- });
264
- rl.on('close', () => {
265
- console.log('');
266
- console.log('Chat ended.');
267
- process.exit(0);
268
- });
269
- }
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { render, Box, Text } from 'ink';
4
+ import { AlmytyClient, resolveCredentialsOrExit, getOrgSlugFromToken } from '@almyty/client';
5
+ import { AgentSelector } from './components.js';
6
+ import { ChatApp, exitMessage } from './app.js';
7
+ export const VERSION = '0.2.0';
8
+ // ── Entry point ─────────────────────────────────────────────────
270
9
  async function main() {
271
10
  const argv = process.argv.slice(2);
272
11
  if (argv.includes('--version') || argv.includes('-v')) {
@@ -274,39 +13,77 @@ async function main() {
274
13
  return;
275
14
  }
276
15
  if (argv.includes('--help') || argv.includes('-h')) {
277
- printHelp();
16
+ console.log(`almyty chat v${VERSION}\n\nUsage:\n npx @almyty/chat <org>/<agent-slug>\n npx @almyty/chat <org>/<agent-slug> --resume <conversation-id>\n\nExamples:\n npx @almyty/chat acme/support-bot\n npx @almyty/chat myorg/my-agent --resume 60d93c85-...\n\nCommands:\n /help /agents /clear /quit\n\nAuth:\n npx @almyty/auth login`);
278
17
  return;
279
18
  }
280
19
  const creds = resolveCredentialsOrExit();
281
20
  const client = new AlmytyClient(creds.url, creds.token);
282
- // Parse --resume <conversation-id>
283
- let resumeConversationId;
284
- const resumeIdx = argv.indexOf('--resume');
285
- if (resumeIdx !== -1) {
286
- const nextArg = argv[resumeIdx + 1];
287
- if (!nextArg || nextArg.startsWith('-')) {
288
- console.error('Usage: --resume <conversation-id>');
21
+ let resumeId;
22
+ const ri = Math.max(argv.indexOf('--resume'), argv.indexOf('-resume'));
23
+ if (ri !== -1) {
24
+ resumeId = argv[ri + 1];
25
+ if (!resumeId || resumeId.startsWith('-')) {
26
+ console.error('--resume requires a conversation id');
289
27
  process.exit(1);
290
28
  }
291
- resumeConversationId = nextArg;
292
29
  }
293
- let agent = null;
294
- const ref = argv.find((a) => !a.startsWith('-') && a !== resumeConversationId);
295
- if (ref) {
296
- agent = await client.findAgentByNameOrId(ref);
297
- if (!agent) {
298
- console.error(`Agent not found: ${ref}`);
30
+ const ref = argv.find(arg => !arg.startsWith('-') && arg !== resumeId);
31
+ // Resolve org slug — from ref (org/slug) or JWT token
32
+ const defaultOrg = getOrgSlugFromToken(creds.token);
33
+ let orgSlug;
34
+ let agentSlug;
35
+ if (ref && ref.includes('/')) {
36
+ [orgSlug, agentSlug] = ref.split('/', 2);
37
+ }
38
+ else if (ref) {
39
+ // Bare slug — use org from JWT
40
+ if (!defaultOrg) {
41
+ console.error('Cannot determine org. Use org/agent-slug format or log in: npx @almyty/auth login');
299
42
  process.exit(1);
300
43
  }
44
+ orgSlug = defaultOrg;
45
+ agentSlug = ref;
301
46
  }
302
47
  else {
303
- agent = await pickAgent(client);
304
- if (!agent)
48
+ // No arg — interactive picker
49
+ if (!defaultOrg) {
50
+ console.error('Usage: npx @almyty/chat <org>/<agent-slug>');
305
51
  process.exit(1);
52
+ }
53
+ orgSlug = defaultOrg;
54
+ const agents = await client.listAgents();
55
+ if (!agents.length) {
56
+ console.error('No agents found. Create one at https://app.almyty.com/agents');
57
+ process.exit(1);
58
+ }
59
+ if (agents.length === 1) {
60
+ agentSlug = agents[0].slug || agents[0].name.toLowerCase().replace(/\s+/g, '-');
61
+ }
62
+ else {
63
+ const picked = await new Promise((resolve) => {
64
+ const { unmount } = render(_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingTop: 1, paddingLeft: 2, children: [_jsx(Text, { color: "#22d3ee", children: "\u26A1" }), _jsx(Text, { color: "#8b5cf6", bold: true, children: " almyty chat" })] }), _jsx(AgentSelector, { agents: agents, onSelect: (a) => { unmount(); resolve(a); } })] }), { exitOnCtrlC: true });
65
+ });
66
+ if (!picked)
67
+ process.exit(0);
68
+ agentSlug = picked.slug || picked.name.toLowerCase().replace(/\s+/g, '-');
69
+ }
70
+ }
71
+ const gw = client.gateway(orgSlug, agentSlug);
72
+ let agent;
73
+ try {
74
+ agent = await gw.getInfo();
75
+ }
76
+ catch {
77
+ console.error(`Agent not found: ${orgSlug}/${agentSlug}`);
78
+ process.exit(1);
79
+ }
80
+ const { waitUntilExit } = render(_jsx(ChatApp, { client: client, initialAgent: agent, gw: gw, resumeConversationId: resumeId }), { exitOnCtrlC: true });
81
+ await waitUntilExit();
82
+ if (exitMessage) {
83
+ process.stdout.write(exitMessage);
306
84
  }
307
- await startChat(client, agent, resumeConversationId);
308
85
  }
309
- main().catch((err) => {
310
- console.error(`Error: ${err.message}`);
86
+ main().catch(err => {
87
+ console.error(err.message);
311
88
  process.exit(1);
312
89
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@almyty/chat",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Interactive chat REPL with almyty agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -27,7 +27,13 @@
27
27
  "author": "almyty",
28
28
  "license": "BSL-1.1",
29
29
  "dependencies": {
30
- "@almyty/client": "^0.1.0"
30
+ "@almyty/client": "^0.1.0",
31
+ "@types/react": "^19.2.14",
32
+ "ink": "^7.0.1",
33
+ "ink-spinner": "^5.0.0",
34
+ "ink-testing-library": "^4.0.0",
35
+ "ink-text-input": "^6.0.0",
36
+ "react": "^19.2.5"
31
37
  },
32
38
  "devDependencies": {
33
39
  "@types/node": "^25.4.0",