askimo 1.6.0 → 1.7.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 +20 -8
- package/index.mjs +42 -5
- package/lib/config.mjs +1 -1
- package/lib/conversation.mjs +1 -1
- package/lib/conversationsUi.mjs +1 -1
- package/lib/image.mjs +36 -0
- package/lib/input.mjs +1 -1
- package/lib/providers.mjs +17 -1
- package/lib/stream.mjs +48 -4
- package/package.json +9 -9
- package/test/image.mjs +63 -0
- package/test/stream.mjs +19 -0
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ OPENAI_MODEL=gpt-4o
|
|
|
36
36
|
ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
|
37
37
|
XAI_MODEL=grok-4
|
|
38
38
|
GEMINI_MODEL=gemini-3-pro-preview
|
|
39
|
+
IMAGE_MODEL=gemini-3-pro-image-preview
|
|
39
40
|
```
|
|
40
41
|
|
|
41
42
|
---
|
|
@@ -65,6 +66,16 @@ askimo "what's happening today?" -x # Use xAI Grok
|
|
|
65
66
|
askimo "summarize this topic" -g # Use Google Gemini
|
|
66
67
|
```
|
|
67
68
|
|
|
69
|
+
### Generate an image
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
askimo --image "a cat wearing a top hat" # Save to current directory
|
|
73
|
+
askimo --image -d ./images "a sunset over ocean" # Save to ./images/
|
|
74
|
+
askimo --image --json "a dog on a skateboard" # JSON output with image paths
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Uses Google Gemini's image generation model. Requires `GOOGLE_GENERATIVE_AI_API_KEY` in your config.
|
|
78
|
+
|
|
68
79
|
### Continue a conversation
|
|
69
80
|
|
|
70
81
|
```bash
|
|
@@ -123,14 +134,15 @@ askimo conversations # Opens browser with all conversations
|
|
|
123
134
|
|
|
124
135
|
## ✨ Features
|
|
125
136
|
|
|
126
|
-
| Feature
|
|
127
|
-
|
|
128
|
-
| Streaming
|
|
129
|
-
|
|
|
130
|
-
|
|
|
131
|
-
|
|
|
132
|
-
|
|
|
133
|
-
|
|
|
137
|
+
| Feature | Description |
|
|
138
|
+
|------------------|---------------------------------------------------|
|
|
139
|
+
| Streaming | Real-time response output |
|
|
140
|
+
| Image generation | Generate images with `-i` (uses Gemini) |
|
|
141
|
+
| Piping | Pipe content via stdin |
|
|
142
|
+
| File input | Read content from files with `-f` |
|
|
143
|
+
| Citations | Source links with Perplexity |
|
|
144
|
+
| History | Conversations saved to `~/.askimo/conversations/` |
|
|
145
|
+
| Multi-provider | Switch between AI providers easily |
|
|
134
146
|
|
|
135
147
|
---
|
|
136
148
|
|
package/index.mjs
CHANGED
|
@@ -5,9 +5,17 @@ import { startChat } from './lib/chat.mjs'
|
|
|
5
5
|
import { ensureDirectories, loadConfig } from './lib/config.mjs'
|
|
6
6
|
import { createConversation, loadConversation, loadConversationById, saveConversation } from './lib/conversation.mjs'
|
|
7
7
|
import { showConversationsInBrowser } from './lib/conversationsUi.mjs'
|
|
8
|
+
import { saveImages } from './lib/image.mjs'
|
|
8
9
|
import { buildMessage, readFile, readStdin } from './lib/input.mjs'
|
|
9
|
-
import { DEFAULT_MODELS, determineProvider, getProvider, listModels } from './lib/providers.mjs'
|
|
10
|
-
import {
|
|
10
|
+
import { DEFAULT_MODELS, determineProvider, getImageProvider, getProvider, listModels } from './lib/providers.mjs'
|
|
11
|
+
import {
|
|
12
|
+
generateImageResponse,
|
|
13
|
+
generateResponse,
|
|
14
|
+
outputJson,
|
|
15
|
+
printImageResponse,
|
|
16
|
+
printResponse,
|
|
17
|
+
streamResponse
|
|
18
|
+
} from './lib/stream.mjs'
|
|
11
19
|
import pkg from './package.json' with { type: 'json' }
|
|
12
20
|
|
|
13
21
|
const program = new Command()
|
|
@@ -28,6 +36,8 @@ program
|
|
|
28
36
|
.option('-c, --continue <n>', 'Continue conversation N (1=last, 2=second-to-last)', Number.parseInt)
|
|
29
37
|
.option('--cid <id>', 'Continue conversation by ID')
|
|
30
38
|
.option('-f, --file <path>', 'Read content from file')
|
|
39
|
+
.option('-i, --image', 'Generate an image (uses Gemini)')
|
|
40
|
+
.option('-d, --output-dir <path>', 'Directory to save generated images (default: current directory)')
|
|
31
41
|
.action(async (question, options) => {
|
|
32
42
|
try {
|
|
33
43
|
const stdinContent = await readStdin()
|
|
@@ -49,8 +59,19 @@ program
|
|
|
49
59
|
const config = await loadConfig()
|
|
50
60
|
await ensureDirectories()
|
|
51
61
|
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
let model
|
|
63
|
+
let name
|
|
64
|
+
let modelName
|
|
65
|
+
|
|
66
|
+
if (options.image) {
|
|
67
|
+
if (options.perplexity || options.openai || options.anthropic || options.xai) {
|
|
68
|
+
console.error('Note: --image uses Gemini regardless of other provider flags')
|
|
69
|
+
}
|
|
70
|
+
;({ model, name, modelName } = getImageProvider(config))
|
|
71
|
+
} else {
|
|
72
|
+
const providerName = determineProvider(options, config)
|
|
73
|
+
;({ model, name, modelName } = getProvider(providerName, config))
|
|
74
|
+
}
|
|
54
75
|
|
|
55
76
|
let conversation
|
|
56
77
|
let existingPath = null
|
|
@@ -87,7 +108,23 @@ program
|
|
|
87
108
|
|
|
88
109
|
let responseText
|
|
89
110
|
|
|
90
|
-
if (options.
|
|
111
|
+
if (options.image) {
|
|
112
|
+
const { text, files, duration } = await generateImageResponse(model, conversation.messages)
|
|
113
|
+
const imagePaths = await saveImages(files, options.outputDir)
|
|
114
|
+
responseText = text || ''
|
|
115
|
+
conversation.messages.push({
|
|
116
|
+
role: 'assistant',
|
|
117
|
+
content: responseText,
|
|
118
|
+
images: imagePaths
|
|
119
|
+
})
|
|
120
|
+
await saveConversation(conversation, existingPath)
|
|
121
|
+
|
|
122
|
+
if (options.json) {
|
|
123
|
+
outputJson(conversation, responseText, undefined, duration, imagePaths)
|
|
124
|
+
} else {
|
|
125
|
+
printImageResponse(responseText, imagePaths, duration, modelName)
|
|
126
|
+
}
|
|
127
|
+
} else if (options.json) {
|
|
91
128
|
const { text, sources, duration } = await generateResponse(model, conversation.messages)
|
|
92
129
|
responseText = text
|
|
93
130
|
conversation.messages.push({
|
package/lib/config.mjs
CHANGED
|
@@ -51,4 +51,4 @@ async function ensureDirectories() {
|
|
|
51
51
|
await mkdir(CONVERSATIONS_DIR, { recursive: true })
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export {
|
|
54
|
+
export { ASKIMO_DIR, CONVERSATIONS_DIR, ensureDirectories, loadConfig, parseConfig }
|
package/lib/conversation.mjs
CHANGED
|
@@ -127,4 +127,4 @@ async function saveConversation(conversation, existingPath = null) {
|
|
|
127
127
|
return filePath
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
export { createConversation, loadConversation, loadConversationById,
|
|
130
|
+
export { createConversation, getAllConversations, loadConversation, loadConversationById, saveConversation }
|
package/lib/conversationsUi.mjs
CHANGED
package/lib/image.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const MEDIA_TYPE_EXTENSIONS = {
|
|
5
|
+
'image/png': '.png',
|
|
6
|
+
'image/jpeg': '.jpg',
|
|
7
|
+
'image/webp': '.webp'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function buildImagePath(outputDir, timestamp, index, mediaType) {
|
|
11
|
+
const ext = MEDIA_TYPE_EXTENSIONS[mediaType] || '.png'
|
|
12
|
+
const safestamp = timestamp.replace(/:/g, '-')
|
|
13
|
+
const filename = `askimo-${safestamp}-${index}${ext}`
|
|
14
|
+
return path.join(outputDir, filename)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function saveImages(files, outputDir) {
|
|
18
|
+
if (!files?.length) return []
|
|
19
|
+
|
|
20
|
+
const dir = outputDir || process.cwd()
|
|
21
|
+
await fs.mkdir(dir, { recursive: true })
|
|
22
|
+
|
|
23
|
+
const timestamp = new Date().toISOString()
|
|
24
|
+
const savedPaths = []
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < files.length; i++) {
|
|
27
|
+
const file = files[i]
|
|
28
|
+
const filePath = buildImagePath(dir, timestamp, i + 1, file.mediaType)
|
|
29
|
+
await fs.writeFile(filePath, file.data)
|
|
30
|
+
savedPaths.push(filePath)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return savedPaths
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { buildImagePath, saveImages }
|
package/lib/input.mjs
CHANGED
package/lib/providers.mjs
CHANGED
|
@@ -12,6 +12,8 @@ const DEFAULT_MODELS = {
|
|
|
12
12
|
gemini: 'gemini-3-pro-preview'
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const DEFAULT_IMAGE_MODEL = 'gemini-3-pro-image-preview'
|
|
16
|
+
|
|
15
17
|
// Provider name mapping (askimo name → models.dev name)
|
|
16
18
|
const PROVIDER_MAP = {
|
|
17
19
|
gemini: 'google'
|
|
@@ -112,6 +114,20 @@ function getProvider(providerName, config) {
|
|
|
112
114
|
}
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
function getImageProvider(config) {
|
|
118
|
+
const apiKey = config.GOOGLE_GENERATIVE_AI_API_KEY
|
|
119
|
+
if (!apiKey) {
|
|
120
|
+
throw new Error('GOOGLE_GENERATIVE_AI_API_KEY not found in config')
|
|
121
|
+
}
|
|
122
|
+
const modelName = config.IMAGE_MODEL || DEFAULT_IMAGE_MODEL
|
|
123
|
+
const google = createGoogleGenerativeAI({ apiKey })
|
|
124
|
+
return {
|
|
125
|
+
model: google(modelName),
|
|
126
|
+
name: 'gemini',
|
|
127
|
+
modelName
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
115
131
|
function determineProvider(options, config = {}) {
|
|
116
132
|
if (options.openai) return 'openai'
|
|
117
133
|
if (options.anthropic) return 'anthropic'
|
|
@@ -127,4 +143,4 @@ function determineProvider(options, config = {}) {
|
|
|
127
143
|
return 'perplexity'
|
|
128
144
|
}
|
|
129
145
|
|
|
130
|
-
export {
|
|
146
|
+
export { DEFAULT_MODELS, determineProvider, getImageProvider, getProvider, listModels }
|
package/lib/stream.mjs
CHANGED
|
@@ -55,6 +55,19 @@ async function generateResponse(model, messages) {
|
|
|
55
55
|
return { text, sources, duration }
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
async function generateImageResponse(model, messages) {
|
|
59
|
+
const startTime = Date.now()
|
|
60
|
+
|
|
61
|
+
const { text, files } = await generateText({
|
|
62
|
+
model,
|
|
63
|
+
messages
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const duration = Date.now() - startTime
|
|
67
|
+
const imageFiles = files?.filter((f) => f.mediaType?.startsWith('image/'))
|
|
68
|
+
return { text, files: imageFiles, duration }
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
function printResponse(text, sources, duration, modelName) {
|
|
59
72
|
process.stdout.write(text)
|
|
60
73
|
process.stdout.write('\n')
|
|
@@ -74,7 +87,7 @@ function printResponse(text, sources, duration, modelName) {
|
|
|
74
87
|
process.stdout.write(`\n\x1b[2m${modelName} · ${formatDuration(duration)}\x1b[0m\n`)
|
|
75
88
|
}
|
|
76
89
|
|
|
77
|
-
function buildJsonOutput(conversation, response, sources, duration) {
|
|
90
|
+
function buildJsonOutput(conversation, response, sources, duration, images) {
|
|
78
91
|
const lastUserMessage = conversation.messages.findLast((m) => m.role === 'user')
|
|
79
92
|
const output = {
|
|
80
93
|
provider: conversation.provider,
|
|
@@ -90,12 +103,43 @@ function buildJsonOutput(conversation, response, sources, duration) {
|
|
|
90
103
|
output.sources = sources
|
|
91
104
|
}
|
|
92
105
|
|
|
106
|
+
if (images?.length > 0) {
|
|
107
|
+
output.images = images
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
return output
|
|
94
111
|
}
|
|
95
112
|
|
|
96
|
-
function
|
|
97
|
-
|
|
113
|
+
function printImageResponse(text, imagePaths, duration, modelName) {
|
|
114
|
+
if (text) {
|
|
115
|
+
process.stdout.write(text)
|
|
116
|
+
process.stdout.write('\n')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (imagePaths?.length > 0) {
|
|
120
|
+
for (const imgPath of imagePaths) {
|
|
121
|
+
process.stdout.write(`\x1b[2m[image]\x1b[0m ${imgPath}\n`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!text && !imagePaths?.length) {
|
|
126
|
+
process.stdout.write('No image or text was generated.\n')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
process.stdout.write(`\n\x1b[2m${modelName} · ${formatDuration(duration)}\x1b[0m\n`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function outputJson(conversation, response, sources, duration, images) {
|
|
133
|
+
const output = buildJsonOutput(conversation, response, sources, duration, images)
|
|
98
134
|
console.log(JSON.stringify(output, null, 2))
|
|
99
135
|
}
|
|
100
136
|
|
|
101
|
-
export {
|
|
137
|
+
export {
|
|
138
|
+
buildJsonOutput,
|
|
139
|
+
generateImageResponse,
|
|
140
|
+
generateResponse,
|
|
141
|
+
outputJson,
|
|
142
|
+
printImageResponse,
|
|
143
|
+
printResponse,
|
|
144
|
+
streamResponse
|
|
145
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "askimo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|
|
@@ -28,17 +28,17 @@
|
|
|
28
28
|
"repository": "https://github.com/amitosdev/askimo.git",
|
|
29
29
|
"homepage": "https://github.com/amitosdev/askimo#readme",
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@biomejs/biome": "^2.4.
|
|
31
|
+
"@biomejs/biome": "^2.4.8",
|
|
32
32
|
"ava": "^6.4.1"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ai-sdk/anthropic": "^3.0.
|
|
36
|
-
"@ai-sdk/google": "^3.0.
|
|
37
|
-
"@ai-sdk/openai": "^3.0.
|
|
38
|
-
"@ai-sdk/perplexity": "^3.0.
|
|
39
|
-
"@ai-sdk/xai": "^3.0.
|
|
40
|
-
"@inquirer/input": "^5.0.
|
|
41
|
-
"ai": "^6.0.
|
|
35
|
+
"@ai-sdk/anthropic": "^3.0.64",
|
|
36
|
+
"@ai-sdk/google": "^3.0.53",
|
|
37
|
+
"@ai-sdk/openai": "^3.0.48",
|
|
38
|
+
"@ai-sdk/perplexity": "^3.0.26",
|
|
39
|
+
"@ai-sdk/xai": "^3.0.74",
|
|
40
|
+
"@inquirer/input": "^5.0.10",
|
|
41
|
+
"ai": "^6.0.138",
|
|
42
42
|
"commander": "^14.0.3",
|
|
43
43
|
"hcat": "^2.2.1"
|
|
44
44
|
},
|
package/test/image.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import test from 'ava'
|
|
4
|
+
import { buildImagePath, saveImages } from '../lib/image.mjs'
|
|
5
|
+
|
|
6
|
+
test('saveImages returns empty array when files is undefined', async (t) => {
|
|
7
|
+
const result = await saveImages(undefined)
|
|
8
|
+
t.deepEqual(result, [])
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('saveImages returns empty array when files is empty', async (t) => {
|
|
12
|
+
const result = await saveImages([])
|
|
13
|
+
t.deepEqual(result, [])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('saveImages writes files and returns paths', async (t) => {
|
|
17
|
+
const absDir = path.join(process.cwd(), `test-saveImages-${Date.now()}`)
|
|
18
|
+
|
|
19
|
+
const files = [
|
|
20
|
+
{ mediaType: 'image/png', data: Buffer.from('fake-png') },
|
|
21
|
+
{ mediaType: 'image/jpeg', data: Buffer.from('fake-jpg') }
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const paths = await saveImages(files, absDir)
|
|
25
|
+
|
|
26
|
+
t.is(paths.length, 2)
|
|
27
|
+
t.true(paths[0].endsWith('.png'))
|
|
28
|
+
t.true(paths[1].endsWith('.jpg'))
|
|
29
|
+
|
|
30
|
+
const content0 = await fs.readFile(paths[0])
|
|
31
|
+
t.deepEqual(content0, Buffer.from('fake-png'))
|
|
32
|
+
|
|
33
|
+
const content1 = await fs.readFile(paths[1])
|
|
34
|
+
t.deepEqual(content1, Buffer.from('fake-jpg'))
|
|
35
|
+
|
|
36
|
+
// cleanup
|
|
37
|
+
await fs.rm(absDir, { recursive: true })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('buildImagePath uses correct extension for png', (t) => {
|
|
41
|
+
const result = buildImagePath('/tmp', '2026-01-01T00:00:00.000Z', 1, 'image/png')
|
|
42
|
+
t.true(result.endsWith('-1.png'))
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('buildImagePath uses correct extension for jpeg', (t) => {
|
|
46
|
+
const result = buildImagePath('/tmp', '2026-01-01T00:00:00.000Z', 1, 'image/jpeg')
|
|
47
|
+
t.true(result.endsWith('-1.jpg'))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('buildImagePath uses correct extension for webp', (t) => {
|
|
51
|
+
const result = buildImagePath('/tmp', '2026-01-01T00:00:00.000Z', 1, 'image/webp')
|
|
52
|
+
t.true(result.endsWith('-1.webp'))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('buildImagePath defaults to .png for unknown media type', (t) => {
|
|
56
|
+
const result = buildImagePath('/tmp', '2026-01-01T00:00:00.000Z', 1, 'image/bmp')
|
|
57
|
+
t.true(result.endsWith('-1.png'))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('buildImagePath replaces colons in timestamp', (t) => {
|
|
61
|
+
const result = buildImagePath('/tmp', '2026-01-01T12:30:45.000Z', 1, 'image/png')
|
|
62
|
+
t.false(result.includes(':'))
|
|
63
|
+
})
|
package/test/stream.mjs
CHANGED
|
@@ -101,3 +101,22 @@ test('buildJsonOutput excludes sources when null', (t) => {
|
|
|
101
101
|
const output = buildJsonOutput(conversation, 'response', null)
|
|
102
102
|
t.false('sources' in output)
|
|
103
103
|
})
|
|
104
|
+
|
|
105
|
+
test('buildJsonOutput includes images when provided', (t) => {
|
|
106
|
+
const conversation = createMockConversation()
|
|
107
|
+
const images = ['/tmp/askimo-1.png', '/tmp/askimo-2.png']
|
|
108
|
+
const output = buildJsonOutput(conversation, 'response', undefined, undefined, images)
|
|
109
|
+
t.deepEqual(output.images, images)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('buildJsonOutput excludes images when empty array', (t) => {
|
|
113
|
+
const conversation = createMockConversation()
|
|
114
|
+
const output = buildJsonOutput(conversation, 'response', undefined, undefined, [])
|
|
115
|
+
t.false('images' in output)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('buildJsonOutput excludes images when undefined', (t) => {
|
|
119
|
+
const conversation = createMockConversation()
|
|
120
|
+
const output = buildJsonOutput(conversation, 'response', undefined, undefined, undefined)
|
|
121
|
+
t.false('images' in output)
|
|
122
|
+
})
|