diff --git a/.vscode/launch.json b/.vscode/launch.json index a0136ec..b1f5d39 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,19 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Launch via NPM", + "request": "launch", + "runtimeArgs": [ + "run-script", + "cli" + ], + "runtimeExecutable": "npm", + "skipFiles": [ + "/**" + ], + "type": "node" + }, { "name": "Debug Jest Tests", "type": "node", diff --git a/bots/git.md b/bots/git.md new file mode 100644 index 0000000..49dce04 --- /dev/null +++ b/bots/git.md @@ -0,0 +1,112 @@ +# Git + +Acts like a git command to demonstrate function calling + +``` +{ "role": "system", "content": "You are an intelligent computer terminal that is capable of natural language. You will gather from your user all the information you need to make one of the function calls you have been supplied with." } +``` + +## FUNCTIONS + +```json +[ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Git Command", + "description": "A representation of a git command to be executed", + "type": "object", + "required": ["subcommand"], + "properties": { + "subcommand": { + "type": "string", + "description": "The git subcommand to be executed", + "enum": ["clone", "commit", "push", "pull", "checkout", "status"] + }, + "options": { + "type": "object", + "description": "Options for the git command", + "properties": { + "clone": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "description": "The URL of the repository to clone" + }, + "directory": { + "type": "string", + "description": "The name of a new directory to clone into" + } + }, + "required": ["repository"] + }, + "commit": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The commit message" + } + }, + "required": ["message"] + }, + "push": { + "type": "object", + "properties": { + "remote": { + "type": "string", + "description": "The remote name" + }, + "branch": { + "type": "string", + "description": "The branch name" + } + } + }, + "pull": { + "type": "object", + "properties": { + "remote": { + "type": "string", + "description": "The remote name" + }, + "branch": { + "type": "string", + "description": "The branch name" + } + } + }, + "checkout": { + "type": "object", + "properties": { + "branch": { + "type": "string", + "description": "The branch or commit to checkout" + } + }, + "required": ["branch"] + }, + "status": { + "type": "object", + "description": "Git status options" + } + } + }, + "globalOptions": { + "type": "object", + "description": "Global options for the git command", + "properties": { + "version": { + "type": "boolean", + "description": "Show git version" + }, + "help": { + "type": "boolean", + "description": "Show help for git command" + } + } + } + } + } +] +``` diff --git a/package.json b/package.json index be01c0c..b089b5a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "license": "AGPL-3.0", "scripts": { "postinstall": "patch-package --patch-dir .patches", - "start": "yarn && NODE_NO_WARNINGS=1 node --loader=import-jsx src/cli.js", + "start": "yarn && yarn cli", + "cli": "NODE_NO_WARNINGS=1 node --loader=import-jsx src/cli.js", "dev": "NODE_NO_WARNINGS=1 nodemon --no-stdin --loader=import-jsx src/cli.js", "test": "NODE_NO_WARNINGS=1 node --experimental-vm-modules --loader=import-jsx node_modules/.bin/jest", "watch": "DEBUG_COLORS=1 NODE_NO_WARNINGS=1 node --experimental-vm-modules --loader=import-jsx node_modules/.bin/jest --watch", diff --git a/src/ai.js b/src/ai.js index 4012d20..7c0e2fc 100644 --- a/src/ai.js +++ b/src/ai.js @@ -22,7 +22,9 @@ export default class AI { static create({ session } = {}) { const ai = new AI() ai.#disk = Disk.create(session) - ai.#push(...ai.#disk.load()) + const loaded = ai.#disk.load() + expect(loaded).has.property('session') + ai.#push(...loaded.session) debug('loaded session', ai.session) return ai } @@ -46,28 +48,59 @@ export default class AI { } async *#stream() { await this.#disk.flush(this.session) - const results = [] + const contentParts = [] + const functionParts = [] debug('session', this.session) - const messages = await this.#disk.expand(this.session) + const { messages, functions } = await this.#disk.expand(this.session) debug('messages', messages) + debug('functions', functions) if (this.#injectedNextResponse) { yield* this.#injectedNextResponse - results.push(...this.#injectedNextResponse) + contentParts.push(...this.#injectedNextResponse) this.#injectedNextResponse = undefined } else { - const stream = await this.#openAi.chat.completions.create({ + const params = { model: 'gpt-4', messages, stream: true, - }) + } + functions.length && (params.functions = functions) + const stream = await this.#openAi.chat.completions.create(params) for await (const part of stream) { - const result = part.choices[0]?.delta?.content || '' - results.push(result) - yield result + const content = part.choices[0]?.delta?.content || '' + content && contentParts.push(content) + const fn = part.choices[0]?.delta?.function_call || '' + fn && functionParts.push(fn) + if (content) { + yield content + } + if (fn && fn.name) { + yield 'FUNCTION: ' + fn.name + } } } - const result = results.join('') - this.#push({ role: 'assistant', content: result }) + if (functionParts.length) { + let name = '' + let args = '' + functionParts.forEach((part) => { + if (part.name) { + name += part.name + } else if (part.arguments) { + args += part.arguments + } else { + throw Error('unknown function part' + JSON.stringify(part, null, 2)) + } + }) + const string = '\n' + args + yield string + this.#push({ role: 'assistant', content: string }) + } + if (contentParts.length) { + this.#push({ role: 'assistant', content: contentParts.join('') }) + } + if (functionParts.length && contentParts.length) { + throw new Error('both function and content parts received') + } await this.#disk.flush(this.session) } #injectedNextResponse diff --git a/src/disk.js b/src/disk.js index 9a9384c..1320581 100644 --- a/src/disk.js +++ b/src/disk.js @@ -5,6 +5,7 @@ import fs from 'fs' import { expect } from 'chai' import Debug from 'debug' import BookLoader from './tools/book-loader.js' +import assert from 'assert-fast' const debug = Debug('disk') const SESSIONS_DIR = 'sessions' const BOTS_DIR = 'bots' @@ -29,50 +30,48 @@ export default class Disk { } async expand(session) { expect(session).to.be.an('array') - const withBots = [] + const messages = [] + const functions = [] for (const item of session) { if (item.role === 'bot') { - const bot = item.content - await this.loadBot(bot) - debug('prompts for', bot, this.#bots.get(bot)) - withBots.push(...this.#bots.get(bot)) + const botName = item.content + const bot = await this.loadBot(botName) + debug('bot for', botName, bot) + messages.push(...bot.messages) + functions.push(...bot.functions) } else { - withBots.push(item) + messages.push(item) } } - - return withBots + return { messages, functions } } load() { - if (!this.#session) { - return [] - } - try { - fs.accessSync(this.#session) - } catch (err) { - return [] - } - try { - const session = [] - const data = fs.readFileSync(this.#session) - const lines = data.toString().split('\n') - for (const line of lines) { - if (!line) { - continue + if (this.#session) { + try { + fs.accessSync(this.#session) + const session = [] + const data = fs.readFileSync(this.#session) + const lines = data.toString().split('\n') + for (const line of lines) { + if (!line) { + continue + } + try { + session.push(JSON.parse(line)) + } catch (err) { + // TODO make the AI parse the error offer corrections + } } - try { - session.push(JSON.parse(line)) - } catch (err) { - // TODO make the AI parse the error offer corrections + // TODO check that expanding the session works correctly + return { session } + } catch (err) { + // TODO log the error somewhere for the user to receive comment on + if (err.code !== 'ENOENT') { + console.error(err) } } - // TODO check that expanding the session works correctly - return session - } catch (err) { - // TODO log the error somewhere for the user to receive comment on - console.error(err) - return [] } + return { session: [] } } async flush(session) { expect(session).is.an('array') @@ -105,25 +104,54 @@ export default class Disk { const filename = `${BOTS_DIR}/${bot}.md` const data = fs.readFileSync(filename) const lines = data.toString().split('\n') - const botPrompts = [] + const messages = [] + let functionsString for (const line of lines) { if (!line) { continue } - try { - botPrompts.push(JSON.parse(line)) - } catch (err) { - // TODO make the AI parse the error offer corrections + + if (line.trim() === '## FUNCTIONS') { + expect(functionsString).to.be.undefined + functionsString = ' ' + } else if (functionsString) { + if (!line.trim().startsWith('```')) { + functionsString += line + } + } else { + try { + messages.push(JSON.parse(line)) + } catch (err) { + // TODO make the AI parse the error offer corrections + } + } + } + expect(messages).length.to.be.above(0) + + const functions = [] + try { + if (functionsString) { + const functionsArray = JSON.parse(functionsString) + for (const fn of functionsArray) { + assert(fn.title, `missing title for ${JSON.stringify(fn, null, 2)}`) + assert(fn.description, `missing description for ${fn.title}`) + const name = fn.title.replace(' ', '_') + const { description, ...parameters } = fn + debug('adding function', name) + functions.push({ name, description, parameters }) + } } + } catch (error) { + console.error('failed to parse functions: ' + functionsString) } - expect(botPrompts).length.to.be.above(0) - debug('loaded bot', bot, botPrompts.length) + + debug('loaded bot', bot, messages.length, functions.length) if (bot === 'book-loader') { debug('knowledge matcher loaded without using knowledge matcher') - this.#bots.set(bot, botPrompts) + this.#bots.set(bot, { messages, functions }) } else { - const withKnowledge = await this.expandKnowledge(botPrompts) - this.#bots.set(bot, withKnowledge) + const withKnowledge = await this.expandKnowledge(messages) + this.#bots.set(bot, { messages: withKnowledge, functions }) } // need to reloop and insert all the other bots it loads }