@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/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
+ }
@@ -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>;
@@ -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
+ };
@@ -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
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- export declare const VERSION = "0.2.0";
2
+ import { VERSION } from './version.js';
3
+ export { VERSION };