felo-ai 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/src/slides.js ADDED
@@ -0,0 +1,228 @@
1
+ import { getApiKey, fetchWithTimeoutAndRetry, NO_KEY_MESSAGE } from './search.js';
2
+
3
+ const DEFAULT_API_BASE = 'https://openapi.felo.ai';
4
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
5
+ const POLL_INTERVAL_MS = 3000;
6
+ const MAX_POLL_TIMEOUT_MS = 600_000; // 10 minutes max wait
7
+
8
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
9
+ const STATUS_LINE_PAD = 50;
10
+
11
+ /** API base URL (default https://openapi.felo.ai). Override via FELO_API_BASE env or config if needed. */
12
+ async function getApiBase() {
13
+ let base = process.env.FELO_API_BASE?.trim();
14
+ if (!base) {
15
+ const { getConfigValue } = await import('./config.js');
16
+ const v = await getConfigValue('FELO_API_BASE');
17
+ base = typeof v === 'string' ? v.trim() : '';
18
+ }
19
+ const normalized = (base || DEFAULT_API_BASE).replace(/\/$/, '');
20
+ return normalized;
21
+ }
22
+
23
+ function sleep(ms) {
24
+ return new Promise((resolve) => setTimeout(resolve, ms));
25
+ }
26
+
27
+ /** Write a single overwritable status line: spinner + elapsed. Call clearStatusLine() before next stdout. */
28
+ function writeStatusLine(spinnerFrame, elapsedSec) {
29
+ const s = `Generating slides... ${spinnerFrame} ${elapsedSec}s`;
30
+ const padded = s.padEnd(STATUS_LINE_PAD, ' ');
31
+ process.stderr.write(`\r${padded}`);
32
+ }
33
+
34
+ function clearStatusLine() {
35
+ process.stderr.write(`\r${' '.repeat(STATUS_LINE_PAD)}\r`);
36
+ }
37
+
38
+ /**
39
+ * Create a PPT task. Returns { task_id, livedoc_short_id, ppt_business_id } or throws.
40
+ * Uses fetchWithTimeoutAndRetry for 5xx retry (per PPT Task API error codes).
41
+ */
42
+ async function createPptTask(apiKey, query, timeoutMs, apiBase) {
43
+ const url = `${apiBase}/v2/ppts`;
44
+ const res = await fetchWithTimeoutAndRetry(
45
+ url,
46
+ {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Accept': 'application/json',
50
+ 'Authorization': `Bearer ${apiKey}`,
51
+ 'Content-Type': 'application/json',
52
+ },
53
+ body: JSON.stringify({ query: query.trim() }),
54
+ },
55
+ timeoutMs
56
+ );
57
+
58
+ const data = await res.json().catch(() => ({}));
59
+
60
+ if (data.status === 'error') {
61
+ const msg = data.message || data.code || 'Unknown error';
62
+ throw new Error(msg);
63
+ }
64
+
65
+ if (!res.ok) {
66
+ const msg = data.message || data.error || res.statusText || `HTTP ${res.status}`;
67
+ throw new Error(msg);
68
+ }
69
+
70
+ const payload = data.data;
71
+ if (!payload || !payload.task_id) {
72
+ throw new Error('Unexpected response: missing task_id');
73
+ }
74
+
75
+ return payload;
76
+ }
77
+
78
+ /**
79
+ * Get task historical info. Returns { task_status, live_doc_url? } or throws.
80
+ * Uses fetchWithTimeoutAndRetry for 5xx retry (per PPT Task API error codes).
81
+ */
82
+ async function getTaskHistorical(apiKey, taskId, timeoutMs, apiBase) {
83
+ const url = `${apiBase}/v2/tasks/${encodeURIComponent(taskId)}/historical`;
84
+ const res = await fetchWithTimeoutAndRetry(
85
+ url,
86
+ {
87
+ method: 'GET',
88
+ headers: {
89
+ 'Accept': 'application/json',
90
+ 'Authorization': `Bearer ${apiKey}`,
91
+ },
92
+ },
93
+ timeoutMs
94
+ );
95
+
96
+ const data = await res.json().catch(() => ({}));
97
+
98
+ if (data.status === 'error') {
99
+ const msg = data.message || data.code || 'Unknown error';
100
+ throw new Error(msg);
101
+ }
102
+
103
+ if (!res.ok) {
104
+ const msg = data.message || data.error || res.statusText || `HTTP ${res.status}`;
105
+ throw new Error(msg);
106
+ }
107
+
108
+ const payload = data.data;
109
+ if (!payload) {
110
+ throw new Error('Unexpected response: missing data');
111
+ }
112
+
113
+ return {
114
+ task_status: payload.task_status,
115
+ live_doc_url: payload.live_doc_url,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Run slides: create PPT task, poll until terminal state, output URL or error.
121
+ * @returns {Promise<number>} exit code (0 success, 1 failure)
122
+ */
123
+ export async function slides(query, options = {}) {
124
+ const apiKey = await getApiKey();
125
+ if (!apiKey) {
126
+ console.error(NO_KEY_MESSAGE.trim());
127
+ return 1;
128
+ }
129
+
130
+ const requestTimeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
131
+ const pollTimeoutMs = options.pollTimeoutMs ?? MAX_POLL_TIMEOUT_MS;
132
+ const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
133
+
134
+ try {
135
+ const apiBase = await getApiBase();
136
+
137
+ process.stderr.write('Creating PPT task...\n');
138
+
139
+ const createResult = await createPptTask(apiKey, query, requestTimeoutMs, apiBase);
140
+ const taskId = createResult.task_id;
141
+
142
+ if (options.json && options.verbose) {
143
+ process.stderr.write(`Task ID: ${taskId}\n`);
144
+ }
145
+
146
+ const useLiveStatus =
147
+ process.stderr.isTTY && !options.verbose && !options.json;
148
+ if (!useLiveStatus) {
149
+ process.stderr.write('Generating slides (this may take a minute)...\n');
150
+ }
151
+
152
+ const startTime = Date.now();
153
+ let lastStatus;
154
+ let spinIndex = 0;
155
+ if (useLiveStatus) writeStatusLine(SPINNER_FRAMES[0], 0);
156
+
157
+ while (Date.now() - startTime < pollTimeoutMs) {
158
+ await sleep(pollIntervalMs);
159
+
160
+ const historical = await getTaskHistorical(apiKey, taskId, requestTimeoutMs, apiBase);
161
+ lastStatus = historical.task_status;
162
+
163
+ if (useLiveStatus) {
164
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
165
+ spinIndex = (spinIndex + 1) % SPINNER_FRAMES.length;
166
+ writeStatusLine(SPINNER_FRAMES[spinIndex], elapsed);
167
+ }
168
+
169
+ // Doc: historical returns task_status "COMPLETED"; polling section also mentions "SUCCESS"
170
+ const done = historical.task_status === 'COMPLETED' || historical.task_status === 'SUCCESS';
171
+ if (done) {
172
+ if (useLiveStatus) clearStatusLine();
173
+ const url = historical.live_doc_url;
174
+ if (options.json) {
175
+ console.log(
176
+ JSON.stringify(
177
+ {
178
+ status: 'ok',
179
+ data: {
180
+ task_id: taskId,
181
+ task_status: historical.task_status,
182
+ live_doc_url: url,
183
+ livedoc_short_id: createResult.livedoc_short_id,
184
+ ppt_business_id: createResult.ppt_business_id,
185
+ },
186
+ },
187
+ null,
188
+ 2
189
+ )
190
+ );
191
+ } else {
192
+ if (url) {
193
+ console.log(url);
194
+ } else {
195
+ if (useLiveStatus) clearStatusLine();
196
+ console.error('Error: Completed but no live_doc_url in response');
197
+ return 1;
198
+ }
199
+ }
200
+ return 0;
201
+ }
202
+
203
+ if (
204
+ historical.task_status === 'FAILED' ||
205
+ historical.task_status === 'ERROR' ||
206
+ (historical.task_status !== 'RUNNING' && historical.task_status !== 'PENDING')
207
+ ) {
208
+ if (useLiveStatus) clearStatusLine();
209
+ console.error(`Error: Task finished with status: ${historical.task_status}`);
210
+ return 1;
211
+ }
212
+
213
+ if (options.verbose) {
214
+ process.stderr.write(` Status: ${historical.task_status}\n`);
215
+ }
216
+ }
217
+
218
+ if (useLiveStatus) clearStatusLine();
219
+ console.error(
220
+ `Error: Timed out after ${pollTimeoutMs / 1000}s. Last status: ${lastStatus ?? 'unknown'}`
221
+ );
222
+ return 1;
223
+ } catch (err) {
224
+ if (process.stderr.isTTY && !options.verbose && !options.json) clearStatusLine();
225
+ console.error('Error:', err.message || err);
226
+ return 1;
227
+ }
228
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ import os from 'os';
6
+
7
+ const tmpDir = path.join(os.tmpdir(), `felo-test-${Date.now()}`);
8
+ const testConfigFile = path.join(tmpDir, 'config.json');
9
+ process.env.FELO_CONFIG_FILE = testConfigFile;
10
+
11
+ import * as config from '../src/config.js';
12
+
13
+ after(async () => {
14
+ delete process.env.FELO_CONFIG_FILE;
15
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
16
+ });
17
+
18
+ describe('config', () => {
19
+ it('getConfig returns {} when file does not exist', async () => {
20
+ const c = await config.getConfig();
21
+ assert.deepStrictEqual(c, {});
22
+ });
23
+
24
+ it('setConfig and getConfigValue', async () => {
25
+ await config.setConfig('FELO_API_KEY', 'fk-secret-123');
26
+ const v = await config.getConfigValue('FELO_API_KEY');
27
+ assert.strictEqual(v, 'fk-secret-123');
28
+ });
29
+
30
+ it('listConfig returns all keys', async () => {
31
+ await config.setConfig('OTHER', 'value');
32
+ const c = await config.listConfig();
33
+ assert.ok('FELO_API_KEY' in c);
34
+ assert.ok('OTHER' in c);
35
+ });
36
+
37
+ it('unsetConfig removes key', async () => {
38
+ await config.unsetConfig('OTHER');
39
+ const v = await config.getConfigValue('OTHER');
40
+ assert.strictEqual(v, undefined);
41
+ });
42
+
43
+ it('getConfigPath returns configured path', () => {
44
+ assert.strictEqual(config.getConfigPath(), testConfigFile);
45
+ });
46
+
47
+ it('getConfig returns {} and warns on invalid JSON', async () => {
48
+ await config.setConfig('X', '1');
49
+ await fs.writeFile(testConfigFile, 'not json {', 'utf-8');
50
+ let writeCalls = 0;
51
+ const orig = process.stderr.write;
52
+ process.stderr.write = () => { writeCalls++; };
53
+ const c = await config.getConfig();
54
+ process.stderr.write = orig;
55
+ assert.deepStrictEqual(c, {});
56
+ assert.ok(writeCalls >= 1);
57
+ });
58
+
59
+ describe('maskValueForDisplay', () => {
60
+ it('masks FELO_API_KEY when value long enough', () => {
61
+ const val = 'fk-abc123xyz789';
62
+ assert.strictEqual(config.maskValueForDisplay('FELO_API_KEY', val), 'fk-a...z789');
63
+ });
64
+
65
+ it('does not mask short value', () => {
66
+ assert.strictEqual(config.maskValueForDisplay('FELO_API_KEY', 'short'), 'short');
67
+ });
68
+
69
+ it('does not mask non-sensitive key', () => {
70
+ assert.strictEqual(config.maskValueForDisplay('LOG_LEVEL', 'debug'), 'debug');
71
+ });
72
+
73
+ it('returns value for undefined/null', () => {
74
+ assert.strictEqual(config.maskValueForDisplay('X', undefined), undefined);
75
+ assert.strictEqual(config.maskValueForDisplay('X', null), null);
76
+ });
77
+ });
78
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import fs from 'fs/promises';
6
+
7
+ const tmpDir = path.join(os.tmpdir(), `felo-test-search-${Date.now()}`);
8
+ const testConfigFile = path.join(tmpDir, 'config.json');
9
+ process.env.FELO_CONFIG_FILE = testConfigFile;
10
+
11
+ import { getApiKey, fetchWithTimeoutAndRetry } from '../src/search.js';
12
+ import * as config from '../src/config.js';
13
+
14
+ after(async () => {
15
+ delete process.env.FELO_API_KEY;
16
+ delete process.env.FELO_CONFIG_FILE;
17
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
18
+ });
19
+
20
+ before(async () => {
21
+ await fs.mkdir(path.dirname(testConfigFile), { recursive: true });
22
+ });
23
+
24
+ describe('getApiKey', () => {
25
+ it('returns env FELO_API_KEY when set', async () => {
26
+ process.env.FELO_API_KEY = 'env-key';
27
+ const key = await getApiKey();
28
+ assert.strictEqual(key, 'env-key');
29
+ });
30
+
31
+ it('returns key from config when env not set', async () => {
32
+ delete process.env.FELO_API_KEY;
33
+ await config.setConfig('FELO_API_KEY', 'config-key');
34
+ const key = await getApiKey();
35
+ assert.strictEqual(key, 'config-key');
36
+ });
37
+
38
+ it('returns empty string when neither set', async () => {
39
+ delete process.env.FELO_API_KEY;
40
+ await config.unsetConfig('FELO_API_KEY');
41
+ const key = await getApiKey();
42
+ assert.strictEqual(key, '');
43
+ });
44
+
45
+ it('env wins over config', async () => {
46
+ process.env.FELO_API_KEY = 'env-wins';
47
+ await config.setConfig('FELO_API_KEY', 'config-key');
48
+ const key = await getApiKey();
49
+ assert.strictEqual(key, 'env-wins');
50
+ });
51
+ });
52
+
53
+ describe('fetchWithTimeoutAndRetry', () => {
54
+ it('returns response on 200', async () => {
55
+ const res = { ok: true, status: 200 };
56
+ let callCount = 0;
57
+ const origFetch = globalThis.fetch;
58
+ globalThis.fetch = () => { callCount++; return Promise.resolve(res); };
59
+ try {
60
+ const out = await fetchWithTimeoutAndRetry('https://example.com', {}, 10_000);
61
+ assert.strictEqual(out, res);
62
+ assert.strictEqual(callCount, 1);
63
+ } finally {
64
+ globalThis.fetch = origFetch;
65
+ }
66
+ });
67
+
68
+ it('retries on 500 then succeeds', async () => {
69
+ const res200 = { ok: true, status: 200 };
70
+ const res500 = { ok: false, status: 502 };
71
+ let callCount = 0;
72
+ const origFetch = globalThis.fetch;
73
+ globalThis.fetch = () => {
74
+ callCount++;
75
+ return Promise.resolve(callCount === 1 ? res500 : res200);
76
+ };
77
+ try {
78
+ const out = await fetchWithTimeoutAndRetry('https://example.com', {}, 10_000);
79
+ assert.strictEqual(out, res200);
80
+ assert.strictEqual(callCount, 2);
81
+ } finally {
82
+ globalThis.fetch = origFetch;
83
+ }
84
+ });
85
+
86
+ it('throws on timeout (AbortError)', async () => {
87
+ const origFetch = globalThis.fetch;
88
+ const abortErr = new Error('The operation was aborted');
89
+ abortErr.name = 'AbortError';
90
+ globalThis.fetch = () => Promise.reject(abortErr);
91
+ try {
92
+ await assert.rejects(
93
+ async () => fetchWithTimeoutAndRetry('https://example.com', {}, 5000),
94
+ (err) => err.message && err.message.includes('timed out')
95
+ );
96
+ } finally {
97
+ globalThis.fetch = origFetch;
98
+ }
99
+ });
100
+ });