nansen-cli 1.20.0 → 1.22.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.
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Nansen CLI - Agent command
3
+ * Interactive research agent with fast/expert modes via SSE streaming.
4
+ */
5
+
6
+ import crypto from 'crypto';
7
+ import { NansenError, ErrorCode, statusToErrorCode, telemetryHeaders, packageVersion } from '../api.js';
8
+ import { getCostForEndpoint } from '../cost-cache.js';
9
+
10
+ /**
11
+ * Build standard request headers, matching apiInstance.request() conventions.
12
+ */
13
+ function buildHeaders(apiInstance) {
14
+ return {
15
+ 'Content-Type': 'application/json',
16
+ 'X-Client-Type': 'nansen-cli',
17
+ 'X-Client-Version': packageVersion,
18
+ ...telemetryHeaders(),
19
+ ...(apiInstance.apiKey ? { 'apikey': apiInstance.apiKey } : {}),
20
+ ...(apiInstance.defaultHeaders || {}),
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Throw a NansenError with the same structure as apiInstance.request() errors.
26
+ * Includes `details` field for consistency with other commands.
27
+ */
28
+ function throwApiError(message, status, serverDetail) {
29
+ // Match the friendly wrapper messages from apiInstance.request()
30
+ let friendlyMessage = message;
31
+ if (status === 401) {
32
+ friendlyMessage = 'Not logged in. Run: nansen login';
33
+ } else if (status === 429) {
34
+ friendlyMessage = 'Rate limited. Try again in a few seconds.';
35
+ }
36
+
37
+ throw new NansenError(
38
+ friendlyMessage,
39
+ statusToErrorCode(status),
40
+ status,
41
+ { detail: serverDetail || message, attempt: 1, retryAfterMs: null },
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Process an SSE response from the agent endpoint.
47
+ *
48
+ * In buffered mode (no callbacks), collects everything and returns it.
49
+ * In streaming mode (callbacks provided), invokes them as events arrive.
50
+ *
51
+ * @param {Response} response – fetch Response with SSE body
52
+ * @param {object} [callbacks]
53
+ * @param {Function} [callbacks.onDelta] – called with each text chunk
54
+ * @param {Function} [callbacks.onToolCall] – called with each tool name
55
+ * @returns {{ text: string, toolCalls: string[], conversationId: string|null }}
56
+ */
57
+ export async function consumeSSEStream(response, callbacks = {}) {
58
+ const { onDelta, onToolCall } = callbacks;
59
+ const chunks = [];
60
+ const toolCalls = [];
61
+ let conversationId = null;
62
+ let errorPayload = null;
63
+
64
+ const reader = response.body;
65
+ const decoder = new TextDecoder();
66
+ let buffer = '';
67
+
68
+ for await (const raw of reader) {
69
+ buffer += decoder.decode(raw, { stream: true });
70
+
71
+ // Normalize \r\n and \r to \n (SSE spec allows all three line terminators)
72
+ buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
73
+
74
+ // SSE: split on double-newline boundaries
75
+ let boundary;
76
+ let done = false;
77
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
78
+ const frame = buffer.slice(0, boundary);
79
+ buffer = buffer.slice(boundary + 2);
80
+
81
+ for (const line of frame.split('\n')) {
82
+ if (!line.startsWith('data: ')) continue;
83
+ const payload = line.slice(6);
84
+ if (payload === '[DONE]') { done = true; break; }
85
+
86
+ let event;
87
+ try {
88
+ event = JSON.parse(payload);
89
+ } catch {
90
+ continue;
91
+ }
92
+
93
+ switch (event.type) {
94
+ case 'delta':
95
+ if (event.text) {
96
+ chunks.push(event.text);
97
+ if (onDelta) onDelta(event.text);
98
+ }
99
+ break;
100
+ case 'tool_call':
101
+ if (event.name) {
102
+ toolCalls.push(event.name);
103
+ if (onToolCall) onToolCall(event.name);
104
+ }
105
+ break;
106
+ case 'finish':
107
+ conversationId = event.conversation_id ?? null;
108
+ break;
109
+ case 'error':
110
+ errorPayload = event;
111
+ break;
112
+ }
113
+ }
114
+ if (done) break;
115
+ }
116
+ if (done) break;
117
+ }
118
+
119
+ if (errorPayload) {
120
+ const status = errorPayload.status_code || 502;
121
+ throwApiError(
122
+ errorPayload.error || 'Agent request failed',
123
+ status,
124
+ errorPayload.error,
125
+ );
126
+ }
127
+
128
+ return { text: chunks.join(''), toolCalls, conversationId };
129
+ }
130
+
131
+ /**
132
+ * Build the `agent` command handler.
133
+ *
134
+ * @param {object} [deps]
135
+ * @param {Function} [deps.log] – stdout line output (default: console.log)
136
+ * @param {Function} [deps.errorLog] – stderr line output (default: console.error)
137
+ * @param {Function} [deps.write] – raw stdout writer, no trailing newline (default: process.stdout.write)
138
+ * @returns {object} command map
139
+ */
140
+ export function buildAgentCommands(deps = {}) {
141
+ const {
142
+ log = console.log,
143
+ errorLog = console.error,
144
+ write = (s) => process.stdout.write(s),
145
+ } = deps;
146
+
147
+ return {
148
+ 'agent': async (args, apiInstance, flags, options) => {
149
+ // ── Help ──
150
+ if (flags.help || flags.h || args[0] === 'help' || args.length === 0) {
151
+ const fmtCost = (c) => `${c.free} credit${c.free === 1 ? '' : 's'} (Free tier) / ${c.pro} credit${c.pro === 1 ? '' : 's'} (Pro tier)`;
152
+ const fastCost = getCostForEndpoint('/api/v1/agent/fast');
153
+ const expertCost = getCostForEndpoint('/api/v1/agent/expert');
154
+ const costSection = (fastCost || expertCost)
155
+ ? `\nCOST:\n${fastCost ? ` fast: ${fmtCost(fastCost)}\n` : ''}${expertCost ? ` expert: ${fmtCost(expertCost)}\n` : ''}`
156
+ : '';
157
+ log(`nansen agent — Nansen Research Agent
158
+
159
+ Ask the Nansen AI agent research questions about crypto wallets, tokens,
160
+ smart money flows, and on-chain activity. The agent uses Nansen's full
161
+ data platform to answer your questions.
162
+
163
+ MODES:
164
+ fast Faster responses, best for simple lookups (default)
165
+ expert Deeper analysis, uses a more capable model
166
+
167
+ USAGE:
168
+ nansen agent "<question>"
169
+ nansen agent "<question>" --expert
170
+ nansen agent "<question>" --conversation-id <id>
171
+
172
+ OPTIONS:
173
+ --expert Use expert mode (default: fast)
174
+ --conversation-id <uuid> Continue a previous conversation (UUID v4)
175
+ --json Output raw JSON instead of formatted text
176
+ ${costSection}
177
+ CONVERSATION FLOW:
178
+ Each request generates a UUID v4 conversation ID. To continue a
179
+ multi-turn conversation, pass it back with --conversation-id. The ID
180
+ and a ready-to-copy follow-up command are printed to stderr after each
181
+ response.
182
+
183
+ EXAMPLES:
184
+ nansen agent "What are the top smart money inflows on Ethereum today?"
185
+ nansen agent "Show me the largest whale wallets on Solana"
186
+ nansen agent "Analyze wallet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" --expert
187
+ nansen agent "Tell me more about their DeFi positions" --conversation-id 550e8400-e29b-41d4-a716-446655440000`);
188
+ return;
189
+ }
190
+
191
+ // ── Parse question ──
192
+ const question = args.join(' ').trim();
193
+ if (!question) {
194
+ throw new NansenError(
195
+ 'Query cannot be empty. Usage: nansen agent "<question>"',
196
+ ErrorCode.INVALID_PARAMS,
197
+ null,
198
+ { detail: 'Empty query string' },
199
+ );
200
+ }
201
+
202
+ // ── Mode ──
203
+ const expert = !!flags.expert;
204
+ const endpoint = expert ? '/api/v1/agent/expert' : '/api/v1/agent/fast';
205
+ const modeName = expert ? 'expert' : 'fast';
206
+
207
+ // ── Conversation ID (must be UUID v4) ──
208
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
209
+ const rawConvId = options['conversation-id'];
210
+ let conversationId;
211
+ if (typeof rawConvId === 'string' && rawConvId) {
212
+ if (!UUID_RE.test(rawConvId)) {
213
+ throw new NansenError(
214
+ `Invalid --conversation-id: expected a UUID (e.g. 550e8400-e29b-41d4-a716-446655440000), got "${rawConvId.slice(0, 60)}${rawConvId.length > 60 ? '...' : ''}"`,
215
+ ErrorCode.INVALID_PARAMS,
216
+ null,
217
+ { detail: 'conversation-id must be a UUID v4' },
218
+ );
219
+ }
220
+ conversationId = rawConvId;
221
+ } else {
222
+ conversationId = crypto.randomUUID();
223
+ }
224
+
225
+ // ── Auth guard ──
226
+ if (!apiInstance.apiKey) {
227
+ throw new NansenError(
228
+ 'Not logged in. Run: nansen login',
229
+ ErrorCode.UNAUTHORIZED,
230
+ 401,
231
+ { detail: 'No API key configured' },
232
+ );
233
+ }
234
+
235
+ // ── Request (no retry — SSE streams are not idempotent) ──
236
+ const url = `${apiInstance.baseUrl}${endpoint}`;
237
+ const body = {
238
+ text: question,
239
+ conversation_id: conversationId,
240
+ };
241
+
242
+ // ── Timeout ──
243
+ const timeoutMs = expert ? 300_000 : 120_000; // 5min expert, 2min fast
244
+ const controller = new AbortController();
245
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
246
+
247
+ let response;
248
+ try {
249
+ response = await fetch(url, {
250
+ method: 'POST',
251
+ headers: buildHeaders(apiInstance),
252
+ body: JSON.stringify(body),
253
+ signal: controller.signal,
254
+ });
255
+ } catch (err) {
256
+ clearTimeout(timer);
257
+ if (err.name === 'AbortError') {
258
+ throw new NansenError(
259
+ `Request timed out after ${timeoutMs / 1000}s`,
260
+ ErrorCode.TIMEOUT,
261
+ 504,
262
+ { detail: `${modeName} mode timeout (${timeoutMs / 1000}s)` },
263
+ );
264
+ }
265
+ throw new NansenError(
266
+ `Network error: ${err.message}`,
267
+ ErrorCode.NETWORK_ERROR,
268
+ null,
269
+ { originalError: err.message },
270
+ );
271
+ }
272
+
273
+ if (!response.ok) {
274
+ clearTimeout(timer);
275
+ let serverDetail;
276
+ if (response.headers.get('content-type')?.includes('application/json')) {
277
+ try {
278
+ const errData = await response.json();
279
+ serverDetail = errData.detail || errData.message;
280
+ } catch { /* ignore parse failure */ }
281
+ }
282
+ throwApiError(
283
+ serverDetail || `Agent returned ${response.status}`,
284
+ response.status,
285
+ serverDetail,
286
+ );
287
+ }
288
+
289
+ // ── JSON mode: buffer everything, return structured data ──
290
+ if (flags.json) {
291
+ let result;
292
+ try {
293
+ result = await consumeSSEStream(response);
294
+ } finally {
295
+ clearTimeout(timer);
296
+ }
297
+ return {
298
+ conversation_id: result.conversationId || conversationId,
299
+ mode: modeName,
300
+ text: result.text,
301
+ tool_calls: result.toolCalls,
302
+ };
303
+ }
304
+
305
+ // ── Streaming output mode ──
306
+ let hasOutput = false;
307
+ let midLine = false; // true when write() was called without a trailing newline
308
+ let result;
309
+ try {
310
+ result = await consumeSSEStream(response, {
311
+ onDelta(text) {
312
+ if (!midLine && text.trim() === '') return;
313
+ write(text);
314
+ hasOutput = true;
315
+ midLine = text.length > 0 && !text.endsWith('\n');
316
+ },
317
+ onToolCall(name) {
318
+ if (midLine) { write('\n'); midLine = false; }
319
+ errorLog(`⚙ ${name}`);
320
+ },
321
+ });
322
+ } finally {
323
+ clearTimeout(timer);
324
+ }
325
+
326
+ // Ensure a trailing newline after streamed text
327
+ if (midLine) {
328
+ write('\n');
329
+ }
330
+
331
+ if (!hasOutput) {
332
+ log('(no response from agent)');
333
+ }
334
+
335
+ // Print conversation continuation hint
336
+ const effectiveConvId = result.conversationId || conversationId;
337
+ const expertFlag = expert ? ' --expert' : '';
338
+ errorLog(`\nTo continue this conversation:`);
339
+ errorLog(` nansen agent "<follow-up>" --conversation-id "${effectiveConvId}"${expertFlag}`);
340
+
341
+ return;
342
+ },
343
+ };
344
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Credit cost cache — reads per-endpoint costs from a local cache populated
3
+ * by an inline fetch of the Nansen OpenAPI spec (at most once per 24h).
4
+ *
5
+ * getCostForEndpoint(endpoint) — sync, reads cache, returns { free, pro } or null
6
+ * refreshCostMapIfStale() — async, fetches inline if cache is missing or stale
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+
12
+ const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.nansen');
13
+ const CACHE_FILE = path.join(CONFIG_DIR, 'cost-map.json');
14
+ const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
15
+ const OPENAPI_URL = 'https://api.nansen.ai/openapi.json';
16
+
17
+ /**
18
+ * Returns { free, pro } credit cost for the given API path, or null if unavailable.
19
+ */
20
+ export function getCostForEndpoint(endpoint) {
21
+ try {
22
+ if (!fs.existsSync(CACHE_FILE)) return null;
23
+ const { costs } = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
24
+ return costs?.[endpoint] ?? null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Fetches the OpenAPI spec and writes the cost map to disk if the cache is
32
+ * missing or older than 24h. Awaited inline — only blocks on cold/stale cache.
33
+ * Silent on any error.
34
+ */
35
+ export async function refreshCostMapIfStale() {
36
+ try {
37
+ if (fs.existsSync(CACHE_FILE)) {
38
+ const { fetchedAt } = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
39
+ if (fetchedAt && Date.now() - fetchedAt < STALE_MS) return;
40
+ }
41
+
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), 10_000);
44
+ let spec;
45
+ try {
46
+ const res = await fetch(OPENAPI_URL, { signal: controller.signal });
47
+ spec = await res.json();
48
+ } finally {
49
+ clearTimeout(timer);
50
+ }
51
+
52
+ const costs = {};
53
+ for (const [p, methods] of Object.entries(spec.paths || {})) {
54
+ for (const op of Object.values(methods)) {
55
+ if (op['x-credit-cost']) {
56
+ costs[p] = op['x-credit-cost'];
57
+ break;
58
+ }
59
+ }
60
+ }
61
+
62
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { mode: 0o700, recursive: true });
63
+ fs.writeFileSync(CACHE_FILE, JSON.stringify({ costs, fetchedAt: Date.now() }));
64
+ } catch {
65
+ // silent — network failure, parse error, write error
66
+ }
67
+ }