├── .cursorignore ├── .env.dist ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── README.md ├── TODO.md ├── bun.lockb ├── docs └── add-new-tool.md ├── drizzle.config.ts ├── oai.sh ├── package.json ├── src ├── cli │ ├── app.cli.ts │ ├── assistant │ │ ├── assistant.utils.ts │ │ ├── create-assistant.cli.ts │ │ ├── delete-assistant.cli.ts │ │ ├── list-assistants.cli.ts │ │ └── update-assistant.cli.ts │ ├── entry.cli.ts │ ├── helpers │ │ └── count-tokens.cli.ts │ ├── md.utils.ts │ └── vector-store │ │ ├── create-vector-store.cli.ts │ │ ├── delete-vector-store.cli.ts │ │ ├── list-vector-stores.cli.ts │ │ ├── sync-vector-store.cli.ts │ │ ├── update-vector-store.cli.ts │ │ └── vector-store.utils.ts ├── openai │ ├── assistant.client.ts │ ├── openai.client.ts │ ├── thread.client.ts │ ├── tool.client.ts │ ├── tool.utils.ts │ ├── vector-store-files.client.ts │ └── vector-store.client.ts ├── sync │ ├── sync-cleanup.ts │ ├── sync-page-urls.ts │ ├── sync-site-map.ts │ └── sync-urls.ts ├── tools │ ├── appendToFile.ts │ ├── commit.ts │ ├── createDir.ts │ ├── executeCommand.ts │ ├── fileDiff.ts │ ├── getUrlContent.ts │ ├── ls.ts │ ├── readFile.ts │ └── writeFile.ts └── utils │ ├── cli.utils.ts │ ├── fs.utils.ts │ ├── json.utils.ts │ ├── node.utils.ts │ ├── regex.utils.ts │ ├── tokens.utils.ts │ ├── ts.utils.ts │ └── web.utils.ts └── tsconfig.json /.cursorignore: -------------------------------------------------------------------------------- 1 | # Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) 2 | .env 3 | .git 4 | .pnpm-store 5 | dist 6 | node_modules -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY= -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: yleflour 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log 3 | *.tsbuildinfo 4 | /dist/ 5 | /.pnpm-store/ 6 | dist 7 | .env 8 | tmp.patch 9 | /data -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "configurations": [ 4 | { 5 | "type": "bun", 6 | "request": "attach", 7 | "name": "Attach to Bun", 8 | 9 | // The URL of the WebSocket inspector to attach to. 10 | // This value can be retrieved by using `bun --inspect`. 11 | "url": "ws://localhost:6499" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "yaml.schemas": { 4 | "./schemas/ai-config-schema.json": "ai.config.yml" 5 | }, 6 | "typescript.tsdk": "node_modules/typescript/lib", 7 | "sqltools.connections": [ 8 | { 9 | "previewLimit": 50, 10 | "driver": "SQLite", 11 | "name": "gpt-coding-assistant", 12 | "database": "${workspaceFolder:gpt-coding-assistant}/data/data.db" 13 | } 14 | ], 15 | "sqltools.useNodeRuntime": true 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OAI - OpenAI Assistant Interface 2 | 3 | ✨✨ ChatGPT on your system ✨✨ 4 | 5 | ## Table of Contents 6 | 7 | - [Why OAI](#why-oai) 8 | - [Sample Usages](#sample-usages) 9 | - [Installation](#installation) 10 | - [Configure](#configure) 11 | - [CLI Usage](#cli-usage) 12 | - [Global access](#global-access) 13 | - [Commands](#commands) 14 | - [Vector Stores](#vector-stores) 15 | - [Tools](#tools) 16 | - [Available tools](#available-tools) 17 | - [How to add tools ?](#how-to-add-tools) 18 | - [Debugging](#debugging) 19 | 20 | ## Why OAI 21 | 22 | The OpenAI Assistant API handles agents, conversation history, vector stores, and running tools which traditionnaly requires a lot of boilerplate code to set up. 23 | 24 | Our goal with OAI is to provide a simple and intuitive interface to interact with this API. 25 | 26 | The current version offers a CLI interface, but more will come in the future. 27 | 28 | ## Sample Usages 29 | 30 | - Chat with up-to-date documentation with managed vector stores 31 | - Ask question about your codebase by leveraging tools 32 | - Ask your assistant to write code directly to files 33 | - Let your assistant manage and access your system 34 | - Configure and test an assistant for another integration 35 | - _Whatever you can think of_ 36 | 37 | Just run `oai` from the command line to interact 38 | 39 | ## Installation 40 | 41 | OAI currently relies on [bun](https://bun.sh/), and it needs to be installed on your system in order to run the project. 42 | 43 | A later version may allow using the `node` runtime, but for now, only `bun` is supported. 44 | 45 | ```bash 46 | git clone git@github.com:pAIrprogio/gpt-assistant-cli-playground.git 47 | bun install 48 | ``` 49 | 50 | ## Configure 51 | 52 | - Create a project on the [OpenAI Platform](https://platform.openai.com/organization/projects) 53 | - Create an API Key 54 | - Add your OpenAI API key to a .env file using `OPENAI_API_KEY=your-key` 55 | 56 | ## CLI Usage 57 | 58 | ### Global access 59 | 60 | To install your assistant globally and access it with the `oai` command, run `bun link` in the project's folder. 61 | 62 | Any change made to the project will be reflected in the global command without any extra build step. 63 | 64 | ### Commands 65 | 66 | - `oai` or `oai chat`: starts a chat with an assistant 67 | - `oai a|assistant`: manage your assistant 68 | - `oai a ls|list`: list available assistants 69 | - `oai a add|create|new`: create a new assistant 70 | - `oai a rm|remove|delete`: remove an assistant 71 | - `oai a e|edit`: edit an assistant 72 | - `oai vs|vector-store`: manage your vector store 73 | - `oai vs ls|list`: list available vector stores 74 | - `oai vs add|create|new`: create a new vector store 75 | - `oai vs rm|remove|delete`: remove a vector store 76 | - `oai vs e|edit`: edit a vector store 77 | - `oai vs sync`: sync managed vector stores 78 | 79 | ## Vector Stores 80 | 81 | Vector Stores are used by assistants with `file search` enabled to dynamically fetch relevant information. OAI helps you manage them custom synchronizations. 82 | 83 | - **Sitemap sync**: Fetches urls from a sitemap, and synchronizes every page to the vector store 84 | - **Page urls sync**: Fetches urls from a page, and synchronizes every url to the vector store 85 | - _More to come_ 86 | 87 | Once set up, make sure to run `oai vs sync` to synchronize your vector store. 88 | 89 | ## Tools 90 | 91 | ### Available tools 92 | 93 | - [ls](./src/tools/ls.ts): Git aware file listing 94 | - [read-file](./src/tools/readFile.ts): Read a file 95 | - [write-file](./src/tools/writeFile.ts): Writes to a file, creating directories if needed 96 | - [append-to-file](./src/tools/appendToFile.ts): Appends to an existing file 97 | - [commit](./src/tools/commit.ts): Commits changes to the current branch 98 | - [create-dir](./src/tools/createDir.ts): Creates a directory with its parents if needed 99 | - [execute-command](./src/tools/executeCommand.ts): Executes a command (⚠️ Will not ask for confirmation) 100 | - [get-url-content](./src/tools/getUrlContent.ts): Fetches the content of a URL 101 | - [file-diff](./src/tools/fileDiff.ts): Reads the current diffs of a file 102 | 103 | ### How to add tools ? 104 | 105 | Follow the steps in [docs/add-new-tool.md](docs/add-new-tool.md) 106 | 107 | ## Debugging 108 | 109 | Due to issues in bun-vscode, you need to inspect through an external debugger. 110 | 111 | To debug the project, run `bun debug` in the project's folder. 112 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | - [ ] Hot Reloading of tools 2 | - [ ] Save threads 3 | - [ ] Assistant swapping 4 | - [ ] Save/Restore chats 5 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pAIrprogio/oai/c3392f976383c483b648a8560e5a37c935db8129/bun.lockb -------------------------------------------------------------------------------- /docs/add-new-tool.md: -------------------------------------------------------------------------------- 1 | ## How to add a tool ? 2 | 3 | 1. Look at `./src/tools/readFile.ts` for an example 4 | 2. Create a new file `./src/tools/.ts` 5 | 3. Write and export an interface for your tool args 6 | - Create the input validation schema using `zod` 7 | - Create the type infering it from the schema 8 | - Export the tool as default export, complying with the `Tool` interface from `./src/tool.utils.ts` 9 | - Make sure the tool name matches the file's name 10 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import type { Config } from "drizzle-kit"; 3 | export default { 4 | schema: "./src/storage/storage.schema.ts", 5 | out: "./drizzle", 6 | driver: "better-sqlite", 7 | } satisfies Config; 8 | -------------------------------------------------------------------------------- /oai.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | dotenv_path="$(dirname "$(realpath "$0")")/.env" 6 | index_ts_path="$(dirname "$(realpath "$0")")/src/cli/entry.cli.ts" 7 | 8 | if [ ! -f "$dotenv_path" ]; then 9 | echo "Error: .env file not found" 10 | exit 1 11 | fi 12 | 13 | extra_args="$@" 14 | bun --env-file="$dotenv_path" $index_ts_path $extra_args 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pairprog/oai", 3 | "version": "3.0.0", 4 | "description": "", 5 | "main": "index.ts", 6 | "type": "module", 7 | "scripts": { 8 | "start": "bun src/index.ts", 9 | "debug": "bun --inspect-wait src/index.ts" 10 | }, 11 | "bin": { 12 | "oai": "./oai.sh" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "@inquirer/prompts": "^5.0.3", 19 | "@mozilla/readability": "^0.5.0", 20 | "@types/jsdom": "^21.1.6", 21 | "axios": "^1.6.8", 22 | "chalk": "^5.3.0", 23 | "cheerio": "^1.0.0-rc.12", 24 | "commander": "^12.0.0", 25 | "dotenv": "^16.3.1", 26 | "inquirer-select-pro": "^1.0.0-alpha.4", 27 | "iter-tools": "^7.5.3", 28 | "jsdom": "^24.0.0", 29 | "jsonrepair": "^3.6.0", 30 | "marked": "^12.0.1", 31 | "marked-terminal": "^7.0.0", 32 | "minimatch": "^9.0.4", 33 | "moderndash": "^3.11.0", 34 | "openai": "^4.47.1", 35 | "ora": "^7.0.1", 36 | "pretty-bytes": "^6.1.1", 37 | "rxjs": "^7.8.1", 38 | "tiktoken": "^1.0.15", 39 | "wretch": "^2.8.1", 40 | "xml2js": "^0.6.2", 41 | "zod": "^3.22.4", 42 | "zod-to-json-schema": "^3.22.4", 43 | "zx": "^7.2.3" 44 | }, 45 | "devDependencies": { 46 | "@types/marked-terminal": "^6.1.1", 47 | "@types/xml2js": "^0.4.14", 48 | "drizzle-kit": "^0.20.14", 49 | "prettier": "^3.1.0", 50 | "typescript": "^5.3.3", 51 | "vitest": "^1.2.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/cli/app.cli.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | import ora, { Ora } from "ora"; 3 | import { lastValueFrom } from "rxjs"; 4 | import { $, chalk, echo, question } from "zx"; 5 | import { createThread } from "../openai/thread.client.js"; 6 | import { deleteLastTextFromTerminal } from "../utils/cli.utils.js"; 7 | import { throwOnUnhandled } from "../utils/ts.utils.js"; 8 | import { promptAssistantSelection } from "./assistant/assistant.utils.js"; 9 | import { mdToTerminal } from "./md.utils.js"; 10 | 11 | $.verbose = false; 12 | 13 | async function promptUserMessage() { 14 | newLine(); 15 | echo(chalk.bold.magenta("User: ")); 16 | newLine(); 17 | const userInput = await question(); 18 | newLine(); 19 | return userInput; 20 | } 21 | 22 | function newLine() { 23 | echo(""); 24 | } 25 | 26 | export async function appAction() { 27 | const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); 28 | let assistantSpinner: Ora | null = null; 29 | 30 | const assistant = await promptAssistantSelection(); 31 | const thread = await createThread(client, assistant); 32 | let bufferedText = ""; 33 | thread.assistantResponses$.subscribe((response) => { 34 | if (response.type === "responseStart") { 35 | return; 36 | } 37 | 38 | if (response.type === "responseEnd") { 39 | promptUserMessage().then((userInput) => { 40 | echo(chalk.bold.blue("Assistant:")); 41 | newLine(); 42 | assistantSpinner = ora({ 43 | text: "Thinking", 44 | color: "blue", 45 | }).start(); 46 | thread.sendMessage(userInput); 47 | }); 48 | return; 49 | } 50 | 51 | if (response.type === "stepStart") { 52 | assistantSpinner?.stop(); 53 | return; 54 | } 55 | 56 | if (response.type === "stepEnd") { 57 | return; 58 | } 59 | 60 | if (response.type === "error") { 61 | return; 62 | } 63 | 64 | if (response.type === "responseAbort") { 65 | return; 66 | } 67 | 68 | if (response.type === "tokensUsage") { 69 | return; 70 | } 71 | 72 | if (response.type === "textStart") { 73 | newLine(); 74 | bufferedText = ""; 75 | return; 76 | } 77 | 78 | if (response.type === "textDelta") { 79 | process.stdout.write(response.content); 80 | bufferedText += response.content; 81 | return; 82 | } 83 | 84 | if (response.type === "textEnd") { 85 | // TODO: Format text to markdown 86 | newLine(); 87 | // deleteLastTextFromTerminal(bufferedText); 88 | // echo(mdToTerminal(bufferedText)); 89 | return; 90 | } 91 | 92 | if (response.type === "functionCallStart") { 93 | newLine(); 94 | console.write( 95 | chalk.cyan("Executing " + chalk.bold(response.name) + " with args: "), 96 | ); 97 | return; 98 | } 99 | 100 | if (response.type === "functionCallDelta") { 101 | console.write(chalk.cyan(response.argsDelta)); 102 | return; 103 | } 104 | 105 | if (response.type === "functionCallDone") { 106 | newLine(); 107 | return; 108 | } 109 | 110 | if (response.type === "functionCallExecuted") { 111 | newLine(); 112 | if (response.output.success) { 113 | echo( 114 | chalk.green( 115 | "✔ Successfully executed " + chalk.bold(response.toolName), 116 | ), 117 | ); 118 | } else { 119 | echo( 120 | chalk.red("✖ Failed to execute " + chalk.bold(response.toolName)), 121 | ); 122 | // @ts-ignore 123 | echo(chalk.red(response.output.error)); 124 | } 125 | return; 126 | } 127 | 128 | throwOnUnhandled(response, "Unhandled response"); 129 | }); 130 | 131 | // First user message 132 | promptUserMessage().then((userInput) => { 133 | echo(chalk.bold.blue("Assistant:")); 134 | assistantSpinner = ora({ 135 | text: "Thinking", 136 | color: "blue", 137 | }).start(); 138 | thread.sendMessage(userInput); 139 | }); 140 | return lastValueFrom(thread.assistantResponses$); 141 | } 142 | -------------------------------------------------------------------------------- /src/cli/assistant/assistant.utils.ts: -------------------------------------------------------------------------------- 1 | import { input, select, confirm, editor } from "@inquirer/prompts"; 2 | import { select as selectPro } from "inquirer-select-pro"; 3 | import { 4 | AssistantConfigInput, 5 | ParsedAssistant, 6 | assistantConfigSchema, 7 | getAssistants, 8 | } from "../../openai/assistant.client.js"; 9 | import { MODELS } from "../../openai/openai.client.js"; 10 | import { asyncToArray } from "iter-tools"; 11 | import ora from "ora"; 12 | import { chalk, echo } from "zx"; 13 | import { getToolsNames } from "../../openai/tool.client.js"; 14 | import { promptVectorStoreSelection } from "../vector-store/vector-store.utils.js"; 15 | 16 | export const promptAssistantConfig = async ( 17 | assistant?: ParsedAssistant, 18 | ): Promise => { 19 | const name = await input({ 20 | message: "Name", 21 | default: assistant?.name ?? undefined, 22 | validate: (input) => 23 | assistantConfigSchema.shape.name.safeParse(input).success, 24 | }); 25 | 26 | const description = await input({ 27 | message: "Description", 28 | default: assistant?.description ?? undefined, 29 | validate: (input) => 30 | assistantConfigSchema.shape.description.safeParse(input).success, 31 | }); 32 | 33 | const temperature = await input({ 34 | message: "Temperature (0 to 1)", 35 | default: assistant?.temperature?.toString() ?? "0.5", 36 | validate: (input) => 37 | assistantConfigSchema.shape.temperature.safeParse(input).success, 38 | }); 39 | 40 | const model = await select({ 41 | message: "Model", 42 | default: assistant?.model ?? MODELS[0], 43 | choices: MODELS.map((m) => ({ value: m })), 44 | }); 45 | 46 | const isCodeInterpreterEnabled = await confirm({ 47 | message: "Enable code interpreter", 48 | default: assistant?.isCodeInterpreterEnabled ?? false, 49 | }); 50 | 51 | const allTools = await asyncToArray(getToolsNames()); 52 | 53 | const toolNames = await selectPro({ 54 | message: "Select tools", 55 | options: allTools.map((t) => ({ value: t })), 56 | multiple: true, 57 | defaultValue: assistant?.toolNames ?? [], 58 | }); 59 | 60 | const isFileSearchEnabled = await confirm({ 61 | message: "Enable file search", 62 | default: assistant?.isFileSearchEnabled ?? false, 63 | }); 64 | 65 | let vectorStoreIds: string[] | undefined; 66 | if (isFileSearchEnabled) 67 | vectorStoreIds = await promptVectorStoreSelection({ 68 | message: "Select vector stores", 69 | multiple: true, 70 | defaultSelectedIds: 71 | assistant?.tool_resources?.file_search?.vector_store_ids ?? [], 72 | }).then((store) => store.map((s) => s.id)); 73 | 74 | const respondWithJson = await confirm({ 75 | message: "Enable JSON only response?", 76 | default: assistant?.respondWithJson ?? false, 77 | }); 78 | 79 | const shouldEditInstructions = await confirm({ 80 | message: "Edit instructions?", 81 | default: true, 82 | }); 83 | 84 | let instructions = assistant?.instructions ?? ""; 85 | if (shouldEditInstructions) { 86 | instructions = await editor({ 87 | message: "Instructions", 88 | default: instructions, 89 | validate: (input) => 90 | assistantConfigSchema.shape.instructions.safeParse(input).success, 91 | }); 92 | } 93 | 94 | // Handle Tools 95 | 96 | return { 97 | name, 98 | description, 99 | model, 100 | toolNames, 101 | temperature: parseFloat(temperature), 102 | instructions, 103 | isCodeInterpreterEnabled, 104 | isFileSearchEnabled, 105 | respondWithJson, 106 | vectorStoreIds, 107 | }; 108 | }; 109 | 110 | export async function promptAssistantSelection(config?: { 111 | message?: string; 112 | multiple?: false; 113 | }): Promise; 114 | export async function promptAssistantSelection(config?: { 115 | message?: string; 116 | multiple?: true; 117 | }): Promise; 118 | export async function promptAssistantSelection(config?: { 119 | message?: string; 120 | multiple?: boolean; 121 | }): Promise { 122 | let spinner = ora({ 123 | text: "Fetching all assistants", 124 | color: "blue", 125 | }).start(); 126 | const allAssistants = await asyncToArray(getAssistants()); 127 | spinner.stop(); 128 | 129 | if (allAssistants.length === 0) { 130 | echo(chalk.yellow("No assistants found")); 131 | process.exit(0); 132 | } 133 | 134 | const answer = (await selectPro({ 135 | message: config?.message ?? "Which assistant do you want to use?", 136 | validate: (input) => input !== null, 137 | options: (input) => 138 | allAssistants 139 | .filter( 140 | (a) => 141 | !input || 142 | (a.name && a.name.toLowerCase().includes(input.toLowerCase())) || 143 | a.id.toLowerCase().includes(input.toLowerCase()), 144 | ) 145 | .map((a) => ({ 146 | name: `${a.name ?? chalk.italic("")} (${a.id}) ${a.description ? `- ${a.description}` : ""}`, 147 | value: a, 148 | })), 149 | multiple: config?.multiple ?? false, 150 | })) as ParsedAssistant | ParsedAssistant[]; 151 | 152 | return answer; 153 | } 154 | 155 | export const renderAssistant = (assistant: ParsedAssistant) => { 156 | let firstLine = 157 | chalk.bold(assistant.name ?? chalk.italic("")) + 158 | ` (${assistant.id})`; 159 | if (assistant.description) firstLine += ` - ${assistant.description}`; 160 | echo(firstLine); 161 | 162 | const vector_stores_count = 163 | assistant.tool_resources?.file_search?.vector_store_ids?.length; 164 | 165 | echo(" " + chalk.underline(`${assistant.playgroundUrl}`)); 166 | 167 | let secondLine = [ 168 | chalk.green(assistant.model), 169 | chalk.blue(`t°${assistant.temperature}`), 170 | ]; 171 | 172 | if (assistant.tools.length > 0) 173 | secondLine.push( 174 | chalk.magenta( 175 | `${assistant.tools.length} tool${assistant.tools.length === 1 ? "" : "s"}`, 176 | ), 177 | ); 178 | 179 | if (vector_stores_count) 180 | secondLine.push( 181 | chalk.yellow( 182 | `${vector_stores_count} vector store${vector_stores_count === 1 ? "" : "s"}`, 183 | ), 184 | ); 185 | 186 | echo(" " + secondLine.join(" - ")); 187 | }; 188 | -------------------------------------------------------------------------------- /src/cli/assistant/create-assistant.cli.ts: -------------------------------------------------------------------------------- 1 | import ora from "ora"; 2 | import { createAssistant } from "../../openai/assistant.client.js"; 3 | import { promptAssistantConfig, renderAssistant } from "./assistant.utils.js"; 4 | import { chalk, echo } from "zx"; 5 | 6 | export const createAssistantAction = async () => { 7 | const config = await promptAssistantConfig(); 8 | const spinner = ora({ 9 | text: "Creating assistant", 10 | color: "blue", 11 | }).start(); 12 | const assistant = await createAssistant(config); 13 | spinner.stop(); 14 | echo(""); 15 | echo(chalk.bold.green("Successfully created assistant")); 16 | renderAssistant(assistant); 17 | }; 18 | -------------------------------------------------------------------------------- /src/cli/assistant/delete-assistant.cli.ts: -------------------------------------------------------------------------------- 1 | import ora from "ora"; 2 | import { deleteAssistant } from "../../openai/assistant.client.js"; 3 | import { promptAssistantSelection } from "./assistant.utils.js"; 4 | 5 | export const deleteAssistantAction = async (args?: string) => { 6 | if (args) { 7 | const spinner = ora({ 8 | text: `Deleting ${args}`, 9 | color: "blue", 10 | }).start(); 11 | await deleteAssistant(args); 12 | spinner.stopAndPersist({ 13 | symbol: "🗑", 14 | text: "Assistant deleted", 15 | }); 16 | return; 17 | } 18 | 19 | const answer = await promptAssistantSelection({ 20 | message: "Which assistants do you want to delete?", 21 | multiple: true, 22 | }); 23 | 24 | const spinner = ora({ 25 | text: `Deleting ${answer.length} assistant(s)`, 26 | color: "blue", 27 | }).start(); 28 | 29 | await Promise.all(answer.map((a) => deleteAssistant(a.id))); 30 | 31 | spinner.stopAndPersist({ 32 | symbol: "🗑", 33 | text: `${answer.length} assistant(s) deleted`, 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/cli/assistant/list-assistants.cli.ts: -------------------------------------------------------------------------------- 1 | import { chalk, echo } from "zx"; 2 | import { getAssistants } from "../../openai/assistant.client.js"; 3 | import { renderAssistant } from "./assistant.utils.js"; 4 | 5 | export const listAssistantsAction = async () => { 6 | const assistants = getAssistants(); 7 | echo(chalk.bgBlue.bold(" Assistants: ")); 8 | for await (const assistant of assistants) { 9 | echo(""); 10 | renderAssistant(assistant); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/cli/assistant/update-assistant.cli.ts: -------------------------------------------------------------------------------- 1 | import ora from "ora"; 2 | import { updateAssistant } from "../../openai/assistant.client.js"; 3 | import { 4 | promptAssistantConfig, 5 | promptAssistantSelection, 6 | renderAssistant, 7 | } from "./assistant.utils.js"; 8 | import { chalk, echo } from "zx"; 9 | 10 | export const updateAssistantAction = async () => { 11 | const assistant = await promptAssistantSelection({ 12 | message: "Which assistant do you want to update?", 13 | }); 14 | const config = await promptAssistantConfig(assistant); 15 | const spinner = ora({ 16 | text: "Updating assistant", 17 | color: "blue", 18 | }).start(); 19 | const updatedAssistant = await updateAssistant(assistant.id, config); 20 | spinner.stop(); 21 | echo(""); 22 | echo(chalk.bold.green("Successfully updated assistant")); 23 | renderAssistant(updatedAssistant); 24 | }; 25 | -------------------------------------------------------------------------------- /src/cli/entry.cli.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { appAction } from "./app.cli.js"; 3 | import { listVectorStoresAction } from "./vector-store/list-vector-stores.cli.js"; 4 | import { createVectorStoreAction } from "./vector-store/create-vector-store.cli.js"; 5 | import { deleteVectorStoreAction } from "./vector-store/delete-vector-store.cli.js"; 6 | import { updateVectorStoreAction } from "./vector-store/update-vector-store.cli.js"; 7 | import { syncVectorStoreCli } from "./vector-store/sync-vector-store.cli.js"; 8 | import { listAssistantsAction } from "./assistant/list-assistants.cli.js"; 9 | import { createAssistantAction } from "./assistant/create-assistant.cli.js"; 10 | import { updateAssistantAction } from "./assistant/update-assistant.cli.js"; 11 | import { deleteAssistantAction } from "./assistant/delete-assistant.cli.js"; 12 | import { countTokensAction } from "./helpers/count-tokens.cli.js"; 13 | 14 | const program = new Command(); 15 | program 16 | .name("ai") 17 | .description("Use your OpenAI assistant from the command line") 18 | .version("2.0.0"); 19 | 20 | program 21 | .command("chat", { isDefault: true, hidden: true }) 22 | .argument("[args...]") 23 | .description("Start a chat with an assistant") 24 | .action(async (args) => { 25 | if (args.length > 0) { 26 | program.help(); 27 | return; 28 | } 29 | 30 | await appAction(); 31 | }); 32 | 33 | const utilsCommand = program 34 | .command("utils") 35 | .alias("u") 36 | .description("Useful utilities"); 37 | 38 | utilsCommand 39 | .command("count-tokens") 40 | .alias("ct") 41 | .description("Count tokens in a project") 42 | .argument("[globs]", "Globs to count tokens from") 43 | .action(countTokensAction); 44 | 45 | const assistantsCommand = program 46 | .command("assistants") 47 | .alias("ass") 48 | .alias("a") 49 | .description("Manage your assistants"); 50 | 51 | assistantsCommand 52 | .command("list") 53 | .alias("ls") 54 | .allowExcessArguments(false) 55 | .description("List all assistants") 56 | .action(listAssistantsAction); 57 | 58 | assistantsCommand 59 | .command("create") 60 | .alias("new") 61 | .alias("add") 62 | .allowExcessArguments(false) 63 | .description("Create a new assistant") 64 | .action(createAssistantAction); 65 | 66 | assistantsCommand 67 | .command("delete") 68 | .alias("rm") 69 | .alias("remove") 70 | .alias("del") 71 | .argument("[id]", "The id of the assistant to delete") 72 | .allowExcessArguments(false) 73 | .description("Delete a assistant") 74 | .action(deleteAssistantAction); 75 | 76 | assistantsCommand 77 | .command("update") 78 | .alias("edit") 79 | .alias("e") 80 | .allowExcessArguments(false) 81 | .description("Update a assistant") 82 | .action(updateAssistantAction); 83 | 84 | const storesCommand = program 85 | .command("vector-stores") 86 | .alias("vs") 87 | .description("Manage your vector stores"); 88 | 89 | storesCommand 90 | .command("list") 91 | .alias("ls") 92 | .allowExcessArguments(false) 93 | .description("List all vector stores") 94 | .action(listVectorStoresAction); 95 | 96 | storesCommand 97 | .command("create") 98 | .alias("new") 99 | .alias("add") 100 | .allowExcessArguments(false) 101 | .description("Create a new vector store") 102 | .action(createVectorStoreAction); 103 | 104 | storesCommand 105 | .command("delete") 106 | .alias("rm") 107 | .alias("remove") 108 | .alias("del") 109 | .argument("[id]", "The id of the vector store to delete") 110 | .allowExcessArguments(false) 111 | .description("Delete a vector store") 112 | .action(deleteVectorStoreAction); 113 | 114 | storesCommand 115 | .command("update") 116 | .alias("edit") 117 | .alias("e") 118 | .allowExcessArguments(false) 119 | .description("Update a vector store") 120 | .action(updateVectorStoreAction); 121 | 122 | storesCommand 123 | .command("sync") 124 | .allowExcessArguments(false) 125 | .description("Sync a vector store") 126 | .action(syncVectorStoreCli); 127 | 128 | program.parse(); 129 | -------------------------------------------------------------------------------- /src/cli/helpers/count-tokens.cli.ts: -------------------------------------------------------------------------------- 1 | import ora from "ora"; 2 | import { countGlobToken } from "../../utils/tokens.utils.js"; 3 | 4 | export const countTokensAction = async (globFilters?: string) => { 5 | const spinner = ora({ 6 | text: "Counting tokens", 7 | color: "blue", 8 | }).start(); 9 | 10 | const res = await countGlobToken(globFilters); 11 | 12 | spinner.stopAndPersist({ 13 | text: `Found ${res.tokensCount} tokens accross ${res.filesCount} files (OpenAI tokens)`, 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/cli/md.utils.ts: -------------------------------------------------------------------------------- 1 | import { marked } from "marked"; 2 | import { markedTerminal } from "marked-terminal"; 3 | 4 | // @ts-ignore 5 | marked.use(markedTerminal({}, {})); 6 | 7 | export const mdToTerminal = (markdown: string) => { 8 | return (marked(markdown) as string).trim(); 9 | }; 10 | -------------------------------------------------------------------------------- /src/cli/vector-store/create-vector-store.cli.ts: -------------------------------------------------------------------------------- 1 | import ora from "ora"; 2 | import { echo } from "zx"; 3 | import { input, select } from "@inquirer/prompts"; 4 | import { 5 | StoreConfigInput, 6 | createVectorStore, 7 | } from "../../openai/vector-store.client.js"; 8 | import { renderStore } from "./vector-store.utils.js"; 9 | 10 | export async function promptVectorStoreConfig( 11 | defaultValues?: StoreConfigInput, 12 | ): Promise { 13 | const name = await input({ 14 | message: "Vector Store Name", 15 | default: defaultValues?.name, 16 | }); 17 | 18 | const type = await select({ 19 | message: "Vector Store Type", 20 | default: defaultValues?.metadata?.syncConfig?.type, 21 | choices: [ 22 | { 23 | name: "Sitemap", 24 | value: "sitemap", 25 | }, 26 | { 27 | name: "Links from URL", 28 | value: "url_links", 29 | }, 30 | { 31 | name: "Unmanaged", 32 | value: "unmanaged", 33 | }, 34 | ], 35 | }); 36 | 37 | if (type === "unmanaged") return { name, metadata: { syncConfig: { type } } }; 38 | 39 | if (type === "sitemap") { 40 | const url = await input({ 41 | message: "Sitemap URL", 42 | // @ts-expect-error 43 | default: defaultValues?.metadata?.syncConfig?.url, 44 | validate: (value) => 45 | (value.startsWith("http") && value.endsWith(".xml")) || 46 | "URL must be match http(s)://**/*.xml", 47 | }); 48 | const filter = await input({ 49 | message: "Sitemap Filter", 50 | // @ts-expect-error 51 | default: defaultValues?.metadata?.syncConfig?.filter, 52 | validate: (value) => { 53 | if (!value) return true; 54 | try { 55 | new RegExp(value); 56 | return true; 57 | } catch (e) { 58 | return "Invalid RegExp"; 59 | } 60 | }, 61 | }); 62 | return { 63 | name, 64 | metadata: { 65 | syncConfig: { 66 | type, 67 | version: "1", 68 | url, 69 | filter: filter ? filter : undefined, 70 | }, 71 | }, 72 | }; 73 | } 74 | 75 | if (type === "url_links") { 76 | const url = await input({ 77 | message: "Base URL", 78 | // @ts-expect-error 79 | default: defaultValues?.metadata?.syncConfig?.url, 80 | }); 81 | const filter = await input({ 82 | message: "Url filter", 83 | // @ts-expect-error 84 | default: defaultValues?.metadata?.syncConfig?.filter, 85 | validate: (value) => { 86 | if (!value) return true; 87 | try { 88 | new RegExp(value); 89 | return true; 90 | } catch (e) { 91 | return "Invalid RegExp"; 92 | } 93 | }, 94 | }); 95 | return { 96 | name, 97 | metadata: { 98 | syncConfig: { 99 | type, 100 | version: "1", 101 | url, 102 | filter: filter ? filter : undefined, 103 | }, 104 | }, 105 | }; 106 | } 107 | 108 | throw new Error("Unhandled type"); 109 | } 110 | 111 | export const createVectorStoreAction = async () => { 112 | const config = await promptVectorStoreConfig(); 113 | const spinner = ora({ 114 | text: "Creating vector store", 115 | color: "blue", 116 | }).start(); 117 | const store = await createVectorStore(config); 118 | spinner.stopAndPersist({ 119 | text: "Store created", 120 | }); 121 | echo(""); 122 | renderStore(store); 123 | }; 124 | -------------------------------------------------------------------------------- /src/cli/vector-store/delete-vector-store.cli.ts: -------------------------------------------------------------------------------- 1 | import ora from "ora"; 2 | import { deleteVectorStore } from "../../openai/vector-store.client.js"; 3 | import { promptVectorStoreSelection } from "./vector-store.utils.js"; 4 | 5 | export const deleteVectorStoreAction = async (args?: string) => { 6 | if (args) { 7 | const spinner = ora({ 8 | text: `Deleting vector store ${args}`, 9 | color: "blue", 10 | }).start(); 11 | await deleteVectorStore(args); 12 | spinner.stopAndPersist({ 13 | symbol: "🗑", 14 | text: "Store deleted", 15 | }); 16 | return; 17 | } 18 | 19 | const answer = await promptVectorStoreSelection({ 20 | message: "Which vector stores do you want to delete?", 21 | multiple: true, 22 | }); 23 | 24 | const spinner = ora({ 25 | text: `Deleting ${answer.length} vector stores`, 26 | color: "blue", 27 | }).start(); 28 | 29 | await Promise.all(answer.map((a) => deleteVectorStore(a.id))); 30 | 31 | spinner.stopAndPersist({ 32 | symbol: "🗑", 33 | text: `${answer.length} vector stores deleted`, 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/cli/vector-store/list-vector-stores.cli.ts: -------------------------------------------------------------------------------- 1 | import { chalk, echo } from "zx"; 2 | import { getVectorStores } from "../../openai/vector-store.client.js"; 3 | import { renderStore } from "./vector-store.utils.js"; 4 | 5 | export const listVectorStoresAction = async () => { 6 | const storesIterator = getVectorStores(); 7 | let storesCount = 0; 8 | echo(chalk.bgBlue.bold(" Vector stores: ")); 9 | for await (const store of storesIterator) { 10 | echo(""); 11 | renderStore(store); 12 | storesCount++; 13 | } 14 | if (storesCount === 0) echo(chalk.yellow("No vector stores found")); 15 | }; 16 | -------------------------------------------------------------------------------- /src/cli/vector-store/sync-vector-store.cli.ts: -------------------------------------------------------------------------------- 1 | import ora from "ora"; 2 | import { chalk, echo } from "zx"; 3 | import { ParsedVectorStore } from "../../openai/vector-store.client.js"; 4 | import { deleteStoreFilesBeforeDate } from "../../sync/sync-cleanup.js"; 5 | import { uploadUrlLinksPages } from "../../sync/sync-page-urls.js"; 6 | import { uploadSitemapPages } from "../../sync/sync-site-map.js"; 7 | import { promptVectorStoreSelection } from "./vector-store.utils.js"; 8 | 9 | export const syncVectorStoreCli = async () => { 10 | const store = await promptVectorStoreSelection({ 11 | message: "Select a vector store to sync", 12 | multiple: false, 13 | excludeUnmanaged: true, 14 | }); 15 | 16 | if (store.syncConfig.type === "unmanaged") { 17 | echo(chalk.yellow("Unmanaged vector store, skipping sync")); 18 | return; 19 | } 20 | 21 | if (store.status === "in_progress") { 22 | echo(chalk.yellow("Vector store is in progress, wait before syncing")); 23 | return; 24 | } 25 | 26 | const timestamp = Math.ceil(Date.now() / 1000); 27 | 28 | let spinner = ora({ 29 | text: `Syncing ${store.name}`, 30 | }).start(); 31 | 32 | if ( 33 | store.syncConfig.type === "sitemap" || 34 | store.syncConfig.type === "url_links" 35 | ) { 36 | // Sync 37 | let counter = 0; 38 | const htmlFilesIterator = 39 | store.syncConfig.type === "sitemap" 40 | ? uploadSitemapPages(store.id, store.syncConfig) 41 | : uploadUrlLinksPages(store.id, store.syncConfig); 42 | for await (const url of htmlFilesIterator) { 43 | counter++; 44 | spinner.text = `Syncing ${store.name} - ${counter} - ${url}`; 45 | } 46 | spinner.succeed(`Synced ${store.name} - ${counter} pages`); 47 | 48 | await cleanupStore(store, timestamp); 49 | } 50 | }; 51 | 52 | async function cleanupStore(store: ParsedVectorStore, timestamp: number) { 53 | // Cleanup 54 | const spinner = ora({ 55 | text: `Cleaning up ${store.name}`, 56 | }).start(); 57 | const cleanupIterator = deleteStoreFilesBeforeDate(store.id, timestamp); 58 | for await (const file of cleanupIterator) { 59 | spinner.text = `Cleaning up ${store.name} - ${file.id}`; 60 | } 61 | spinner.succeed(`Cleaned up ${store.name}`); 62 | } 63 | -------------------------------------------------------------------------------- /src/cli/vector-store/update-vector-store.cli.ts: -------------------------------------------------------------------------------- 1 | import ora from "ora"; 2 | import { updateVectorStore } from "../../openai/vector-store.client.js"; 3 | import { promptVectorStoreConfig } from "./create-vector-store.cli.js"; 4 | import { promptVectorStoreSelection } from "./vector-store.utils.js"; 5 | 6 | export const updateVectorStoreAction = async () => { 7 | const store = await promptVectorStoreSelection({ 8 | message: "Which vector store do you want to update?", 9 | multiple: false, 10 | }); 11 | 12 | const config = await promptVectorStoreConfig({ 13 | name: store.name, 14 | metadata: { 15 | syncConfig: store.syncConfig, 16 | }, 17 | }); 18 | 19 | const spinner = ora({ 20 | text: "Updating vector store", 21 | color: "blue", 22 | }).start(); 23 | await updateVectorStore(store.id, config); 24 | spinner.succeed("Vector store updated"); 25 | }; 26 | -------------------------------------------------------------------------------- /src/cli/vector-store/vector-store.utils.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { select as selectPro } from "inquirer-select-pro"; 3 | import { asyncToArray } from "iter-tools"; 4 | import ora from "ora"; 5 | import { echo } from "zx"; 6 | import prettyBytes from "pretty-bytes"; 7 | import { 8 | ParsedVectorStore, 9 | getVectorStores, 10 | } from "../../openai/vector-store.client.js"; 11 | 12 | export const renderStatus = (status: ParsedVectorStore["status"]) => { 13 | switch (status) { 14 | case "expired": 15 | return chalk.red("expired"); 16 | case "in_progress": 17 | return chalk.yellow("syncing"); 18 | case "completed": 19 | return chalk.green("synced"); 20 | } 21 | }; 22 | 23 | export const renderSyncType = ( 24 | syncType: ParsedVectorStore["syncConfig"]["type"], 25 | ) => { 26 | switch (syncType) { 27 | case "unmanaged": 28 | return chalk.yellow("unmanaged"); 29 | case "sitemap": 30 | return chalk.magenta("sitemap sync"); 31 | case "url_links": 32 | return chalk.magenta("links sync"); 33 | default: 34 | return chalk.red(""); 35 | } 36 | }; 37 | 38 | export const renderStore = (store: ParsedVectorStore) => { 39 | echo(`${chalk.bold(store.name ?? "")} (${store.id})`); 40 | echo(" " + chalk.underline(store.playgroundUrl)); 41 | echo( 42 | chalk.blue( 43 | ` ${renderStatus(store.status)} - ${store.file_counts.total} files / ${prettyBytes(store.usage_bytes)} - ${renderSyncType(store.syncConfig.type)}`, 44 | ), 45 | ); 46 | }; 47 | 48 | export async function promptVectorStoreSelection(config?: { 49 | message?: string; 50 | multiple?: false; 51 | excludeUnmanaged?: boolean; 52 | defaultSelected?: string[]; 53 | }): Promise; 54 | export async function promptVectorStoreSelection(config?: { 55 | message?: string; 56 | multiple: true; 57 | excludeUnmanaged?: boolean; 58 | defaultSelectedIds?: string[]; 59 | }): Promise; 60 | export async function promptVectorStoreSelection(config?: { 61 | message?: string; 62 | multiple?: boolean; 63 | excludeUnmanaged?: boolean; 64 | defaultSelectedIds?: string[]; 65 | }) { 66 | let spinner = ora({ 67 | text: "Fetching all vector stores", 68 | color: "blue", 69 | }).start(); 70 | let stores = await asyncToArray(getVectorStores()); 71 | spinner.stop(); 72 | 73 | if (config?.excludeUnmanaged) 74 | stores = stores.filter((store) => store.syncConfig.type !== "unmanaged"); 75 | 76 | const preSelectedSet = new Set(config?.defaultSelectedIds ?? []); 77 | const defaultValues = config?.multiple 78 | ? stores.filter((store) => preSelectedSet.has(store.id)) 79 | : undefined; 80 | 81 | const answer = await selectPro({ 82 | message: config?.message ?? "Which vector store do you want to use?", 83 | multiple: config?.multiple ?? false, 84 | validate: (input) => input !== null, 85 | defaultValue: defaultValues, 86 | equals: (a, b) => a.id === b.id, 87 | options: (input) => 88 | stores 89 | .filter( 90 | (store) => 91 | !input || 92 | (store.name && 93 | store.name.toLowerCase().includes(input.toLowerCase())) || 94 | store.id.toLowerCase().includes(input.toLowerCase()), 95 | ) 96 | .map((s) => ({ 97 | value: s, 98 | name: `${s.name ?? chalk.italic("")} (${s.id}) | ${s.file_counts.total} files / ${prettyBytes(s.usage_bytes)}`, 99 | })), 100 | }); 101 | 102 | return answer; 103 | } 104 | -------------------------------------------------------------------------------- /src/openai/assistant.client.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { openaiClient } from "./openai.client.js"; 3 | import { PromiseReturnType } from "../utils/ts.utils.js"; 4 | import { Assistant, AssistantTool } from "openai/resources/beta/assistants.mjs"; 5 | import { getOpenAITool } from "./tool.client.js"; 6 | 7 | const metadataSchema = z.object({}).optional().default({}); 8 | 9 | export type AssistantMetadata = z.infer; 10 | 11 | export const getAssistantPlaygroundUrl = (id: string) => 12 | `https://platform.openai.com/playground/assistants?assistant=${id}&mode=assistant`; 13 | 14 | export const parseAssistant = (assistant: Assistant) => { 15 | return { 16 | ...assistant, 17 | playgroundUrl: getAssistantPlaygroundUrl(assistant.id), 18 | isCodeInterpreterEnabled: assistant.tools.some( 19 | (tool) => tool.type === "code_interpreter", 20 | ), 21 | isFileSearchEnabled: assistant.tools.some( 22 | (tool) => tool.type === "file_search", 23 | ), 24 | respondWithJson: 25 | assistant.response_format && 26 | typeof assistant.response_format !== "string" && 27 | assistant.response_format.type === "json_object", 28 | toolNames: assistant.tools 29 | .filter( 30 | (tool): tool is Extract => 31 | tool.type === "function", 32 | ) 33 | .map((tool) => tool.function.name), 34 | metadata: metadataSchema.parse(assistant.metadata), 35 | }; 36 | }; 37 | 38 | export type ParsedAssistant = ReturnType; 39 | 40 | export async function* getAssistants() { 41 | type ListRes = PromiseReturnType; 42 | let res: ListRes = await openaiClient.beta.assistants.list(); 43 | while (true) { 44 | yield* res.data.map(parseAssistant); 45 | if (!res.hasNextPage()) return; 46 | res = await res.getNextPage(); 47 | } 48 | } 49 | 50 | export async function getAssistant(id: string) { 51 | const assistant = await openaiClient.beta.assistants.retrieve(id); 52 | return parseAssistant(assistant); 53 | } 54 | 55 | export const assistantConfigSchema = z.object({ 56 | name: z.string().min(1), 57 | description: z.string().optional(), 58 | model: z.string().min(1), 59 | temperature: z.coerce.number().gte(0).lte(1), 60 | instructions: z.string().optional(), 61 | metadata: metadataSchema, 62 | isCodeInterpreterEnabled: z.boolean().optional().default(false), 63 | isFileSearchEnabled: z.boolean().optional().default(false), 64 | respondWithJson: z.boolean().optional().default(false), 65 | toolNames: z.array(z.string()).optional().default([]), 66 | vectorStoreIds: z.array(z.string()).optional().default([]), 67 | }); 68 | 69 | export type AssistantConfigInput = z.input; 70 | export type AssistantConfig = z.output; 71 | 72 | async function getToolsConfig(config: AssistantConfig) { 73 | const codeInterpreter = config.isCodeInterpreterEnabled 74 | ? ([{ type: "code_interpreter" }] as const) 75 | : []; 76 | 77 | const fileSearch = config.isFileSearchEnabled 78 | ? ([{ type: "file_search" }] as const) 79 | : []; 80 | 81 | const tools = await Promise.all(config.toolNames.map(getOpenAITool)); 82 | 83 | return [...codeInterpreter, ...fileSearch, ...tools]; 84 | } 85 | 86 | export async function createAssistant(_config: AssistantConfigInput) { 87 | const config = assistantConfigSchema.parse(_config); 88 | 89 | const assistant = await openaiClient.beta.assistants.create({ 90 | name: config.name, 91 | model: config.model, 92 | description: config.description, 93 | temperature: config.temperature, 94 | instructions: config.instructions, 95 | metadata: config.metadata, 96 | tools: await getToolsConfig(config), 97 | }); 98 | 99 | return parseAssistant(assistant); 100 | } 101 | 102 | export async function updateAssistant( 103 | id: string, 104 | _config: AssistantConfigInput, 105 | ) { 106 | const config = assistantConfigSchema.parse(_config); 107 | 108 | const assistant = await openaiClient.beta.assistants.update(id, { 109 | name: config.name, 110 | description: config.description, 111 | model: config.model, 112 | temperature: config.temperature, 113 | instructions: config.instructions, 114 | metadata: config.metadata, 115 | tool_resources: { 116 | file_search: { 117 | vector_store_ids: config.vectorStoreIds, 118 | }, 119 | }, 120 | response_format: config.respondWithJson 121 | ? { type: "json_object" } 122 | : { type: "text" }, 123 | tools: await getToolsConfig(config), 124 | }); 125 | 126 | return parseAssistant(assistant); 127 | } 128 | 129 | export async function deleteAssistant(id: string) { 130 | await openaiClient.beta.assistants.del(id); 131 | } 132 | -------------------------------------------------------------------------------- /src/openai/openai.client.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | 3 | export const openaiClient = new OpenAI({ 4 | apiKey: process.env.OPENAI_API_KEY, 5 | }); 6 | 7 | export const MODELS = [ 8 | "gpt-4o", 9 | "gpt-4-turbo", 10 | "gpt-4", 11 | "gpt-3.5-turbo", 12 | ] as const; 13 | -------------------------------------------------------------------------------- /src/openai/thread.client.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | import { Subject } from "rxjs"; 3 | import { throwOnUnhandled } from "../utils/ts.utils.js"; 4 | import { RequiredActionFunctionToolCall } from "openai/resources/beta/threads/index.mjs"; 5 | import { ParsedAssistant } from "./assistant.client.js"; 6 | import { ToolRunnerOutput, executeToolCalls } from "./tool.client.js"; 7 | 8 | type PromiseValue = T extends Promise ? U : never; 9 | 10 | type UserInput = 11 | | { 12 | type: "message"; 13 | content: string; 14 | assistant?: ParsedAssistant; 15 | } 16 | | { type: "abort" }; 17 | 18 | type AssistantResponse = 19 | | { type: "responseStart" } // Thinking 20 | | { type: "stepStart" } 21 | // 22 | | { type: "functionCallStart"; id: string; name: string } 23 | | { type: "functionCallDelta"; argsDelta: string } 24 | | { type: "functionCallDone"; id: string; output: string } 25 | | { 26 | type: "functionCallExecuted"; 27 | toolId: string; 28 | toolName: string; 29 | args: any; 30 | output: { success: boolean }; 31 | } 32 | // 33 | | { type: "textStart" } 34 | | { type: "textDelta"; content: string } 35 | | { type: "textEnd" } 36 | // 37 | | { type: "stepEnd" } 38 | | { type: "responseEnd" } 39 | // Errors 40 | | { type: "responseAbort" } 41 | | { type: "error"; message: string } 42 | // Tokens 43 | | { 44 | type: "tokensUsage"; 45 | tokens: { 46 | prompt?: number; 47 | completion?: number; 48 | }; 49 | }; 50 | 51 | export async function createThread( 52 | openAIClient: OpenAI, 53 | assistant: ParsedAssistant, 54 | ) { 55 | const userInput$ = new Subject(); 56 | const assistantResponses$ = new Subject(); 57 | const openAIThread = await openAIClient.beta.threads.create({}); 58 | 59 | let stream: ReturnType | null = 60 | null; 61 | let toolCallsPromises: ToolRunnerOutput = []; 62 | 63 | type OpenAIStream = ReturnType; 64 | 65 | const sendMessage = async (text: string) => { 66 | userInput$.next({ type: "message", content: text }); 67 | }; 68 | 69 | const abortCompletion = () => { 70 | userInput$.next({ type: "abort" }); 71 | }; 72 | 73 | const addStreamListeners = ( 74 | stream: ReturnType, 75 | ) => { 76 | stream.on("abort", () => { 77 | assistantResponses$.next({ type: "responseAbort" }); 78 | }); 79 | stream.on("connect", () => { 80 | toolCallsPromises = []; 81 | assistantResponses$.next({ type: "responseStart" }); 82 | }); 83 | stream.on("end", () => { 84 | if (toolCallsPromises.length === 0) { 85 | assistantResponses$.next({ type: "responseEnd" }); 86 | return; 87 | } 88 | 89 | Promise.all(toolCallsPromises).then((toolCallsOutputs) => { 90 | for (let toolCallOutput of toolCallsOutputs) { 91 | assistantResponses$.next({ 92 | type: "functionCallExecuted", 93 | ...toolCallOutput, 94 | }); 95 | } 96 | 97 | stream = openAIClient.beta.threads.runs.submitToolOutputsStream( 98 | openAIThread.id, 99 | stream.currentRun()!.id, 100 | { 101 | tool_outputs: toolCallsOutputs.map((response) => ({ 102 | tool_call_id: response.toolId, 103 | output: JSON.stringify(response.output), 104 | })), 105 | }, 106 | ); 107 | addStreamListeners(stream); 108 | addToolsHandler(stream); 109 | }); 110 | }); 111 | stream.on("error", (data) => { 112 | assistantResponses$.next({ type: "error", message: data.message }); 113 | }); 114 | stream.on("imageFileDone", (data) => { 115 | // TODO: An image file has been rendered 116 | }); 117 | stream.on("runStepCreated", (data) => { 118 | assistantResponses$.next({ type: "stepStart" }); 119 | }); 120 | stream.on("runStepDone", (data) => { 121 | assistantResponses$.next({ type: "stepEnd" }); 122 | assistantResponses$.next({ 123 | type: "tokensUsage", 124 | tokens: { 125 | prompt: data.usage?.prompt_tokens, 126 | completion: data.usage?.completion_tokens, 127 | }, 128 | }); 129 | }); 130 | stream.on("textCreated", (data) => { 131 | assistantResponses$.next({ type: "textStart" }); 132 | }); 133 | stream.on("textDelta", (data, snap) => { 134 | assistantResponses$.next({ 135 | type: "textDelta", 136 | content: data.value ?? "", 137 | }); 138 | }); 139 | stream.on("textDone", (data) => { 140 | assistantResponses$.next({ type: "textEnd" }); 141 | }); 142 | // TODO 143 | stream.on("toolCallCreated", (data) => { 144 | if (data.type === "function") { 145 | assistantResponses$.next({ 146 | type: "functionCallStart", 147 | id: data.id, 148 | name: data.function.name, 149 | }); 150 | return; 151 | } 152 | 153 | if (data.type === "file_search") { 154 | // TODO 155 | return; 156 | } 157 | 158 | if (data.type === "code_interpreter") { 159 | // TODO 160 | return; 161 | } 162 | 163 | return throwOnUnhandled(data, "Unknown tool call type"); 164 | }); 165 | stream.on("toolCallDelta", (data, snap) => { 166 | if (data.type === "function") { 167 | assistantResponses$.next({ 168 | type: "functionCallDelta", 169 | argsDelta: data.function?.arguments ?? "", 170 | }); 171 | return; 172 | } 173 | 174 | if (data.type === "file_search") { 175 | // TODO 176 | return; 177 | } 178 | 179 | if (data.type === "code_interpreter") { 180 | // TODO 181 | return; 182 | } 183 | 184 | return throwOnUnhandled(data, "Unknown tool call type"); 185 | }); 186 | stream.on("toolCallDone", (data) => { 187 | if (data.type === "function") { 188 | assistantResponses$.next({ 189 | type: "functionCallDone", 190 | id: data.id, 191 | output: data.function?.output ?? "", 192 | }); 193 | return; 194 | } 195 | 196 | if (data.type === "file_search") { 197 | // TODO 198 | return; 199 | } 200 | 201 | if (data.type === "code_interpreter") { 202 | // TODO 203 | return; 204 | } 205 | 206 | return throwOnUnhandled(data, "Unknown tool call type"); 207 | }); 208 | }; 209 | 210 | const addToolsHandler = (stream: OpenAIStream) => { 211 | stream.on("event", async ({ data, event }) => { 212 | if ( 213 | event === "thread.run.requires_action" && 214 | data.required_action?.submit_tool_outputs.tool_calls 215 | ) { 216 | const toolCalls = 217 | data.required_action.submit_tool_outputs.tool_calls.filter( 218 | (tool): tool is RequiredActionFunctionToolCall => 219 | tool.type === "function", 220 | ); 221 | 222 | toolCallsPromises = executeToolCalls(toolCalls); 223 | } 224 | }); 225 | }; 226 | 227 | userInput$.subscribe((event) => { 228 | if (event.type === "abort") { 229 | stream?.abort(); 230 | return; 231 | } 232 | 233 | if (event.type === "message") { 234 | if (event.assistant) assistant = event.assistant; 235 | stream = openAIClient.beta.threads.runs.stream(openAIThread.id, { 236 | assistant_id: assistant.id, 237 | additional_messages: [ 238 | { 239 | role: "user", 240 | content: event.content, 241 | }, 242 | ], 243 | }); 244 | addStreamListeners(stream); 245 | addToolsHandler(stream); 246 | return; 247 | } 248 | 249 | throwOnUnhandled(event, "Unsupported event type"); 250 | }); 251 | 252 | return { 253 | openAIThread, 254 | assistantResponses$, 255 | sendMessage, 256 | abortCompletion, 257 | }; 258 | } 259 | 260 | export type Thread = PromiseValue>; 261 | -------------------------------------------------------------------------------- /src/openai/tool.client.ts: -------------------------------------------------------------------------------- 1 | import { glob } from "zx"; 2 | import { ROOTDIR } from "../utils/node.utils.js"; 3 | import path from "path"; 4 | import { 5 | ErrorToolOutput, 6 | Tool, 7 | ToolOutput, 8 | toOpenAiTool, 9 | } from "./tool.utils.js"; 10 | import { z } from "zod"; 11 | import { RequiredActionFunctionToolCall } from "openai/resources/beta/threads/index.mjs"; 12 | import { PromiseReturnType, PromiseValue } from "../utils/ts.utils.js"; 13 | 14 | class ToolNotFoundException extends Error { 15 | constructor(toolName: string) { 16 | super(`Tool ${toolName} not found`); 17 | } 18 | } 19 | 20 | class ToolWithoutDefaultExportException extends Error { 21 | constructor(toolName: string) { 22 | super(`Tool ${toolName} does not export a default function`); 23 | } 24 | } 25 | 26 | export async function getOpenAITool(toolName: string) { 27 | const tool = await getTool(toolName); 28 | return toOpenAiTool(tool); 29 | } 30 | 31 | export async function* getToolsNames() { 32 | const files = await glob("src/tools/*.ts", { 33 | cwd: ROOTDIR, 34 | }); 35 | 36 | for await (const file of files) { 37 | yield path.basename(file, ".ts"); 38 | } 39 | } 40 | 41 | export async function getTool(toolName: string): Promise { 42 | const module = await import(`../tools/${toolName}.js`).catch((e) => { 43 | if (e instanceof Error && e.message.includes("Cannot find module")) { 44 | throw new ToolNotFoundException(toolName); 45 | } 46 | throw e; 47 | }); 48 | if (!module.default) throw new ToolWithoutDefaultExportException(toolName); 49 | return module.default as Tool; 50 | } 51 | 52 | const defaultErrorFormater = (error: any): ErrorToolOutput => { 53 | if (typeof error === "string") return { success: false as const, error }; 54 | if (error instanceof Error) 55 | return { success: false as const, error: error.message }; 56 | 57 | return { success: false as const, error: JSON.stringify(error) }; 58 | }; 59 | 60 | export async function executeTool( 61 | toolName: string, 62 | _args: any, 63 | ): Promise { 64 | try { 65 | const tool = await getTool(toolName); 66 | const args = tool.argsSchema.parse(_args); 67 | const res = await tool.call(args); 68 | return res; 69 | } catch (e) { 70 | if (e instanceof z.ZodError) { 71 | return { 72 | success: false, 73 | error: `Invalid arguments for tool ${toolName}`, 74 | output: e.format(), 75 | }; 76 | } 77 | if (e instanceof ToolNotFoundException) { 78 | return { success: false, error: `Tool ${toolName} not found` }; 79 | } 80 | if (e instanceof ToolWithoutDefaultExportException) { 81 | return { 82 | success: false, 83 | error: `Tool ${toolName} does not export a default function`, 84 | }; 85 | } 86 | if (e instanceof Error) { 87 | return { success: false, error: e.message }; 88 | } 89 | return defaultErrorFormater(e); 90 | } 91 | } 92 | 93 | type FunctionOnlyToolCall = RequiredActionFunctionToolCall & { 94 | type: "function"; 95 | }; 96 | 97 | export function executeToolCalls(calls: Array) { 98 | return calls 99 | .filter((call): call is FunctionOnlyToolCall => call.type === "function") 100 | .map((call) => ({ 101 | id: call.id, 102 | name: call.function.name, 103 | args: JSON.parse(call.function.arguments), 104 | })) 105 | .map((call) => 106 | executeTool(call.name, call.args).then((output) => ({ 107 | toolId: call.id, 108 | toolName: call.name, 109 | args: call.args, 110 | output, 111 | })), 112 | ); 113 | } 114 | 115 | export type ToolRunnerOutput = ReturnType; 116 | -------------------------------------------------------------------------------- /src/openai/tool.utils.ts: -------------------------------------------------------------------------------- 1 | import { AssistantTool } from "openai/resources/beta/assistants.mjs"; 2 | import { z } from "zod"; 3 | import { zodToJsonSchema } from "zod-to-json-schema"; 4 | 5 | export type SuccessToolOutput = { 6 | success: true; 7 | output: any; 8 | error?: undefined; 9 | }; 10 | 11 | export type ErrorToolOutput = { 12 | success: false; 13 | error: string; 14 | output?: any; 15 | }; 16 | 17 | export type ToolOutput = SuccessToolOutput | ErrorToolOutput; 18 | 19 | export interface Tool { 20 | name: string; 21 | description: string; 22 | argsSchema: z.ZodSchema; 23 | call: (args: Args) => Promise; 24 | } 25 | 26 | export const toOpenAiTool = (tool: Tool): AssistantTool => { 27 | const parameters = zodToJsonSchema(tool.argsSchema); 28 | delete parameters.$schema; 29 | return { 30 | type: "function" as const, 31 | function: { 32 | name: tool.name, 33 | description: tool.description, 34 | parameters, 35 | }, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /src/openai/vector-store-files.client.ts: -------------------------------------------------------------------------------- 1 | import { toFile } from "openai"; 2 | import { openaiClient } from "./openai.client.js"; 3 | 4 | export async function* getVectorStoreFiles(storeId: string) { 5 | let res = await openaiClient.beta.vectorStores.files.list(storeId); 6 | 7 | do { 8 | yield* res.data; 9 | if (!res.hasNextPage()) break; 10 | res = await res.getNextPage(); 11 | } while (true); 12 | } 13 | 14 | export async function deleteVectorStoreFile(storeId: string, fileId: string) { 15 | await openaiClient.beta.vectorStores.files.del(storeId, fileId); 16 | } 17 | 18 | export async function uploadVectorStoreFile( 19 | storeId: string, 20 | fileName: string, 21 | file: ReadableStream | Blob, 22 | ) { 23 | const res = await openaiClient.beta.vectorStores.files.upload( 24 | storeId, 25 | await toFile(file, fileName), 26 | ); 27 | return res; 28 | } 29 | -------------------------------------------------------------------------------- /src/openai/vector-store.client.ts: -------------------------------------------------------------------------------- 1 | import { VectorStore as OAIVectorStore } from "openai/resources/beta/index.mjs"; 2 | import { z } from "zod"; 3 | import { openaiClient } from "./openai.client.js"; 4 | import { safeParseJson } from "../utils/json.utils.js"; 5 | 6 | const syncConfigUnmanagedSchema = z.object({ 7 | type: z.literal("unmanaged").optional().default("unmanaged"), 8 | version: z.literal("1").default("1"), 9 | }); 10 | 11 | const syncConfigSitemapSchema = z.object({ 12 | type: z.literal("sitemap"), 13 | version: z.literal("1").default("1"), 14 | url: z 15 | .string() 16 | .url() 17 | .refine((val) => val.endsWith(".xml"), "Invalid sitemap url"), 18 | filter: z 19 | .string() 20 | .optional() 21 | .superRefine((val, ctx) => { 22 | if (!val) return; 23 | try { 24 | new RegExp(val); 25 | return; 26 | } catch (e) { 27 | ctx.addIssue({ 28 | code: z.ZodIssueCode.custom, 29 | message: "Invalid RegExp", 30 | }); 31 | return false; 32 | } 33 | }), 34 | }); 35 | 36 | const syncConfigUrlLinksSchema = z.object({ 37 | type: z.literal("url_links"), 38 | version: z.literal("1").default("1"), 39 | url: z.string().url(), 40 | filter: z 41 | .string() 42 | .optional() 43 | .superRefine((val, ctx) => { 44 | if (!val) return; 45 | try { 46 | new RegExp(val); 47 | return; 48 | } catch (e) { 49 | ctx.addIssue({ 50 | code: z.ZodIssueCode.custom, 51 | message: "Invalid RegExp", 52 | }); 53 | return false; 54 | } 55 | }), 56 | }); 57 | 58 | const syncConfigSchema = z 59 | .discriminatedUnion("type", [ 60 | syncConfigUnmanagedSchema, 61 | syncConfigSitemapSchema, 62 | syncConfigUrlLinksSchema, 63 | ]) 64 | .optional() 65 | .default({ type: "unmanaged", version: "1" }); 66 | 67 | const storeMetadataSchema = z.object({ 68 | oai: z.preprocess( 69 | safeParseJson, 70 | z.object({ syncConfig: syncConfigSchema }).optional().default({}), 71 | ), 72 | }); 73 | 74 | const storeConfigSchema = z.object({ 75 | name: z.string(), 76 | metadata: z.object({ 77 | syncConfig: syncConfigSchema, 78 | }), 79 | }); 80 | 81 | export type StoreConfigInput = z.input; 82 | 83 | export const getVectorStorePlaygroundUrl = (storeId: string) => 84 | `https://platform.openai.com/storage/vector_stores/${storeId}`; 85 | 86 | const parseVectorStore = (store: OAIVectorStore) => { 87 | const metadata = storeMetadataSchema.parse(store.metadata); 88 | 89 | return { 90 | ...store, 91 | syncConfig: metadata.oai.syncConfig, 92 | playgroundUrl: getVectorStorePlaygroundUrl(store.id), 93 | }; 94 | }; 95 | 96 | export type ParsedVectorStore = ReturnType; 97 | 98 | export async function* getVectorStores() { 99 | let res = await openaiClient.beta.vectorStores.list(); 100 | 101 | do { 102 | for (const store of res.data) { 103 | yield parseVectorStore(store); 104 | } 105 | if (!res.hasNextPage()) break; 106 | res = await res.getNextPage(); 107 | } while (true); 108 | } 109 | 110 | export async function getVectorStore(storeId: string) { 111 | const res = await openaiClient.beta.vectorStores.retrieve(storeId); 112 | return parseVectorStore(res); 113 | } 114 | 115 | export async function updateVectorStore(id: string, _config: StoreConfigInput) { 116 | const config = storeConfigSchema.parse(_config); 117 | const res = await openaiClient.beta.vectorStores.update(id, { 118 | ...config, 119 | metadata: { 120 | oai: JSON.stringify({ syncConfig: config.metadata.syncConfig }), 121 | }, 122 | }); 123 | return parseVectorStore(res); 124 | } 125 | 126 | export async function createVectorStore(_config: StoreConfigInput) { 127 | const config = storeConfigSchema.parse(_config); 128 | const res = await openaiClient.beta.vectorStores.create({ 129 | ...config, 130 | metadata: { 131 | oai: JSON.stringify({ syncConfig: config.metadata.syncConfig }), 132 | }, 133 | }); 134 | 135 | return parseVectorStore(res); 136 | } 137 | 138 | export async function deleteVectorStore(storeId: string) { 139 | await openaiClient.beta.vectorStores.del(storeId); 140 | } 141 | -------------------------------------------------------------------------------- /src/sync/sync-cleanup.ts: -------------------------------------------------------------------------------- 1 | import { 2 | deleteVectorStoreFile, 3 | getVectorStoreFiles, 4 | } from "../openai/vector-store-files.client.js"; 5 | 6 | export async function* deleteStoreFilesBeforeDate( 7 | storeId: string, 8 | timestamp: number, 9 | ) { 10 | // Cleanup 11 | const storeFilesIterator = getVectorStoreFiles(storeId); 12 | for await (const file of storeFilesIterator) { 13 | if (file.created_at < timestamp) continue; 14 | yield file; 15 | await deleteVectorStoreFile(storeId, file.id); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/sync/sync-page-urls.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio"; 2 | import wretch from "wretch"; 3 | import { regexFilter } from "../utils/regex.utils.js"; 4 | import { asyncFilter, asyncMap, pipe } from "iter-tools"; 5 | import { getUrlAsPage } from "./sync-urls.js"; 6 | import { uploadVectorStoreFile } from "../openai/vector-store-files.client.js"; 7 | 8 | async function* getUrlLinks(url: string) { 9 | const res = await wretch(url).get().text(); 10 | const parsedUrl = new URL(url); 11 | const $ = cheerio.load(res); 12 | const nodes = $("body").find("a"); 13 | 14 | const links = new Set(); 15 | for (const node of nodes) { 16 | const href = node.attribs?.href; 17 | if (!href) continue; 18 | const fullLink = href.startsWith("/") ? `${parsedUrl.origin}${href}` : href; 19 | if (links.has(fullLink)) continue; 20 | links.add(fullLink); 21 | if (fullLink.startsWith("http")) yield fullLink; 22 | } 23 | } 24 | 25 | export async function* getUrlLinksPages( 26 | sitemapUrl: string, 27 | urlFilter?: string, 28 | ) { 29 | const pathFilter = regexFilter(urlFilter); 30 | yield* pipe( 31 | getUrlLinks, 32 | asyncFilter(pathFilter), 33 | asyncMap(getUrlAsPage), 34 | asyncFilter((page) => page.html !== null), 35 | )(sitemapUrl); 36 | } 37 | 38 | export async function* uploadUrlLinksPages( 39 | storeId: string, 40 | { 41 | url, 42 | filter, 43 | }: { 44 | url: string; 45 | filter?: string; 46 | }, 47 | ) { 48 | const pagesIterator = getUrlLinksPages(url, filter); 49 | for await (const page of pagesIterator) { 50 | yield page.url; 51 | await uploadVectorStoreFile(storeId, page.url, page.html); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/sync/sync-site-map.ts: -------------------------------------------------------------------------------- 1 | import xml2js from "xml2js"; 2 | import { getUrl } from "../utils/web.utils.js"; 3 | import { asyncFilter, asyncMap, pipe } from "iter-tools"; 4 | import { regexFilter } from "../utils/regex.utils.js"; 5 | import { getUrlAsPage } from "./sync-urls.js"; 6 | import { uploadVectorStoreFile } from "../openai/vector-store-files.client.js"; 7 | 8 | async function* getSitemapUrls(sitemapUrl: string) { 9 | const sitemapXml = await getUrl(sitemapUrl); 10 | const sitemap = 11 | (await xml2js.parseStringPromise(sitemapXml)).urlset?.url ?? 12 | ([] as { loc: [string] }[]); 13 | return sitemap.map((page: { loc: [string] }) => page.loc[0]); 14 | } 15 | 16 | export async function* getSitemapPages(sitemapUrl: string, urlFilter?: string) { 17 | const pathFilter = regexFilter(urlFilter); 18 | yield* pipe( 19 | getSitemapUrls, 20 | asyncFilter(pathFilter), 21 | asyncMap(getUrlAsPage), 22 | asyncFilter((page) => page.html !== null), 23 | )(sitemapUrl); 24 | } 25 | 26 | export async function* uploadSitemapPages( 27 | storeId: string, 28 | { 29 | url, 30 | filter, 31 | }: { 32 | url: string; 33 | filter?: string; 34 | }, 35 | ) { 36 | const pagesIterator = getSitemapPages(url, filter); 37 | for await (const page of pagesIterator) { 38 | yield page.url; 39 | await uploadVectorStoreFile(storeId, page.url, page.html); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/sync/sync-urls.ts: -------------------------------------------------------------------------------- 1 | import { getArticleFromUrl } from "../utils/web.utils.js"; 2 | 3 | function urlToFileName(url: string) { 4 | return url.replace(/^https?:\/\//, "").replace(/\/$/, "/index") + ".html"; 5 | } 6 | 7 | export async function getUrlAsPage(url: string) { 8 | return { 9 | url: urlToFileName(url), 10 | html: await getUrlAsBlob(url), 11 | }; 12 | } 13 | 14 | const getUrlAsBlob = async (url: string) => { 15 | const res = await getArticleFromUrl(url); 16 | return new Blob([res.content], { type: "text/html" }); 17 | }; 18 | -------------------------------------------------------------------------------- /src/tools/appendToFile.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | import { z } from "zod"; 3 | import { Tool } from "../openai/tool.utils.js"; 4 | 5 | const argsSchema = z.object({ 6 | relativeFilePath: z 7 | .string() 8 | .describe("The path to the file relative to the project root"), 9 | content: z.string().describe("The content to append to the file"), 10 | }); 11 | 12 | type Args = z.input; 13 | 14 | export default { 15 | name: "appendToFile", 16 | description: "Appends content to the specified file", 17 | argsSchema, 18 | async call(args: Args) { 19 | await fs.appendFile(args.relativeFilePath, args.content); 20 | return { success: true, output: null }; 21 | }, 22 | } satisfies Tool; 23 | -------------------------------------------------------------------------------- /src/tools/commit.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { $ } from "zx"; 3 | import { Tool } from "../openai/tool.utils.js"; 4 | 5 | const argsSchema = z.object({ 6 | message: z.string(), 7 | }); 8 | 9 | type Args = z.input; 10 | 11 | export default { 12 | name: "commit", 13 | description: "Adds and commits all changes to the current git repository", 14 | argsSchema, 15 | async call(args: Args) { 16 | await $`git add . && git commit -m "${args.message}"`; 17 | return { success: true, output: null }; 18 | }, 19 | } satisfies Tool; 20 | -------------------------------------------------------------------------------- /src/tools/createDir.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { ensureDir } from "fs-extra"; 3 | import { Tool } from "../openai/tool.utils.js"; 4 | 5 | const argsSchema = z.object({ 6 | path: z.string(), 7 | }); 8 | 9 | type Args = z.input; 10 | 11 | export default { 12 | name: "createDir", 13 | description: 14 | "Creates a directory and its parent directories if they don't exist", 15 | argsSchema, 16 | async call(args: Args) { 17 | await ensureDir(args.path); 18 | return { success: true, output: null }; 19 | }, 20 | } satisfies Tool; 21 | -------------------------------------------------------------------------------- /src/tools/executeCommand.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { $ } from "zx"; 3 | import { Tool } from "../openai/tool.utils.js"; 4 | 5 | const argsSchema = z.object({ 6 | command: z.string(), 7 | cwd: z 8 | .string() 9 | .optional() 10 | .describe( 11 | "relative path to the current directory to execute the command from", 12 | ), 13 | }); 14 | 15 | type Args = z.input; 16 | 17 | export default { 18 | name: "executeCommand", 19 | description: "Executes a command in a bash terminal", 20 | argsSchema, 21 | async call(args: Args) { 22 | // Split space but keep quoted strings together 23 | const argsArray = args.command.match(/"[^"]+"|\S+/g) || []; 24 | const res = await $`${argsArray}`; 25 | 26 | return { 27 | success: true, 28 | output: res.stdout, 29 | }; 30 | }, 31 | } satisfies Tool; 32 | -------------------------------------------------------------------------------- /src/tools/fileDiff.ts: -------------------------------------------------------------------------------- 1 | import { readFile as fsReadFile } from "fs/promises"; 2 | import { z } from "zod"; 3 | import { Tool } from "../openai/tool.utils.js"; 4 | import { $ } from "zx"; 5 | 6 | const argsSchema = z.object({ 7 | relativeFilePath: z 8 | .string() 9 | .describe("The path to the file relative to the project root"), 10 | }); 11 | 12 | type Args = z.input; 13 | 14 | export default { 15 | name: "fileDiff", 16 | description: "Reads the current diffs of a file", 17 | argsSchema, 18 | async call(args: Args) { 19 | let diff = await $`git --no-pager diff ${args.relativeFilePath}`; 20 | 21 | return { 22 | success: true, 23 | output: diff, 24 | }; 25 | }, 26 | } satisfies Tool; 27 | -------------------------------------------------------------------------------- /src/tools/getUrlContent.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Tool } from "../openai/tool.utils.js"; 3 | import axios from "axios"; 4 | import { getArticleFromUrl } from "../utils/web.utils.js"; 5 | 6 | const argsSchema = z.object({ 7 | url: z.string(), 8 | }); 9 | 10 | type Args = z.input; 11 | 12 | export default { 13 | name: "getUrlContent", 14 | description: "Fetches the content of a URL", 15 | argsSchema, 16 | async call(args: Args) { 17 | const article = await getArticleFromUrl(args.url); 18 | 19 | return { 20 | success: true, 21 | output: article, 22 | }; 23 | }, 24 | } satisfies Tool; 25 | -------------------------------------------------------------------------------- /src/tools/ls.ts: -------------------------------------------------------------------------------- 1 | import { z, input } from "zod"; 2 | import { $ } from "zx"; 3 | import { Tool } from "../openai/tool.utils.js"; 4 | 5 | const argsSchema = z.object({ 6 | relativePath: z 7 | .string() 8 | .default(".") 9 | .describe("relative path to the directory"), 10 | }); 11 | 12 | type Args = z.input; 13 | 14 | export default { 15 | name: "ls", 16 | description: "List a directory's files", 17 | argsSchema, 18 | async call({ relativePath }: Args) { 19 | const res = 20 | await $`(git ls-files ${relativePath}; git ls-files -m ${relativePath}; git ls-files --others --exclude-standard ${relativePath}) | sort | uniq`; 21 | return { 22 | success: true, 23 | output: res.stdout.trim(), 24 | }; 25 | }, 26 | } satisfies Tool; 27 | -------------------------------------------------------------------------------- /src/tools/readFile.ts: -------------------------------------------------------------------------------- 1 | import { readFile as fsReadFile } from "fs/promises"; 2 | import { z } from "zod"; 3 | import { Tool } from "../openai/tool.utils.js"; 4 | 5 | const argsSchema = z.object({ 6 | relativeFilePath: z 7 | .string() 8 | .describe("The path to the file relative to the project root"), 9 | prefixWithLineNumbers: z 10 | .boolean() 11 | .optional() 12 | .describe("Prefix each line with its line number. Use it for git patches"), 13 | }); 14 | 15 | type Args = z.input; 16 | 17 | export default { 18 | name: "readFile", 19 | description: "Reads a file", 20 | argsSchema, 21 | async call(args: Args) { 22 | let fileContent = await fsReadFile(args.relativeFilePath, "utf-8"); 23 | 24 | if (args.prefixWithLineNumbers) { 25 | fileContent = fileContent 26 | .split("\n") 27 | .map((line, index) => `${index + 1}:${line}`) 28 | .join("\n"); 29 | } 30 | 31 | return { 32 | success: true, 33 | output: fileContent, 34 | }; 35 | }, 36 | } satisfies Tool; 37 | -------------------------------------------------------------------------------- /src/tools/writeFile.ts: -------------------------------------------------------------------------------- 1 | import { writeFile as fsWriteFile } from "fs/promises"; 2 | import { ensureDir } from "fs-extra"; 3 | import { dirname } from "path"; 4 | import { Tool } from "../openai/tool.utils.js"; 5 | import { z } from "zod"; 6 | 7 | const argsSchema = z.object({ 8 | relativeFilePath: z 9 | .string() 10 | .describe("The path to the file relative to the project root"), 11 | content: z.string().describe("The content to write to the file"), 12 | }); 13 | 14 | type Args = z.input; 15 | 16 | export default { 17 | name: "writeFile", 18 | description: "Write text to a file, replacing the current file's content", 19 | argsSchema, 20 | async call(args: Args) { 21 | await ensureDir(dirname(args.relativeFilePath)); 22 | await fsWriteFile(args.relativeFilePath, args.content); 23 | return { success: true, output: null }; 24 | }, 25 | } satisfies Tool; 26 | -------------------------------------------------------------------------------- /src/utils/cli.utils.ts: -------------------------------------------------------------------------------- 1 | import readline from "readline"; 2 | 3 | function deleteLastLinesFromTerminal(count: number) { 4 | if (count === 0) return; 5 | 6 | for (let i = 0; i < count; i++) { 7 | readline.clearLine(process.stdout, 0); 8 | readline.moveCursor(process.stdout, 0, -1); 9 | } 10 | readline.cursorTo(process.stdout, 0); 11 | } 12 | 13 | export function deleteLastTextFromTerminal(text: string) { 14 | const lines = text.split("\n"); 15 | deleteLastLinesFromTerminal(lines.length - 1); 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/fs.utils.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | import { join } from "path"; 3 | 4 | export function readFile(path: string, cwd?: string) { 5 | const fullPath = cwd ? join(cwd, path) : path; 6 | return fs.readFile(fullPath, "utf-8").catch((e) => { 7 | if (e.code === "ENOENT") throw new Error("File does not exist"); 8 | throw e; 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/json.utils.ts: -------------------------------------------------------------------------------- 1 | export const safeParseJson = (value: string | any): any => { 2 | if (typeof value === "string") { 3 | return JSON.parse(value); 4 | } 5 | return value; 6 | }; 7 | 8 | export const objectEntriesToJson = (object: Record) => { 9 | return Object.fromEntries( 10 | Object.entries(object).map(([key, value]) => [key, JSON.stringify(value)]), 11 | ); 12 | }; 13 | 14 | export const objectEntriesFromJson = (object: Record) => { 15 | return Object.fromEntries( 16 | Object.entries(object).map(([key, value]) => [key, safeParseJson(value)]), 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/utils/node.utils.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "url"; 2 | import { dirname, join } from "path"; 3 | 4 | export const toDirname = (url: string) => dirname(fileURLToPath(url)); 5 | 6 | export const ROOTDIR = join(toDirname(import.meta.url), "../.."); 7 | 8 | export const waitFor = (ms: number) => 9 | new Promise((resolve) => setTimeout(resolve, ms)); 10 | 11 | export const retry = async (fn: () => Promise, retries: number) => { 12 | for (let attempt = 1; attempt <= retries; attempt++) { 13 | try { 14 | return await fn(); 15 | } catch (error) { 16 | if (attempt === retries) { 17 | throw error; // Rethrow error if last attempt fails 18 | } 19 | return await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1 second before retrying 20 | } 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/regex.utils.ts: -------------------------------------------------------------------------------- 1 | export const regexFilter = (regex?: string | RegExp) => { 2 | if (!regex) return (input: string) => true; 3 | if (regex instanceof RegExp) return (input: string) => regex.test(input); 4 | 5 | const filterRegex = new RegExp(regex); 6 | return (input: string) => filterRegex.test(input); 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/tokens.utils.ts: -------------------------------------------------------------------------------- 1 | import { get_encoding } from "tiktoken"; 2 | import { readFile } from "./fs.utils.js"; 3 | import { glob } from "zx"; 4 | 5 | export function countTokens(text: string): number { 6 | const encoding = get_encoding("cl100k_base"); 7 | const length = encoding.encode(text).length; 8 | encoding.free(); 9 | return length; 10 | } 11 | 12 | export async function countFileToken(path: string, cwd?: string) { 13 | const content = await readFile(path, cwd); 14 | return countTokens(content); 15 | } 16 | 17 | export async function countGlobToken(globsString: string = "**/*") { 18 | const globs = globsString.split(","); 19 | const includes = globs.filter((g) => !g.startsWith("!")); 20 | const excludes = globs.filter((g) => g.startsWith("!")); 21 | 22 | const paths = await glob(includes, { 23 | ignore: excludes, 24 | }); 25 | 26 | if (!paths) throw new Error("No files found"); 27 | 28 | const sizes = await Promise.all(paths.map((path) => countFileToken(path))); 29 | return { 30 | filesCount: paths.length, 31 | tokensCount: sizes.reduce((acc, size) => acc + size, 0), 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/ts.utils.ts: -------------------------------------------------------------------------------- 1 | export const never = (input: never) => {}; 2 | 3 | export const throwOnUnhandled = (input: never, errorMessage: string) => { 4 | throw new Error(errorMessage); 5 | }; 6 | 7 | export type PromiseValue = T extends Promise ? U : never; 8 | export type PromiseReturnType any> = PromiseValue< 9 | ReturnType 10 | >; 11 | -------------------------------------------------------------------------------- /src/utils/web.utils.ts: -------------------------------------------------------------------------------- 1 | import { Readability } from "@mozilla/readability"; 2 | import { JSDOM } from "jsdom"; 3 | 4 | export const getUrl = async (url: string) => { 5 | const res = await fetch(url); 6 | return res.text(); 7 | }; 8 | 9 | export const getArticleFromUrl = async (url: string) => { 10 | const content = await getUrl(url); 11 | const doc = new JSDOM(content, { url }); 12 | let reader = new Readability(doc.window.document); 13 | let article = reader.parse(); 14 | 15 | if (!article?.content) 16 | return { 17 | url, 18 | error: "No article content found, defaulted to base content", 19 | content, 20 | }; 21 | 22 | return { 23 | url, 24 | content: article.content, 25 | title: article.title, 26 | byline: article.byline, 27 | siteName: article.siteName, 28 | lang: article.lang, 29 | publishedTime: article.publishedTime, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "nodenext", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "skipLibCheck": true, 9 | "resolveJsonModule": true, 10 | "outDir": "dist" 11 | } 12 | } 13 | --------------------------------------------------------------------------------