@almyty/chat 0.2.0 → 1.3.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/turn.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * One turn of a conversation, driven the same way in both modes.
3
+ *
4
+ * The REPL and the non-interactive path used to be different code, and
5
+ * the non-interactive path did not exist. Both now call `runTurn`,
6
+ * which owns the choice between a streamed autonomous run and a
7
+ * streamed workflow pipeline, the cancellation handshake, and the
8
+ * cost tally. It takes the gateway as an interface so it can be tested
9
+ * against a fake without a network or a terminal.
10
+ */
11
+ import { drain, finalText, initialStreamState, reduceStreamEvent, routingFromSteps, } from './stream.js';
12
+ function isAborted(err, signal) {
13
+ const e = err;
14
+ return !!signal?.aborted || (!!e && (e.name === 'AbortError' || e.code === 'ABORT_ERR'));
15
+ }
16
+ /** True when the pipeline stream endpoint is not there to be used. */
17
+ function streamUnavailable(err) {
18
+ const status = err?.status;
19
+ if (status === 404 || status === 405)
20
+ return true;
21
+ return /Unknown agent action|SSE 404|SSE 405/.test(err?.message ?? '');
22
+ }
23
+ /** Wire the reducer's output into the hooks, once per event. */
24
+ function pump(hooks, state, event) {
25
+ const before = state.partial;
26
+ const beforeLabel = state.label;
27
+ let next = reduceStreamEvent(state, event);
28
+ const drained = drain(next);
29
+ next = drained.state;
30
+ for (const activity of drained.activities)
31
+ hooks?.activity?.(activity);
32
+ if (next.partial !== before)
33
+ hooks?.partial?.(next.partial);
34
+ if (next.label !== beforeLabel)
35
+ hooks?.label?.(next.label);
36
+ return next;
37
+ }
38
+ /** Fill in attribution and totals the stream did not carry. */
39
+ function settleUsage(usage, run) {
40
+ const settled = { ...usage };
41
+ if (!settled.model && run) {
42
+ const routing = routingFromSteps(run.steps);
43
+ if (routing) {
44
+ settled.model = routing.model ?? settled.model;
45
+ settled.rationale = routing.rationale ?? settled.rationale;
46
+ settled.attempt = routing.attempt ?? settled.attempt;
47
+ }
48
+ }
49
+ // The persisted totals are authoritative when the stream missed
50
+ // events (a reconnect, or a fallback to polling).
51
+ if (run && typeof run.totalCost === 'number' && run.totalCost > settled.cost)
52
+ settled.cost = run.totalCost;
53
+ if (run && typeof run.totalTokens === 'number' && run.totalTokens > settled.tokens)
54
+ settled.tokens = run.totalTokens;
55
+ return settled;
56
+ }
57
+ export async function runTurn(target, message, options = {}) {
58
+ const { hooks, signal } = options;
59
+ let state = initialStreamState();
60
+ hooks?.label?.(state.label);
61
+ if (options.mode === 'autonomous') {
62
+ let runId;
63
+ let conversationId = options.conversationId;
64
+ if (options.pendingRunId) {
65
+ await target.sendRunInput(options.pendingRunId, message);
66
+ runId = options.pendingRunId;
67
+ }
68
+ else {
69
+ const run = await target.startRun(message, { ...options.limits, conversationId });
70
+ runId = run.id;
71
+ conversationId = run.conversationId ?? conversationId;
72
+ }
73
+ let final;
74
+ try {
75
+ final = await target.streamRun(runId, (event) => { state = pump(hooks, state, event); }, signal);
76
+ }
77
+ catch (err) {
78
+ if (!isAborted(err, signal))
79
+ throw err;
80
+ // Stop the run where it is running, not just where it is watched.
81
+ // Killing the client alone leaves the run spending money with
82
+ // nobody reading the answer.
83
+ await target.cancelRun(runId).catch(() => { });
84
+ return {
85
+ status: 'cancelled',
86
+ text: state.partial.trim(),
87
+ usage: settleUsage(state.usage, undefined),
88
+ runId,
89
+ conversationId,
90
+ };
91
+ }
92
+ if (state.cancelled) {
93
+ return { status: 'cancelled', text: state.partial.trim(), usage: settleUsage(state.usage, final), runId, conversationId };
94
+ }
95
+ const usage = settleUsage(state.usage, final);
96
+ const text = finalText(state, final?.output);
97
+ if (final?.status === 'waiting_input') {
98
+ return { status: 'waiting_input', text, usage, runId, conversationId, pendingRunId: runId };
99
+ }
100
+ if (state.failed || final?.status === 'failed' || final?.status === 'timeout') {
101
+ return { status: 'failed', text, usage, error: state.failed ?? final?.error ?? 'The run failed', runId, conversationId };
102
+ }
103
+ return { status: 'completed', text, usage, runId, conversationId };
104
+ }
105
+ // Workflow agents: a pipeline, streamed so its nodes are visible.
106
+ // The execution id arrives on execution.started and is the only handle
107
+ // on the pipeline: it is not known before the stream opens, and without
108
+ // it a cancel has nothing to name.
109
+ let executionId;
110
+ try {
111
+ await target.streamInvoke({ message }, (event) => {
112
+ const id = event?.data?.executionId;
113
+ if (typeof id === 'string' && id)
114
+ executionId = id;
115
+ state = pump(hooks, state, event);
116
+ }, signal);
117
+ }
118
+ catch (err) {
119
+ if (isAborted(err, signal)) {
120
+ // Stop the pipeline where it is running, not just where it is
121
+ // watched -- the same handshake the autonomous branch does with
122
+ // cancelRun. A workflow run is an execution, not a run, so
123
+ // cancelRun could never reach it: Ctrl-C reported `cancelled`
124
+ // while the pipeline ran on, billing model calls nobody would read.
125
+ if (executionId)
126
+ await target.cancelExecution(executionId).catch(() => { });
127
+ return { status: 'cancelled', text: state.partial.trim(), usage: state.usage, conversationId: options.conversationId };
128
+ }
129
+ if (!streamUnavailable(err))
130
+ throw err;
131
+ // A deployment without the pipeline stream still answers the
132
+ // blocking call; the run is just invisible while it happens.
133
+ const result = await target.invoke({ message });
134
+ const output = result?.output ?? result?.data?.output ?? result;
135
+ return {
136
+ status: result?.status && result.status !== 'completed' ? 'failed' : 'completed',
137
+ text: finalText(state, output),
138
+ usage: state.usage,
139
+ error: result?.error,
140
+ conversationId: options.conversationId,
141
+ };
142
+ }
143
+ if (state.failed) {
144
+ return { status: 'failed', text: finalText(state), usage: state.usage, error: state.failed, conversationId: options.conversationId };
145
+ }
146
+ return { status: 'completed', text: finalText(state), usage: state.usage, conversationId: options.conversationId };
147
+ }
@@ -0,0 +1,2 @@
1
+ export declare function readVersion(fallback?: string): string;
2
+ export declare const VERSION: string;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The CLI's own version, read from its package.json at startup.
3
+ *
4
+ * Hardcoding it drifted: `--version` answered 0.2.0 while the
5
+ * published package was 1.2.0, so a bug report never identified the
6
+ * build it came from. Both `dist/index.js` and `src/index.tsx` sit one
7
+ * directory below the package root, so the same relative path resolves
8
+ * for the built bin and for `tsx src/index.tsx`.
9
+ *
10
+ * A single-file executable (the `/apps` tui target compiles this client
11
+ * with `bun --compile`) has no package.json to read, so it falls back.
12
+ * A compiled terminal app's version is the app's, not this client's,
13
+ * and the build is where that belongs.
14
+ */
15
+ import { readFileSync } from 'node:fs';
16
+ export function readVersion(fallback = '0.0.0') {
17
+ try {
18
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
19
+ return typeof pkg.version === 'string' && pkg.version.length > 0
20
+ ? pkg.version
21
+ : fallback;
22
+ }
23
+ catch {
24
+ return fallback;
25
+ }
26
+ }
27
+ export const VERSION = readVersion();
@@ -0,0 +1,61 @@
1
+ /**
2
+ * How much of the transcript fits on screen.
3
+ *
4
+ * ink redraws its whole tree, so a transcript taller than the terminal
5
+ * garbles the frame instead of scrolling it. The fix is to draw only
6
+ * what fits and say how much is above. Pure arithmetic, kept out of the
7
+ * components so it can be tested at any terminal size — including the
8
+ * 40-column window nobody remembers to try.
9
+ */
10
+ /** Narrowest width worth laying out for. */
11
+ export declare const MIN_COLUMNS = 40;
12
+ /** Fewest transcript rows to draw, even on a very short terminal. */
13
+ export declare const MIN_ROWS = 5;
14
+ export declare function columns(stdoutColumns?: number): number;
15
+ /**
16
+ * Rows left for the transcript once the header, prompt and status bar
17
+ * have taken theirs.
18
+ */
19
+ export declare function usableRows(stdoutRows: number | undefined, chromeRows: number): number;
20
+ /**
21
+ * How many terminal columns a string occupies.
22
+ *
23
+ * `String.length` counts UTF-16 units, which is not width. A CJK
24
+ * character is one unit and two columns; an emoji is two units and two
25
+ * columns; a combining mark or a zero-width joiner is a unit and no
26
+ * columns. Measuring by length made a Japanese or Chinese transcript
27
+ * estimate half its real height, so the window packed twice what fits
28
+ * and ink drew a frame taller than the terminal — the garbling the rest
29
+ * of this module exists to prevent.
30
+ */
31
+ export declare function displayWidth(text: string): number;
32
+ /**
33
+ * Rows one message will occupy at this width.
34
+ *
35
+ * Counts the wrap, not the characters: a 300-character paragraph is
36
+ * four rows at 80 columns and eight at 40.
37
+ */
38
+ export declare function estimateLines(text: string, cols: number): number;
39
+ export interface WindowSelection {
40
+ startIdx: number;
41
+ endIdx: number;
42
+ hiddenBefore: number;
43
+ hiddenAfter: number;
44
+ }
45
+ /**
46
+ * The slice of messages to draw.
47
+ *
48
+ * Walks backwards from the end so the newest message is always visible:
49
+ * a transcript that scrolls away from the answer you just asked for is
50
+ * worse than one that hides the beginning. The newest message is kept
51
+ * even when it alone is taller than the window, because dropping it
52
+ * would leave an empty screen.
53
+ */
54
+ export declare function selectWindow(messages: Array<{
55
+ text: string;
56
+ }>, options: {
57
+ rows: number;
58
+ cols: number;
59
+ reservedRows?: number;
60
+ scrollOffset?: number;
61
+ }): WindowSelection;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * How much of the transcript fits on screen.
3
+ *
4
+ * ink redraws its whole tree, so a transcript taller than the terminal
5
+ * garbles the frame instead of scrolling it. The fix is to draw only
6
+ * what fits and say how much is above. Pure arithmetic, kept out of the
7
+ * components so it can be tested at any terminal size — including the
8
+ * 40-column window nobody remembers to try.
9
+ */
10
+ /** Narrowest width worth laying out for. */
11
+ export const MIN_COLUMNS = 40;
12
+ /** Fewest transcript rows to draw, even on a very short terminal. */
13
+ export const MIN_ROWS = 5;
14
+ export function columns(stdoutColumns) {
15
+ return Math.max(stdoutColumns && stdoutColumns > 0 ? stdoutColumns : 80, MIN_COLUMNS);
16
+ }
17
+ /**
18
+ * Rows left for the transcript once the header, prompt and status bar
19
+ * have taken theirs.
20
+ */
21
+ export function usableRows(stdoutRows, chromeRows) {
22
+ const total = stdoutRows && stdoutRows > 0 ? stdoutRows : 24;
23
+ return Math.max(total - chromeRows, MIN_ROWS);
24
+ }
25
+ /**
26
+ * Code points that take two terminal columns: the East Asian Wide and
27
+ * Fullwidth ranges, plus the emoji blocks terminals render double-width.
28
+ */
29
+ function isWide(cp) {
30
+ return ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
31
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi, CJK symbols
32
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, Hangul, CJK compatibility
33
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK extension A
34
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK unified ideographs
35
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
36
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
37
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
38
+ (cp >= 0xfe30 && cp <= 0xfe6f) || // CJK compatibility forms
39
+ (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
40
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
41
+ (cp >= 0x1f300 && cp <= 0x1f64f) || // emoji, pictographs
42
+ (cp >= 0x1f680 && cp <= 0x1f6ff) ||
43
+ (cp >= 0x1f900 && cp <= 0x1f9ff) ||
44
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK extension B and later
45
+ );
46
+ }
47
+ /**
48
+ * How many terminal columns a string occupies.
49
+ *
50
+ * `String.length` counts UTF-16 units, which is not width. A CJK
51
+ * character is one unit and two columns; an emoji is two units and two
52
+ * columns; a combining mark or a zero-width joiner is a unit and no
53
+ * columns. Measuring by length made a Japanese or Chinese transcript
54
+ * estimate half its real height, so the window packed twice what fits
55
+ * and ink drew a frame taller than the terminal — the garbling the rest
56
+ * of this module exists to prevent.
57
+ */
58
+ export function displayWidth(text) {
59
+ let width = 0;
60
+ for (const ch of text) {
61
+ const cp = ch.codePointAt(0);
62
+ // Zero-width: combining marks, joiners, variation selectors.
63
+ if (cp === 0x200b || cp === 0x200d || cp === 0xfeff ||
64
+ (cp >= 0x0300 && cp <= 0x036f) ||
65
+ (cp >= 0x1ab0 && cp <= 0x1aff) ||
66
+ (cp >= 0x20d0 && cp <= 0x20f0) ||
67
+ (cp >= 0xfe00 && cp <= 0xfe0f)) {
68
+ continue;
69
+ }
70
+ width += isWide(cp) ? 2 : 1;
71
+ }
72
+ return width;
73
+ }
74
+ /**
75
+ * Rows one message will occupy at this width.
76
+ *
77
+ * Counts the wrap, not the characters: a 300-character paragraph is
78
+ * four rows at 80 columns and eight at 40.
79
+ */
80
+ export function estimateLines(text, cols) {
81
+ const textWidth = Math.max(cols - 10, 20);
82
+ const lines = text.split('\n');
83
+ let total = 1; // the margin every message carries
84
+ for (const line of lines) {
85
+ total += Math.max(1, Math.ceil(Math.max(displayWidth(line), 1) / textWidth));
86
+ }
87
+ return total;
88
+ }
89
+ /**
90
+ * The slice of messages to draw.
91
+ *
92
+ * Walks backwards from the end so the newest message is always visible:
93
+ * a transcript that scrolls away from the answer you just asked for is
94
+ * worse than one that hides the beginning. The newest message is kept
95
+ * even when it alone is taller than the window, because dropping it
96
+ * would leave an empty screen.
97
+ */
98
+ export function selectWindow(messages, options) {
99
+ const scrollOffset = Math.max(0, Math.min(options.scrollOffset ?? 0, messages.length));
100
+ const available = Math.max(options.rows - (options.reservedRows ?? 0), MIN_ROWS);
101
+ const endIdx = Math.max(0, messages.length - scrollOffset);
102
+ let used = 0;
103
+ let startIdx = endIdx;
104
+ for (let i = endIdx - 1; i >= 0; i--) {
105
+ const cost = estimateLines(messages[i].text, options.cols);
106
+ // The last message always goes in; anything earlier has to fit.
107
+ if (used + cost > available && i < endIdx - 1)
108
+ break;
109
+ used += cost;
110
+ startIdx = i;
111
+ }
112
+ return {
113
+ startIdx,
114
+ endIdx,
115
+ hiddenBefore: startIdx,
116
+ hiddenAfter: messages.length - endIdx,
117
+ };
118
+ }
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@almyty/chat",
3
- "version": "0.2.0",
4
- "description": "Interactive chat REPL with almyty agents",
3
+ "version": "1.3.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Chat with your almyty agents in an interactive terminal REPL — multi-turn and streaming, with tool calls and autonomous runs.",
5
8
  "type": "module",
6
9
  "main": "dist/index.js",
7
10
  "bin": {
@@ -12,7 +15,8 @@
12
15
  ],
13
16
  "scripts": {
14
17
  "build": "tsc && chmod +x dist/index.js",
15
- "dev": "tsx src/index.ts",
18
+ "dev": "tsx src/index.tsx",
19
+ "typecheck": "tsc --noEmit",
16
20
  "test": "vitest run",
17
21
  "test:watch": "vitest",
18
22
  "prepublishOnly": "npm run build"
@@ -25,20 +29,32 @@
25
29
  "cli"
26
30
  ],
27
31
  "author": "almyty",
28
- "license": "BSL-1.1",
32
+ "license": "Apache-2.0",
29
33
  "dependencies": {
30
- "@almyty/client": "^0.1.0",
31
- "@types/react": "^19.2.14",
34
+ "@almyty/client": "^1.2.0",
32
35
  "ink": "^7.0.1",
33
36
  "ink-spinner": "^5.0.0",
34
- "ink-testing-library": "^4.0.0",
35
37
  "ink-text-input": "^6.0.0",
36
38
  "react": "^19.2.5"
37
39
  },
38
40
  "devDependencies": {
39
41
  "@types/node": "^25.4.0",
42
+ "@types/react": "^19.2.14",
43
+ "ink-testing-library": "^4.0.0",
40
44
  "tsx": "^4.7.0",
41
45
  "typescript": "^5.3.0",
42
46
  "vitest": "^4.1.0"
47
+ },
48
+ "homepage": "https://almyty.com",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/almyty-inc/almyty.git",
52
+ "directory": "packages/chat-cli"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/almyty-inc/almyty/issues"
56
+ },
57
+ "overrides": {
58
+ "postcss": "^8.5.23"
43
59
  }
44
60
  }