@fettstorch/clai 0.1.6 → 0.1.7
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/.vscode/settings.json +10 -0
- package/README.md +1 -0
- package/biome.json +33 -0
- package/dist/cli.js +5875 -5611
- package/dist/index.js +1570 -1365
- package/package.json +36 -36
- package/src/cli.ts +148 -140
- package/src/index.ts +28 -25
- package/src/openai.ts +29 -25
- package/src/scraper.ts +246 -40
- package/src/summarizer.ts +44 -41
- package/tsconfig.json +14 -14
package/package.json
CHANGED
@@ -1,38 +1,38 @@
|
|
1
1
|
{
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
2
|
+
"name": "@fettstorch/clai",
|
3
|
+
"version": "0.1.7",
|
4
|
+
"main": "dist/index.js",
|
5
|
+
"bin": {
|
6
|
+
"clai": "dist/cli.js"
|
7
|
+
},
|
8
|
+
"repository": {
|
9
|
+
"type": "git",
|
10
|
+
"url": "git+https://github.com/schnullerpip/clai.git"
|
11
|
+
},
|
12
|
+
"scripts": {
|
13
|
+
"start": "bun run src/cli.ts",
|
14
|
+
"build": "bun build ./src/index.ts --outdir dist --target node && bun build ./src/cli.ts --outdir dist --target node",
|
15
|
+
"dev": "bun --watch src/cli.ts"
|
16
|
+
},
|
17
|
+
"author": "schnullerpip (https://github.com/schnullerpip)",
|
18
|
+
"license": "ISC",
|
19
|
+
"description": "AI-powered webpage summarizer",
|
20
|
+
"dependencies": {
|
21
|
+
"@biomejs/biome": "^1.9.4",
|
22
|
+
"@fettstorch/jule": "^0.5.3",
|
23
|
+
"chalk": "^5.3.0",
|
24
|
+
"cheerio": "^1.0.0-rc.12",
|
25
|
+
"commander": "^12.1.0",
|
26
|
+
"inquirer": "^12.1.0",
|
27
|
+
"openai": "^4.73.0",
|
28
|
+
"ora": "^8.1.1"
|
29
|
+
},
|
30
|
+
"devDependencies": {
|
31
|
+
"@types/inquirer": "^9.0.7",
|
32
|
+
"@types/node": "^20.11.19",
|
33
|
+
"bun-types": "latest"
|
34
|
+
},
|
35
|
+
"publishConfig": {
|
36
|
+
"access": "public"
|
37
|
+
}
|
38
38
|
}
|
package/src/cli.ts
CHANGED
@@ -1,155 +1,163 @@
|
|
1
1
|
#!/usr/bin/env bun
|
2
|
-
import {
|
3
|
-
import
|
4
|
-
import
|
5
|
-
import
|
6
|
-
import
|
7
|
-
import
|
8
|
-
import
|
2
|
+
import { when } from '@fettstorch/jule'
|
3
|
+
import chalk from 'chalk'
|
4
|
+
import { Command } from 'commander'
|
5
|
+
import inquirer from 'inquirer'
|
6
|
+
import ora from 'ora'
|
7
|
+
import pkg from '../package.json' assert { type: 'json' }
|
8
|
+
import { clai } from './index'
|
9
9
|
|
10
|
-
const program = new Command()
|
10
|
+
const program = new Command()
|
11
11
|
|
12
12
|
async function main() {
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
13
|
+
try {
|
14
|
+
program
|
15
|
+
.name('clai')
|
16
|
+
.description('AI-powered web scraping tool')
|
17
|
+
.version(pkg.version)
|
18
|
+
.argument('[input...]', 'URL or search terms to analyze')
|
19
|
+
.action(async (inputs: string[]) => {
|
20
|
+
const openAIKey = process.env.OPENAI_API_KEY
|
21
|
+
|
22
|
+
if (!openAIKey) {
|
23
|
+
console.error(
|
24
|
+
chalk.red('❌ OPENAI_API_KEY environment variable is not set'),
|
25
|
+
)
|
26
|
+
process.exit(1)
|
27
|
+
}
|
28
|
+
|
29
|
+
let input = inputs?.join(' ')
|
30
|
+
|
31
|
+
if (!input) {
|
32
|
+
const answers = await inquirer.prompt([
|
33
|
+
{
|
34
|
+
type: 'input',
|
35
|
+
name: 'input',
|
36
|
+
message: 'Enter a URL or search query:',
|
37
|
+
validate: (input) => input.length > 0,
|
38
|
+
},
|
39
|
+
])
|
40
|
+
input = answers.input
|
41
|
+
}
|
42
|
+
|
43
|
+
await analyzeInput(input, openAIKey)
|
44
|
+
process.exit(0)
|
45
|
+
})
|
46
|
+
|
47
|
+
await program.parseAsync()
|
48
|
+
} catch (error) {
|
49
|
+
console.error(chalk.red('Fatal error:'), error)
|
50
|
+
process.exit(1)
|
51
|
+
}
|
50
52
|
}
|
51
53
|
|
52
54
|
async function animateText(text: string, delay = 25) {
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
55
|
+
let shouldComplete = false
|
56
|
+
|
57
|
+
// Setup keypress listener
|
58
|
+
const keypressHandler = (str: string, key: { name: string }) => {
|
59
|
+
if (key.name === 'return') {
|
60
|
+
shouldComplete = true
|
61
|
+
}
|
62
|
+
}
|
63
|
+
|
64
|
+
process.stdin.on('keypress', keypressHandler)
|
65
|
+
|
66
|
+
// Enable raw mode to get keypress events
|
67
|
+
process.stdin.setRawMode(true)
|
68
|
+
process.stdin.resume()
|
69
|
+
|
70
|
+
let currentIndex = 0
|
71
|
+
while (currentIndex < text.length) {
|
72
|
+
if (shouldComplete) {
|
73
|
+
// Show remaining text immediately
|
74
|
+
process.stdout.write(text.slice(currentIndex))
|
75
|
+
break
|
76
|
+
}
|
77
|
+
|
78
|
+
process.stdout.write(text[currentIndex])
|
79
|
+
currentIndex++
|
80
|
+
|
81
|
+
if (!shouldComplete) {
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
83
|
+
}
|
84
|
+
}
|
85
|
+
|
86
|
+
// Cleanup
|
87
|
+
process.stdin.setRawMode(false)
|
88
|
+
process.stdin.pause()
|
89
|
+
process.stdin.removeListener('keypress', keypressHandler)
|
90
|
+
|
91
|
+
process.stdout.write('\n')
|
90
92
|
}
|
91
93
|
|
92
94
|
function formatMarkdownForTerminal(text: string): string {
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
95
|
+
// Handle headings first
|
96
|
+
const headingHandled = text.replace(
|
97
|
+
/^(#{1,3})\s+(.*?)$/gm,
|
98
|
+
(_, hashes, content) =>
|
99
|
+
when(hashes.length)({
|
100
|
+
1: () =>
|
101
|
+
`\n${chalk.yellow.bold('═══ ')}${chalk.yellow.bold(content)}${chalk.yellow.bold(' ═══')}`,
|
102
|
+
2: () => chalk.yellowBright.bold(content),
|
103
|
+
3: () => chalk.yellow(content),
|
104
|
+
else: () => content,
|
105
|
+
}),
|
106
|
+
)
|
107
|
+
|
108
|
+
// Handle regular bold text after headings
|
109
|
+
const boldHandled = headingHandled.replace(/\*\*(.*?)\*\*/g, (_, content) =>
|
110
|
+
chalk.bold(content),
|
111
|
+
)
|
112
|
+
|
113
|
+
return boldHandled
|
105
114
|
}
|
106
115
|
|
107
116
|
async function analyzeInput(input: string, openAIKey: string) {
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
}
|
117
|
+
const spinner = ora('Thinking...').start()
|
118
|
+
|
119
|
+
try {
|
120
|
+
const result = await clai(input, openAIKey)
|
121
|
+
spinner.succeed('AHA!')
|
122
|
+
|
123
|
+
console.log(chalk.green.bold('\n📝 ═══ Summary ═══ :'))
|
124
|
+
const formattedContent = formatMarkdownForTerminal(result.summary)
|
125
|
+
await animateText(formattedContent)
|
126
|
+
|
127
|
+
// Prompt user to select a link
|
128
|
+
const { selectedLink } = await inquirer.prompt([
|
129
|
+
{
|
130
|
+
type: 'list',
|
131
|
+
name: 'selectedLink',
|
132
|
+
message: '\n\nWhat now?:',
|
133
|
+
choices: [
|
134
|
+
{ name: chalk.yellow('🔍 New search'), value: 'new' },
|
135
|
+
...result.links.map((link) => ({
|
136
|
+
name: `${chalk.bold(link.name)}: ${chalk.cyan(link.url)}`,
|
137
|
+
value: link.url,
|
138
|
+
})),
|
139
|
+
{ name: 'Exit', value: 'exit' },
|
140
|
+
],
|
141
|
+
},
|
142
|
+
])
|
143
|
+
|
144
|
+
if (selectedLink === 'new') {
|
145
|
+
const { input: newInput } = await inquirer.prompt([
|
146
|
+
{
|
147
|
+
type: 'input',
|
148
|
+
name: 'input',
|
149
|
+
message: 'Enter a URL or search query:',
|
150
|
+
validate: (input) => input.length > 0,
|
151
|
+
},
|
152
|
+
])
|
153
|
+
await analyzeInput(newInput, openAIKey)
|
154
|
+
} else if (selectedLink && selectedLink !== 'exit') {
|
155
|
+
await analyzeInput(selectedLink, openAIKey)
|
156
|
+
}
|
157
|
+
} catch (error) {
|
158
|
+
spinner?.fail('Analysis failed')
|
159
|
+
console.error(chalk.red('Error:'), error)
|
160
|
+
}
|
153
161
|
}
|
154
162
|
|
155
|
-
main()
|
163
|
+
main()
|
package/src/index.ts
CHANGED
@@ -1,13 +1,13 @@
|
|
1
|
-
import { scrape } from './scraper'
|
2
|
-
import { summarizeWebPage as summarize } from './summarizer'
|
1
|
+
import { scrape } from './scraper'
|
2
|
+
import { summarizeWebPage as summarize } from './summarizer'
|
3
3
|
|
4
4
|
export interface SummaryOutput {
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
5
|
+
summary: string
|
6
|
+
links: ReadonlyArray<{
|
7
|
+
name: string
|
8
|
+
url: string
|
9
|
+
}>
|
10
|
+
sources: string[]
|
11
11
|
}
|
12
12
|
|
13
13
|
/**
|
@@ -15,7 +15,7 @@ export interface SummaryOutput {
|
|
15
15
|
* @param input - URL or search query to analyze
|
16
16
|
* @param openAIKey - OpenAI API key
|
17
17
|
* @returns Promise with summary, extracted links, and source URLs
|
18
|
-
*
|
18
|
+
*
|
19
19
|
* @example
|
20
20
|
* ```ts
|
21
21
|
* const result = await clai('https://example.com', 'your-openai-key')
|
@@ -24,22 +24,25 @@ export interface SummaryOutput {
|
|
24
24
|
* console.log(result.sources) // Source URLs
|
25
25
|
* ```
|
26
26
|
*/
|
27
|
-
export async function clai(
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
27
|
+
export async function clai(
|
28
|
+
input: string,
|
29
|
+
openAIKey: string,
|
30
|
+
): Promise<SummaryOutput> {
|
31
|
+
const scrapedData = await scrape(input)
|
32
|
+
|
33
|
+
// Combine all content with source attribution
|
34
|
+
const combinedContent = scrapedData
|
35
|
+
.map((data) => `Content from ${data.url}:\n${data.content}`)
|
36
|
+
.join('\n\n')
|
37
|
+
|
38
|
+
const result = await summarize(combinedContent, openAIKey)
|
39
|
+
|
40
|
+
return {
|
41
|
+
summary: result.textual.trim(),
|
42
|
+
links: result.links,
|
43
|
+
sources: scrapedData.map((data) => data.url),
|
44
|
+
}
|
42
45
|
}
|
43
46
|
|
44
47
|
// Default export for easier importing
|
45
|
-
export default clai
|
48
|
+
export default clai
|
package/src/openai.ts
CHANGED
@@ -1,5 +1,5 @@
|
|
1
|
-
import { once } from
|
2
|
-
import OpenAI from
|
1
|
+
import { once } from "@fettstorch/jule";
|
2
|
+
import OpenAI from "openai";
|
3
3
|
|
4
4
|
const MAX_INPUT_TOKENS = 10000;
|
5
5
|
|
@@ -30,16 +30,16 @@ class OpenAIWrapper {
|
|
30
30
|
} = {}
|
31
31
|
): Promise<string> {
|
32
32
|
const truncatedPrompt = truncateContent(prompt);
|
33
|
-
const { model =
|
33
|
+
const { model = "gpt-4o", temperature = 0.6 } = options;
|
34
34
|
|
35
35
|
const response = await this.client.chat.completions.create({
|
36
36
|
model,
|
37
|
-
messages: [{ role:
|
37
|
+
messages: [{ role: "user", content: truncatedPrompt }],
|
38
38
|
temperature,
|
39
|
-
max_tokens: 2000
|
39
|
+
max_tokens: 2000,
|
40
40
|
});
|
41
41
|
|
42
|
-
return response.choices[0]?.message?.content ??
|
42
|
+
return response.choices[0]?.message?.content ?? "";
|
43
43
|
}
|
44
44
|
|
45
45
|
async completeStructured<T>(
|
@@ -52,41 +52,45 @@ class OpenAIWrapper {
|
|
52
52
|
}
|
53
53
|
): Promise<T> {
|
54
54
|
const truncatedPrompt = truncateContent(prompt);
|
55
|
-
const {
|
56
|
-
model =
|
55
|
+
const {
|
56
|
+
model = "gpt-4o",
|
57
57
|
temperature = 1.6,
|
58
|
-
functionName =
|
59
|
-
responseSchema
|
58
|
+
functionName = "generate_response",
|
59
|
+
responseSchema,
|
60
60
|
} = options;
|
61
61
|
|
62
62
|
const response = await this.client.chat.completions.create({
|
63
63
|
model,
|
64
|
-
messages: [{ role:
|
64
|
+
messages: [{ role: "user", content: truncatedPrompt }],
|
65
65
|
temperature,
|
66
66
|
max_tokens: 2000,
|
67
|
-
functions: [
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
67
|
+
functions: [
|
68
|
+
{
|
69
|
+
name: functionName,
|
70
|
+
parameters: {
|
71
|
+
type: "object",
|
72
|
+
properties: responseSchema,
|
73
|
+
required: Object.keys(responseSchema),
|
74
|
+
},
|
75
|
+
},
|
76
|
+
],
|
77
|
+
function_call: { name: functionName },
|
76
78
|
});
|
77
79
|
|
78
80
|
const functionCall = response.choices[0]?.message?.function_call;
|
79
81
|
if (!functionCall?.arguments) {
|
80
|
-
throw new Error(
|
82
|
+
throw new Error("No function call arguments received");
|
81
83
|
}
|
82
84
|
|
83
85
|
return JSON.parse(functionCall.arguments) as T;
|
84
86
|
}
|
85
87
|
}
|
86
88
|
|
87
|
-
export const openaiClient: (apiKey?: string) => OpenAIWrapper = once(
|
89
|
+
export const openaiClient: (apiKey?: string) => OpenAIWrapper = once(
|
90
|
+
(apiKey?: string) => {
|
88
91
|
if (!apiKey) {
|
89
|
-
|
92
|
+
throw new Error("OPENAI_API_KEY is not set");
|
90
93
|
}
|
91
|
-
return new OpenAIWrapper(apiKey)
|
92
|
-
}
|
94
|
+
return new OpenAIWrapper(apiKey);
|
95
|
+
}
|
96
|
+
);
|