askimo 1.1.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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  A CLI tool for communicating with AI providers.
9
9
 
10
- **Supported providers:** Perplexity · OpenAI · Anthropic
10
+ **Supported providers:** Perplexity · OpenAI · Anthropic · xAI (Grok)
11
11
 
12
12
  ---
13
13
 
@@ -26,12 +26,14 @@ Create a config file at `~/.askimo/config`:
26
26
  PERPLEXITY_API_KEY=your-perplexity-key
27
27
  OPENAI_API_KEY=your-openai-key
28
28
  ANTHROPIC_API_KEY=your-anthropic-key
29
+ XAI_API_KEY=your-xai-key
29
30
 
30
31
  # Optional settings
31
32
  DEFAULT_PROVIDER=perplexity
32
33
  PERPLEXITY_MODEL=sonar
33
34
  OPENAI_MODEL=gpt-4o
34
35
  ANTHROPIC_MODEL=claude-sonnet-4-20250514
36
+ XAI_MODEL=grok-4
35
37
  ```
36
38
 
37
39
  ---
@@ -46,15 +48,17 @@ askimo "What is the capital of France?"
46
48
 
47
49
  ### Choose a provider
48
50
 
49
- | Flag | Provider |
50
- |------|----------|
51
+ | Flag | Provider |
52
+ |------|----------------------|
51
53
  | `-p` | Perplexity (default) |
52
- | `-o` | OpenAI |
53
- | `-a` | Anthropic |
54
+ | `-o` | OpenAI |
55
+ | `-a` | Anthropic |
56
+ | `-x` | xAI (Grok) |
54
57
 
55
58
  ```bash
56
59
  askimo "explain quantum computing" -o # Use OpenAI
57
60
  askimo "write a haiku" -a # Use Anthropic
61
+ askimo "what's happening today?" -x # Use xAI Grok
58
62
  ```
59
63
 
60
64
  ### Continue a conversation
@@ -62,6 +66,7 @@ askimo "write a haiku" -a # Use Anthropic
62
66
  ```bash
63
67
  askimo "tell me more" -c 1 # Continue last conversation
64
68
  askimo "go deeper" -c 2 # Continue second-to-last
69
+ askimo "explain more" --cid abc123 # Continue by conversation ID
65
70
  ```
66
71
 
67
72
  ### JSON output
@@ -89,7 +94,9 @@ askimo -f error.log "find the bug"
89
94
  ```bash
90
95
  askimo chat # Start new chat
91
96
  askimo chat -o # Chat with OpenAI
97
+ askimo chat -x # Chat with xAI Grok
92
98
  askimo chat -c 1 # Continue last conversation
99
+ askimo chat --cid abc123 # Continue by conversation ID
93
100
  ```
94
101
 
95
102
  Type `exit` or `Ctrl+C` to quit.
@@ -99,20 +106,27 @@ Type `exit` or `Ctrl+C` to quit.
99
106
  ```bash
100
107
  askimo models # All providers
101
108
  askimo models -p # Perplexity only
109
+ askimo models -x # xAI only
110
+ ```
111
+
112
+ ### View conversation history
113
+
114
+ ```bash
115
+ askimo conversations # Opens browser with all conversations
102
116
  ```
103
117
 
104
118
  ---
105
119
 
106
120
  ## ✨ Features
107
121
 
108
- | Feature | Description |
109
- |---------|-------------|
110
- | Streaming | Real-time response output |
111
- | Piping | Pipe content via stdin |
112
- | File input | Read content from files with `-f` |
113
- | Citations | Source links with Perplexity |
114
- | History | Conversations saved to `~/.askimo/conversations/` |
115
- | Multi-provider | Switch between AI providers easily |
122
+ | Feature | Description |
123
+ |----------------|---------------------------------------------------|
124
+ | Streaming | Real-time response output |
125
+ | Piping | Pipe content via stdin |
126
+ | File input | Read content from files with `-f` |
127
+ | Citations | Source links with Perplexity |
128
+ | History | Conversations saved to `~/.askimo/conversations/` |
129
+ | Multi-provider | Switch between AI providers easily |
116
130
 
117
131
  ---
118
132
 
package/index.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  import { Command } from 'commander'
4
4
  import { startChat } from './lib/chat.mjs'
5
5
  import { ensureDirectories, loadConfig } from './lib/config.mjs'
6
- import { createConversation, loadConversation, saveConversation } from './lib/conversation.mjs'
6
+ import { createConversation, loadConversation, loadConversationById, saveConversation } from './lib/conversation.mjs'
7
+ import { showConversationsInBrowser } from './lib/conversations-ui.mjs'
7
8
  import { buildMessage, readFile, readStdin } from './lib/input.mjs'
8
9
  import { DEFAULT_MODELS, determineProvider, getProvider, listModels } from './lib/providers.mjs'
9
10
  import { generateResponse, outputJson, streamResponse } from './lib/stream.mjs'
@@ -20,8 +21,10 @@ program
20
21
  .option('-p, --perplexity', 'Use Perplexity AI (default)')
21
22
  .option('-o, --openai', 'Use OpenAI')
22
23
  .option('-a, --anthropic', 'Use Anthropic Claude')
24
+ .option('-x, --xai', 'Use xAI Grok')
23
25
  .option('-j, --json', 'Output as JSON instead of streaming')
24
26
  .option('-c, --continue <n>', 'Continue conversation N (1=last, 2=second-to-last)', Number.parseInt)
27
+ .option('--cid <id>', 'Continue conversation by ID')
25
28
  .option('-f, --file <path>', 'Read content from file')
26
29
  .action(async (question, options) => {
27
30
  try {
@@ -50,11 +53,24 @@ program
50
53
  let conversation
51
54
  let existingPath = null
52
55
 
56
+ if (options.continue && options.cid) {
57
+ console.error('Error: Cannot use both -c and --cid flags')
58
+ process.exit(1)
59
+ }
60
+
53
61
  if (options.continue) {
54
62
  const loaded = await loadConversation(options.continue)
55
63
  conversation = loaded.conversation
56
64
  existingPath = loaded.filePath
57
65
 
66
+ if (conversation.provider !== name) {
67
+ console.error(`Warning: Continuing ${conversation.provider} conversation with ${name}`)
68
+ }
69
+ } else if (options.cid) {
70
+ const loaded = await loadConversationById(options.cid)
71
+ conversation = loaded.conversation
72
+ existingPath = loaded.filePath
73
+
58
74
  if (conversation.provider !== name) {
59
75
  console.error(`Warning: Continuing ${conversation.provider} conversation with ${name}`)
60
76
  }
@@ -70,16 +86,16 @@ program
70
86
  let responseText
71
87
 
72
88
  if (options.json) {
73
- const { text, sources } = await generateResponse(model, conversation.messages)
89
+ const { text, sources, duration } = await generateResponse(model, conversation.messages)
74
90
  responseText = text
75
91
  conversation.messages.push({
76
92
  role: 'assistant',
77
93
  content: responseText
78
94
  })
79
95
  await saveConversation(conversation, existingPath)
80
- outputJson(conversation, responseText, sources)
96
+ outputJson(conversation, responseText, sources, duration)
81
97
  } else {
82
- responseText = await streamResponse(model, conversation.messages)
98
+ responseText = await streamResponse(model, conversation.messages, modelName)
83
99
  conversation.messages.push({
84
100
  role: 'assistant',
85
101
  content: responseText
@@ -98,16 +114,23 @@ program
98
114
  .option('-p, --perplexity', 'Use Perplexity AI (default)')
99
115
  .option('-o, --openai', 'Use OpenAI')
100
116
  .option('-a, --anthropic', 'Use Anthropic Claude')
117
+ .option('-x, --xai', 'Use xAI Grok')
101
118
  .option('-c, --continue <n>', 'Continue conversation N (1=last, 2=second-to-last)', Number.parseInt)
119
+ .option('--cid <id>', 'Continue conversation by ID')
102
120
  .action(async (options) => {
103
121
  try {
122
+ if (options.continue && options.cid) {
123
+ console.error('Error: Cannot use both -c and --cid flags')
124
+ process.exit(1)
125
+ }
126
+
104
127
  const config = await loadConfig()
105
128
  await ensureDirectories()
106
129
 
107
130
  const providerName = determineProvider(options, config)
108
131
  const { model, name, modelName } = getProvider(providerName, config)
109
132
 
110
- await startChat(model, name, modelName, options.continue)
133
+ await startChat(model, name, modelName, options.continue, options.cid)
111
134
  } catch (err) {
112
135
  console.error('Error:', err.message)
113
136
  process.exit(1)
@@ -120,6 +143,7 @@ program
120
143
  .option('-p, --perplexity', 'Show only Perplexity models')
121
144
  .option('-o, --openai', 'Show only OpenAI models')
122
145
  .option('-a, --anthropic', 'Show only Anthropic models')
146
+ .option('-x, --xai', 'Show only xAI models')
123
147
  .action(async (options) => {
124
148
  try {
125
149
  const config = await loadConfig()
@@ -128,8 +152,9 @@ program
128
152
  if (options.perplexity) providers.push('perplexity')
129
153
  if (options.openai) providers.push('openai')
130
154
  if (options.anthropic) providers.push('anthropic')
155
+ if (options.xai) providers.push('xai')
131
156
 
132
- const toShow = providers.length === 0 ? ['perplexity', 'openai', 'anthropic'] : providers
157
+ const toShow = providers.length === 0 ? ['perplexity', 'openai', 'anthropic', 'xai'] : providers
133
158
 
134
159
  const results = await Promise.all(
135
160
  toShow.map(async (provider) => ({
@@ -159,4 +184,16 @@ program
159
184
  }
160
185
  })
161
186
 
187
+ program
188
+ .command('conversations')
189
+ .description('View all conversations in browser')
190
+ .action(async () => {
191
+ try {
192
+ await showConversationsInBrowser()
193
+ } catch (err) {
194
+ console.error('Error:', err.message)
195
+ process.exit(1)
196
+ }
197
+ })
198
+
162
199
  program.parse()
package/lib/chat.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import input from '@inquirer/input'
2
- import { createConversation, loadConversation, saveConversation } from './conversation.mjs'
2
+ import { createConversation, loadConversation, loadConversationById, saveConversation } from './conversation.mjs'
3
3
  import { streamResponse } from './stream.mjs'
4
4
 
5
- async function startChat(model, providerName, modelName, continueN = null) {
5
+ async function startChat(model, providerName, modelName, continueN = null, conversationId = null) {
6
6
  let conversation
7
7
  let existingPath = null
8
8
 
@@ -15,6 +15,16 @@ async function startChat(model, providerName, modelName, continueN = null) {
15
15
  console.error(`Warning: Continuing ${conversation.provider} conversation with ${providerName}`)
16
16
  }
17
17
 
18
+ console.log(`Continuing conversation ${conversation.id} (${conversation.messages.length} messages)`)
19
+ } else if (conversationId) {
20
+ const loaded = await loadConversationById(conversationId)
21
+ conversation = loaded.conversation
22
+ existingPath = loaded.filePath
23
+
24
+ if (conversation.provider !== providerName) {
25
+ console.error(`Warning: Continuing ${conversation.provider} conversation with ${providerName}`)
26
+ }
27
+
18
28
  console.log(`Continuing conversation ${conversation.id} (${conversation.messages.length} messages)`)
19
29
  } else {
20
30
  conversation = createConversation(providerName, modelName)
@@ -42,7 +52,7 @@ async function startChat(model, providerName, modelName, continueN = null) {
42
52
  })
43
53
 
44
54
  console.log('')
45
- const responseText = await streamResponse(model, conversation.messages)
55
+ const responseText = await streamResponse(model, conversation.messages, modelName)
46
56
 
47
57
  conversation.messages.push({
48
58
  role: 'assistant',
@@ -52,6 +52,65 @@ async function loadConversation(n) {
52
52
  }
53
53
  }
54
54
 
55
+ async function loadConversationById(conversationId) {
56
+ await ensureDirectories()
57
+
58
+ try {
59
+ const files = await readdir(CONVERSATIONS_DIR)
60
+ const jsonFiles = files.filter((f) => f.endsWith('.json'))
61
+
62
+ const matchingFile = jsonFiles.find((f) => {
63
+ const nameWithoutExt = f.slice(0, -5)
64
+ return nameWithoutExt.endsWith(`-${conversationId}`)
65
+ })
66
+
67
+ if (!matchingFile) {
68
+ console.error(`Conversation with ID "${conversationId}" not found`)
69
+ process.exit(1)
70
+ }
71
+
72
+ const filePath = join(CONVERSATIONS_DIR, matchingFile)
73
+ const content = await readFile(filePath, 'utf8')
74
+ return {
75
+ conversation: JSON.parse(content),
76
+ filePath
77
+ }
78
+ } catch (err) {
79
+ if (err.code !== 'ENOENT') {
80
+ throw err
81
+ }
82
+ console.error('No conversations directory found')
83
+ process.exit(1)
84
+ }
85
+ }
86
+
87
+ async function getAllConversations() {
88
+ await ensureDirectories()
89
+
90
+ try {
91
+ const files = await readdir(CONVERSATIONS_DIR)
92
+ const jsonFiles = files
93
+ .filter((f) => f.endsWith('.json'))
94
+ .sort()
95
+ .reverse()
96
+
97
+ const conversations = await Promise.all(
98
+ jsonFiles.map(async (filename) => {
99
+ const filePath = join(CONVERSATIONS_DIR, filename)
100
+ const content = await readFile(filePath, 'utf8')
101
+ return JSON.parse(content)
102
+ })
103
+ )
104
+
105
+ return conversations
106
+ } catch (err) {
107
+ if (err.code !== 'ENOENT') {
108
+ throw err
109
+ }
110
+ return []
111
+ }
112
+ }
113
+
55
114
  async function saveConversation(conversation, existingPath = null) {
56
115
  await ensureDirectories()
57
116
 
@@ -68,4 +127,4 @@ async function saveConversation(conversation, existingPath = null) {
68
127
  return filePath
69
128
  }
70
129
 
71
- export { createConversation, loadConversation, saveConversation }
130
+ export { createConversation, loadConversation, loadConversationById, getAllConversations, saveConversation }
@@ -0,0 +1,572 @@
1
+ import hcat from 'hcat'
2
+ import { getAllConversations } from './conversation.mjs'
3
+
4
+ function formatDate(isoString) {
5
+ const date = new Date(isoString)
6
+ return date.toLocaleDateString('en-US', {
7
+ year: 'numeric',
8
+ month: 'short',
9
+ day: 'numeric',
10
+ hour: '2-digit',
11
+ minute: '2-digit'
12
+ })
13
+ }
14
+
15
+ function getPreview(messages, maxLength = 100) {
16
+ const firstUserMessage = messages.find((m) => m.role === 'user')
17
+ if (!firstUserMessage) return 'No messages'
18
+
19
+ const content = firstUserMessage.content
20
+ if (content.length <= maxLength) return content
21
+ return content.slice(0, maxLength) + '...'
22
+ }
23
+
24
+ function escapeHtml(text) {
25
+ return text
26
+ .replace(/&/g, '&amp;')
27
+ .replace(/</g, '&lt;')
28
+ .replace(/>/g, '&gt;')
29
+ .replace(/"/g, '&quot;')
30
+ .replace(/'/g, '&#039;')
31
+ }
32
+
33
+ const ITEMS_PER_PAGE = 20
34
+
35
+ function generateHtml(conversations) {
36
+ // Prepare conversation data for client-side rendering
37
+ const conversationsJson = JSON.stringify(
38
+ conversations.map((conv) => ({
39
+ id: conv.id,
40
+ provider: conv.provider,
41
+ model: conv.model,
42
+ createdAt: conv.createdAt,
43
+ messageCount: conv.messages.length,
44
+ preview: getPreview(conv.messages),
45
+ messages: conv.messages
46
+ }))
47
+ )
48
+
49
+ return `<!DOCTYPE html>
50
+ <html lang="en">
51
+ <head>
52
+ <meta charset="UTF-8">
53
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
54
+ <title>Askimo Conversations</title>
55
+ <style>
56
+ * {
57
+ box-sizing: border-box;
58
+ margin: 0;
59
+ padding: 0;
60
+ }
61
+
62
+ body {
63
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
64
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
65
+ min-height: 100vh;
66
+ color: #e0e0e0;
67
+ padding: 2rem;
68
+ }
69
+
70
+ .container {
71
+ max-width: 1400px;
72
+ margin: 0 auto;
73
+ }
74
+
75
+ header {
76
+ text-align: center;
77
+ margin-bottom: 2rem;
78
+ }
79
+
80
+ h1 {
81
+ font-size: 2.5rem;
82
+ font-weight: 700;
83
+ color: #fff;
84
+ margin-bottom: 0.5rem;
85
+ }
86
+
87
+ .subtitle {
88
+ color: #888;
89
+ font-size: 1rem;
90
+ }
91
+
92
+ .stats {
93
+ display: flex;
94
+ justify-content: center;
95
+ gap: 2rem;
96
+ margin-bottom: 2rem;
97
+ }
98
+
99
+ .stat {
100
+ background: rgba(255, 255, 255, 0.05);
101
+ padding: 1rem 2rem;
102
+ border-radius: 8px;
103
+ text-align: center;
104
+ }
105
+
106
+ .stat-value {
107
+ font-size: 2rem;
108
+ font-weight: 700;
109
+ color: #4fc3f7;
110
+ }
111
+
112
+ .stat-label {
113
+ font-size: 0.85rem;
114
+ color: #888;
115
+ text-transform: uppercase;
116
+ letter-spacing: 0.5px;
117
+ }
118
+
119
+ table {
120
+ width: 100%;
121
+ border-collapse: collapse;
122
+ background: rgba(255, 255, 255, 0.03);
123
+ border-radius: 12px;
124
+ overflow: hidden;
125
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
126
+ }
127
+
128
+ th {
129
+ background: rgba(79, 195, 247, 0.1);
130
+ padding: 1rem;
131
+ text-align: left;
132
+ font-weight: 600;
133
+ color: #4fc3f7;
134
+ text-transform: uppercase;
135
+ font-size: 0.75rem;
136
+ letter-spacing: 1px;
137
+ }
138
+
139
+ td {
140
+ padding: 1rem;
141
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
142
+ }
143
+
144
+ .conversation-row {
145
+ cursor: pointer;
146
+ transition: background 0.2s;
147
+ }
148
+
149
+ .conversation-row:hover {
150
+ background: rgba(255, 255, 255, 0.05);
151
+ }
152
+
153
+ .conversation-row.expanded {
154
+ background: rgba(79, 195, 247, 0.1);
155
+ }
156
+
157
+ .conversation-detail {
158
+ display: none;
159
+ }
160
+
161
+ .conversation-detail.expanded {
162
+ display: table-row;
163
+ }
164
+
165
+ .conversation-detail td {
166
+ padding: 0;
167
+ background: rgba(0, 0, 0, 0.2);
168
+ }
169
+
170
+ .detail-content {
171
+ padding: 1.5rem;
172
+ max-height: 500px;
173
+ overflow-y: auto;
174
+ }
175
+
176
+ .message {
177
+ margin-bottom: 1rem;
178
+ padding: 1rem;
179
+ border-radius: 8px;
180
+ }
181
+
182
+ .message:last-child {
183
+ margin-bottom: 0;
184
+ }
185
+
186
+ .message.user {
187
+ background: rgba(79, 195, 247, 0.1);
188
+ border-left: 3px solid #4fc3f7;
189
+ }
190
+
191
+ .message.assistant {
192
+ background: rgba(129, 199, 132, 0.1);
193
+ border-left: 3px solid #81c784;
194
+ }
195
+
196
+ .message-role {
197
+ font-size: 0.75rem;
198
+ font-weight: 600;
199
+ text-transform: uppercase;
200
+ letter-spacing: 0.5px;
201
+ margin-bottom: 0.5rem;
202
+ color: #888;
203
+ }
204
+
205
+ .message.user .message-role {
206
+ color: #4fc3f7;
207
+ }
208
+
209
+ .message.assistant .message-role {
210
+ color: #81c784;
211
+ }
212
+
213
+ .message-content {
214
+ white-space: pre-wrap;
215
+ word-break: break-word;
216
+ line-height: 1.6;
217
+ }
218
+
219
+ .id {
220
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
221
+ font-size: 0.85rem;
222
+ color: #81c784;
223
+ }
224
+
225
+ .id-text {
226
+ margin-right: 0.5rem;
227
+ }
228
+
229
+ .copy-btn {
230
+ background: none;
231
+ border: none;
232
+ color: #666;
233
+ cursor: pointer;
234
+ padding: 0.25rem;
235
+ border-radius: 4px;
236
+ transition: all 0.2s;
237
+ vertical-align: middle;
238
+ }
239
+
240
+ .copy-btn:hover {
241
+ color: #4fc3f7;
242
+ background: rgba(79, 195, 247, 0.1);
243
+ }
244
+
245
+ .copy-btn.copied {
246
+ color: #81c784;
247
+ }
248
+
249
+ .provider {
250
+ text-transform: capitalize;
251
+ font-weight: 500;
252
+ }
253
+
254
+ .model {
255
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
256
+ font-size: 0.85rem;
257
+ color: #ffb74d;
258
+ }
259
+
260
+ .date {
261
+ color: #888;
262
+ font-size: 0.9rem;
263
+ white-space: nowrap;
264
+ }
265
+
266
+ .messages {
267
+ text-align: center;
268
+ font-weight: 600;
269
+ }
270
+
271
+ .preview {
272
+ color: #aaa;
273
+ font-size: 0.9rem;
274
+ max-width: 400px;
275
+ overflow: hidden;
276
+ text-overflow: ellipsis;
277
+ white-space: nowrap;
278
+ }
279
+
280
+ .empty {
281
+ text-align: center;
282
+ padding: 4rem;
283
+ color: #666;
284
+ }
285
+
286
+ .usage {
287
+ margin-top: 2rem;
288
+ text-align: center;
289
+ color: #666;
290
+ font-size: 0.9rem;
291
+ }
292
+
293
+ code {
294
+ background: rgba(255, 255, 255, 0.1);
295
+ padding: 0.2rem 0.5rem;
296
+ border-radius: 4px;
297
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
298
+ }
299
+
300
+ /* Pagination */
301
+ .pagination {
302
+ display: flex;
303
+ justify-content: center;
304
+ align-items: center;
305
+ gap: 0.5rem;
306
+ margin-top: 1.5rem;
307
+ padding: 1rem;
308
+ }
309
+
310
+ .page-btn {
311
+ background: rgba(255, 255, 255, 0.05);
312
+ border: 1px solid rgba(255, 255, 255, 0.1);
313
+ color: #e0e0e0;
314
+ padding: 0.5rem 0.75rem;
315
+ border-radius: 6px;
316
+ cursor: pointer;
317
+ transition: all 0.2s;
318
+ display: flex;
319
+ align-items: center;
320
+ justify-content: center;
321
+ }
322
+
323
+ .page-btn:hover:not(:disabled) {
324
+ background: rgba(79, 195, 247, 0.2);
325
+ border-color: rgba(79, 195, 247, 0.3);
326
+ color: #4fc3f7;
327
+ }
328
+
329
+ .page-btn:disabled {
330
+ opacity: 0.3;
331
+ cursor: not-allowed;
332
+ }
333
+
334
+ .page-info {
335
+ color: #888;
336
+ font-size: 0.9rem;
337
+ padding: 0 1rem;
338
+ }
339
+
340
+ /* Scrollbar styling */
341
+ .detail-content::-webkit-scrollbar {
342
+ width: 8px;
343
+ }
344
+
345
+ .detail-content::-webkit-scrollbar-track {
346
+ background: rgba(255, 255, 255, 0.05);
347
+ border-radius: 4px;
348
+ }
349
+
350
+ .detail-content::-webkit-scrollbar-thumb {
351
+ background: rgba(255, 255, 255, 0.2);
352
+ border-radius: 4px;
353
+ }
354
+
355
+ .detail-content::-webkit-scrollbar-thumb:hover {
356
+ background: rgba(255, 255, 255, 0.3);
357
+ }
358
+ </style>
359
+ </head>
360
+ <body>
361
+ <div class="container">
362
+ <header>
363
+ <h1>Askimo Conversations</h1>
364
+ <p class="subtitle">Your AI conversation history</p>
365
+ </header>
366
+
367
+ <div class="stats">
368
+ <div class="stat">
369
+ <div class="stat-value">${conversations.length}</div>
370
+ <div class="stat-label">Conversations</div>
371
+ </div>
372
+ <div class="stat">
373
+ <div class="stat-value">${conversations.reduce((sum, c) => sum + c.messages.length, 0)}</div>
374
+ <div class="stat-label">Total Messages</div>
375
+ </div>
376
+ </div>
377
+
378
+ <div id="conversations-container">
379
+ <!-- Rendered by JavaScript -->
380
+ </div>
381
+
382
+ <div class="usage">
383
+ <p>Continue a conversation: <code>askimo --cid &lt;ID&gt; "your follow-up"</code></p>
384
+ </div>
385
+ </div>
386
+
387
+ <script>
388
+ const conversations = ${conversationsJson};
389
+ const ITEMS_PER_PAGE = ${ITEMS_PER_PAGE};
390
+ let currentPage = 1;
391
+ const totalPages = Math.ceil(conversations.length / ITEMS_PER_PAGE);
392
+
393
+ function escapeHtml(text) {
394
+ const div = document.createElement('div');
395
+ div.textContent = text;
396
+ return div.innerHTML;
397
+ }
398
+
399
+ function formatDate(isoString) {
400
+ const date = new Date(isoString);
401
+ return date.toLocaleDateString('en-US', {
402
+ year: 'numeric',
403
+ month: 'short',
404
+ day: 'numeric',
405
+ hour: '2-digit',
406
+ minute: '2-digit'
407
+ });
408
+ }
409
+
410
+ function renderMessages(messages) {
411
+ return messages.map(msg => \`
412
+ <div class="message \${msg.role}">
413
+ <div class="message-role">\${msg.role === 'user' ? 'You' : 'Assistant'}</div>
414
+ <div class="message-content">\${escapeHtml(msg.content)}</div>
415
+ </div>
416
+ \`).join('');
417
+ }
418
+
419
+ function renderPage(page) {
420
+ currentPage = page;
421
+ const container = document.getElementById('conversations-container');
422
+ const start = (page - 1) * ITEMS_PER_PAGE;
423
+ const end = Math.min(start + ITEMS_PER_PAGE, conversations.length);
424
+ const pageConversations = conversations.slice(start, end);
425
+
426
+ if (conversations.length === 0) {
427
+ container.innerHTML = \`
428
+ <div class="empty">
429
+ <p>No conversations found.</p>
430
+ <p>Start a conversation with: <code>askimo "your question"</code></p>
431
+ </div>
432
+ \`;
433
+ return;
434
+ }
435
+
436
+ const rows = pageConversations.map((conv, idx) => {
437
+ const globalIndex = start + idx;
438
+ return \`
439
+ <tr class="conversation-row" data-index="\${globalIndex}">
440
+ <td class="id">
441
+ <span class="id-text">\${escapeHtml(conv.id)}</span>
442
+ <button class="copy-btn" data-id="\${escapeHtml(conv.id)}" title="Copy ID">
443
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
444
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
445
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
446
+ </svg>
447
+ </button>
448
+ </td>
449
+ <td class="provider">\${escapeHtml(conv.provider)}</td>
450
+ <td class="model">\${escapeHtml(conv.model)}</td>
451
+ <td class="date">\${formatDate(conv.createdAt)}</td>
452
+ <td class="messages">\${conv.messageCount}</td>
453
+ <td class="preview">\${escapeHtml(conv.preview)}</td>
454
+ </tr>
455
+ <tr class="conversation-detail" data-index="\${globalIndex}">
456
+ <td colspan="6">
457
+ <div class="detail-content">
458
+ \${renderMessages(conv.messages)}
459
+ </div>
460
+ </td>
461
+ </tr>
462
+ \`;
463
+ }).join('');
464
+
465
+ const pagination = totalPages > 1 ? \`
466
+ <div class="pagination">
467
+ <button class="page-btn" onclick="renderPage(1)" \${currentPage === 1 ? 'disabled' : ''}>
468
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
469
+ <polyline points="11 17 6 12 11 7"></polyline>
470
+ <polyline points="18 17 13 12 18 7"></polyline>
471
+ </svg>
472
+ </button>
473
+ <button class="page-btn" onclick="renderPage(\${currentPage - 1})" \${currentPage === 1 ? 'disabled' : ''}>
474
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
475
+ <polyline points="15 18 9 12 15 6"></polyline>
476
+ </svg>
477
+ </button>
478
+ <span class="page-info">Page \${currentPage} of \${totalPages}</span>
479
+ <button class="page-btn" onclick="renderPage(\${currentPage + 1})" \${currentPage === totalPages ? 'disabled' : ''}>
480
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
481
+ <polyline points="9 18 15 12 9 6"></polyline>
482
+ </svg>
483
+ </button>
484
+ <button class="page-btn" onclick="renderPage(\${totalPages})" \${currentPage === totalPages ? 'disabled' : ''}>
485
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
486
+ <polyline points="13 17 18 12 13 7"></polyline>
487
+ <polyline points="6 17 11 12 6 7"></polyline>
488
+ </svg>
489
+ </button>
490
+ </div>
491
+ \` : '';
492
+
493
+ container.innerHTML = \`
494
+ <table>
495
+ <thead>
496
+ <tr>
497
+ <th>ID</th>
498
+ <th>Provider</th>
499
+ <th>Model</th>
500
+ <th>Date</th>
501
+ <th>Messages</th>
502
+ <th>Preview</th>
503
+ </tr>
504
+ </thead>
505
+ <tbody>
506
+ \${rows}
507
+ </tbody>
508
+ </table>
509
+ \${pagination}
510
+ \`;
511
+
512
+ attachEventListeners();
513
+ }
514
+
515
+ function attachEventListeners() {
516
+ // Accordion behavior
517
+ document.querySelectorAll('.conversation-row').forEach(row => {
518
+ row.addEventListener('click', (e) => {
519
+ if (e.target.closest('.copy-btn')) return;
520
+
521
+ const index = row.dataset.index;
522
+ const detailRow = document.querySelector('.conversation-detail[data-index="' + index + '"]');
523
+ const isExpanded = row.classList.contains('expanded');
524
+
525
+ document.querySelectorAll('.conversation-row.expanded').forEach(r => r.classList.remove('expanded'));
526
+ document.querySelectorAll('.conversation-detail.expanded').forEach(d => d.classList.remove('expanded'));
527
+
528
+ if (!isExpanded) {
529
+ row.classList.add('expanded');
530
+ detailRow.classList.add('expanded');
531
+ }
532
+ });
533
+ });
534
+
535
+ // Copy button functionality
536
+ document.querySelectorAll('.copy-btn').forEach(btn => {
537
+ btn.addEventListener('click', async (e) => {
538
+ e.stopPropagation();
539
+ const id = btn.dataset.id;
540
+
541
+ try {
542
+ await navigator.clipboard.writeText(id);
543
+ btn.classList.add('copied');
544
+ btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>';
545
+
546
+ setTimeout(() => {
547
+ btn.classList.remove('copied');
548
+ btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
549
+ }, 2000);
550
+ } catch (err) {
551
+ console.error('Failed to copy:', err);
552
+ }
553
+ });
554
+ });
555
+ }
556
+
557
+ // Initial render
558
+ renderPage(1);
559
+ </script>
560
+ </body>
561
+ </html>`
562
+ }
563
+
564
+ async function showConversationsInBrowser() {
565
+ const conversations = await getAllConversations()
566
+ const html = generateHtml(conversations)
567
+
568
+ console.log('Opening conversations in browser...')
569
+ hcat(html, { port: 0 })
570
+ }
571
+
572
+ export { showConversationsInBrowser, generateHtml }
package/lib/providers.mjs CHANGED
@@ -1,11 +1,13 @@
1
1
  import { createAnthropic } from '@ai-sdk/anthropic'
2
2
  import { createOpenAI } from '@ai-sdk/openai'
3
3
  import { createPerplexity } from '@ai-sdk/perplexity'
4
+ import { createXai } from '@ai-sdk/xai'
4
5
 
5
6
  const DEFAULT_MODELS = {
6
7
  perplexity: 'sonar',
7
8
  openai: 'gpt-4o',
8
- anthropic: 'claude-sonnet-4-20250514'
9
+ anthropic: 'claude-sonnet-4-20250514',
10
+ xai: 'grok-4'
9
11
  }
10
12
 
11
13
  // Perplexity doesn't have a models list API, so we hardcode these
@@ -17,6 +19,20 @@ const PERPLEXITY_MODELS = [
17
19
  { id: 'sonar-deep-research', description: 'Deep research sessions' }
18
20
  ]
19
21
 
22
+ // xAI doesn't have a public models list API, so we hardcode these
23
+ const XAI_MODELS = [
24
+ { id: 'grok-4-1-fast-reasoning', description: 'Grok 4.1 fast with reasoning' },
25
+ { id: 'grok-4-1-fast-non-reasoning', description: 'Grok 4.1 fast without reasoning' },
26
+ { id: 'grok-code-fast-1', description: 'Grok optimized for code' },
27
+ { id: 'grok-4-fast-reasoning', description: 'Grok 4 fast with reasoning' },
28
+ { id: 'grok-4-fast-non-reasoning', description: 'Grok 4 fast without reasoning' },
29
+ { id: 'grok-4-0709', description: 'Grok 4 flagship model' },
30
+ { id: 'grok-3-mini', description: 'Lightweight Grok 3 model' },
31
+ { id: 'grok-3', description: 'Grok 3 base model' },
32
+ { id: 'grok-2-vision-1212', description: 'Grok 2 with vision capabilities' },
33
+ { id: 'grok-2-image-1212', description: 'Image generation model' }
34
+ ]
35
+
20
36
  async function fetchOpenAiModels(apiKey) {
21
37
  const response = await fetch('https://api.openai.com/v1/models', {
22
38
  // biome-ignore lint/style/useNamingConvention: headers use standard capitalization
@@ -71,6 +87,9 @@ async function listModels(provider, config) {
71
87
  return fetchAnthropicModels(apiKey)
72
88
  }
73
89
 
90
+ case 'xai':
91
+ return XAI_MODELS
92
+
74
93
  default:
75
94
  throw new Error(`Unknown provider: ${provider}`)
76
95
  }
@@ -117,6 +136,19 @@ function getProvider(providerName, config) {
117
136
  modelName
118
137
  }
119
138
  }
139
+ case 'xai': {
140
+ const apiKey = config.XAI_API_KEY
141
+ if (!apiKey) {
142
+ throw new Error('XAI_API_KEY not found in config')
143
+ }
144
+ const modelName = config.XAI_MODEL || DEFAULT_MODELS.xai
145
+ const xai = createXai({ apiKey })
146
+ return {
147
+ model: xai(modelName),
148
+ name: 'xai',
149
+ modelName
150
+ }
151
+ }
120
152
  default:
121
153
  throw new Error(`Unknown provider: ${providerName}`)
122
154
  }
@@ -126,9 +158,10 @@ function determineProvider(options, config = {}) {
126
158
  if (options.openai) return 'openai'
127
159
  if (options.anthropic) return 'anthropic'
128
160
  if (options.perplexity) return 'perplexity'
161
+ if (options.xai) return 'xai'
129
162
 
130
163
  const defaultProvider = config.DEFAULT_PROVIDER?.toLowerCase()
131
- if (defaultProvider && ['perplexity', 'openai', 'anthropic'].includes(defaultProvider)) {
164
+ if (defaultProvider && ['perplexity', 'openai', 'anthropic', 'xai'].includes(defaultProvider)) {
132
165
  return defaultProvider
133
166
  }
134
167
 
package/lib/stream.mjs CHANGED
@@ -1,6 +1,13 @@
1
1
  import { generateText, streamText } from 'ai'
2
2
 
3
- async function streamResponse(model, messages) {
3
+ function formatDuration(ms) {
4
+ if (ms < 1000) return `${ms}ms`
5
+ return `${(ms / 1000).toFixed(1)}s`
6
+ }
7
+
8
+ async function streamResponse(model, messages, modelName) {
9
+ const startTime = Date.now()
10
+
4
11
  const result = streamText({
5
12
  model,
6
13
  messages
@@ -29,19 +36,26 @@ async function streamResponse(model, messages) {
29
36
  })
30
37
  }
31
38
 
39
+ // Display status line
40
+ const duration = Date.now() - startTime
41
+ process.stdout.write(`\n\x1b[2m${modelName} · ${formatDuration(duration)}\x1b[0m\n`)
42
+
32
43
  return fullText
33
44
  }
34
45
 
35
46
  async function generateResponse(model, messages) {
47
+ const startTime = Date.now()
48
+
36
49
  const { text, sources } = await generateText({
37
50
  model,
38
51
  messages
39
52
  })
40
53
 
41
- return { text, sources }
54
+ const duration = Date.now() - startTime
55
+ return { text, sources, duration }
42
56
  }
43
57
 
44
- function buildJsonOutput(conversation, response, sources) {
58
+ function buildJsonOutput(conversation, response, sources, duration) {
45
59
  const lastUserMessage = conversation.messages.findLast((m) => m.role === 'user')
46
60
  const output = {
47
61
  provider: conversation.provider,
@@ -49,7 +63,8 @@ function buildJsonOutput(conversation, response, sources) {
49
63
  question: lastUserMessage?.content || '',
50
64
  response,
51
65
  conversationId: conversation.id,
52
- messageCount: conversation.messages.length + 1
66
+ messageCount: conversation.messages.length + 1,
67
+ durationMs: duration
53
68
  }
54
69
 
55
70
  if (sources?.length > 0) {
@@ -59,8 +74,8 @@ function buildJsonOutput(conversation, response, sources) {
59
74
  return output
60
75
  }
61
76
 
62
- function outputJson(conversation, response, sources) {
63
- const output = buildJsonOutput(conversation, response, sources)
77
+ function outputJson(conversation, response, sources, duration) {
78
+ const output = buildJsonOutput(conversation, response, sources, duration)
64
79
  console.log(JSON.stringify(output, null, 2))
65
80
  }
66
81
 
package/package.json CHANGED
@@ -1,9 +1,21 @@
1
1
  {
2
2
  "name": "askimo",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "A CLI tool for communicating with AI providers (Perplexity, OpenAI, Anthropic)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Amit Tal",
7
+ "keywords": [
8
+ "cli",
9
+ "ai",
10
+ "llm",
11
+ "perplexity",
12
+ "openai",
13
+ "anthropic",
14
+ "claude",
15
+ "gpt",
16
+ "chatbot",
17
+ "terminal"
18
+ ],
7
19
  "type": "module",
8
20
  "bin": {
9
21
  "askimo": "./index.mjs"
@@ -27,8 +39,10 @@
27
39
  "@ai-sdk/anthropic": "^2.0.53",
28
40
  "@ai-sdk/openai": "^2.0.76",
29
41
  "@ai-sdk/perplexity": "^2.0.21",
42
+ "@ai-sdk/xai": "^2.0.40",
30
43
  "@inquirer/input": "^5.0.2",
31
44
  "ai": "^5.0.106",
32
- "commander": "^14.0.2"
45
+ "commander": "^14.0.2",
46
+ "hcat": "^2.2.1"
33
47
  }
34
48
  }