Repository: ian-kent/gptchat Branch: main Commit: 21441696c2e2 Files: 24 Total size: 55.3 KB Directory structure: gitextract_eagp9m2d/ ├── LICENSE.md ├── README.md ├── chat_loop.go ├── command.go ├── config/ │ └── config.go ├── conversation.go ├── go.mod ├── go.sum ├── main.go ├── module/ │ ├── memory/ │ │ ├── memory.go │ │ ├── recall.go │ │ ├── storage.go │ │ └── store.go │ ├── module.go │ ├── plugin/ │ │ ├── compiled/ │ │ │ └── README.md │ │ ├── create.go │ │ └── source/ │ │ └── README.md │ └── plugin.go ├── parser/ │ ├── cmd/ │ │ └── main.go │ ├── parser.go │ └── parser_test.go ├── ui/ │ ├── theme.go │ └── ui.go └── util/ └── strings.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: LICENSE.md ================================================ The MIT License (MIT) Copyright (c) 2023 Ian Kent Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ GPTChat ======= GPTChat is a client which gives GPT-4 some unique tools to be a better AI. With GPTChat, GPT-4 can: * remember useful information and recall it later * recall information without knowing it's previously remembered it * write it's own plugins and call them * decide to write plugins without being prompted * complete tasks by combining memories and plugins * use multi-step commands to complete complex tasks ### Getting started You'll need: * A working installation of Go (which you download from https://go.dev/) * An OpenAI account * An API key with access to the GPT-4 API If you don't have an API key, you can get one here: https://platform.openai.com/account/api-keys If you haven't joined the GPT-4 API waitlist, you can do that here: https://openai.com/waitlist/gpt-4-api Once you're ready: 1. Set the `OPENAI_API_KEY` environment variable to avoid the API key prompt on startup 2. Run GPTChat with `go run .` from the `gptchat` directory 3. Have fun! ## Memory GPT-4's context window is pretty small. GPTChat adds a long term memory which GPT-4 can use to remember useful information. For example, if you tell GPT-4 what pets you have, it'll remember and can recall that information to answer questions even when the context is gone. [See a GPT-4 memory demo on YouTube](https://www.youtube.com/watch?v=PUFZdM1nSTI) ## Plugins GPT-4 can write its own plugins to improve itself. For example, GPT-4 is pretty bad at math and generating random numbers. With the plugin system, you can ask GPT-4 to generate two random numbers and add them together, and it'll write a plugin to do just that. [See a GPT-4 plugin demo on YouTube](https://www.youtube.com/watch?v=o7M-XH6tMhc) ℹ️ Plugins are only supported on unix based systems like Linux and MacOS - to get plugins working on Windows, you'll need to use something like WSL2. ## Contributing PRs to add new features are welcome. Be careful of prompt changes - small changes can disrupt GPT-4's ability to use the commands correctly. ## Disclaimer You should supervise GPT-4's activity. In one experiment, GPT-4 gave itself internet access with a HTTP client plugin - this seemed like a bad idea. ### Supervised mode GPTChat will run in supervised mode by default. This doesn't restrict any functionality, but does require user confirmation before compiling and executing any plugin code written by GPT, giving users a chance to review the code for safety before executing it. ⚠️ Code written by GPT is untrusted code from the internet and potentially dangerous All code is compiled and executed as your user, with the same level of permissions your user has. It may be safer to run this in a container or virtual machine. Supervised mode can be disabled but I wouldn't recommend it. # License See [LICENSE.md](LICENSE.md) for more information. Copyright (c) 2023 Ian Kent ================================================ FILE: chat_loop.go ================================================ package main import ( "context" "fmt" "strings" "time" "github.com/ian-kent/gptchat/config" "github.com/ian-kent/gptchat/module" "github.com/ian-kent/gptchat/parser" "github.com/ian-kent/gptchat/ui" "github.com/ian-kent/gptchat/util" "github.com/sashabaranov/go-openai" ) func chatLoop(cfg config.Config) { RESET: appendMessage(openai.ChatMessageRoleSystem, systemPrompt) if cfg.IsDebugMode() { ui.PrintChatDebug(ui.System, systemPrompt) } var skipUserInput = true appendMessage(openai.ChatMessageRoleUser, openingPrompt) if cfg.IsDebugMode() { ui.PrintChatDebug(ui.User, openingPrompt) } if !cfg.IsDebugMode() { ui.PrintChat(ui.App, "Setting up the chat environment, please wait for GPT to respond - this may take a few moments.") } var i int for { i++ if !skipUserInput { input := ui.PromptChatInput() var echo bool ok, result := parseSlashCommand(input) if ok { // the command was handled but returned nothing // to send to the AI, let's prompt the user again if result == nil { continue } if result.resetConversation { resetConversation() goto RESET } // if the result is a retry, we can just send the // same request to GPT again if result.retry { skipUserInput = true goto RETRY } if result.toggleDebugMode { cfg = cfg.WithDebugMode(!cfg.IsDebugMode()) module.UpdateConfig(cfg) if cfg.IsDebugMode() { ui.PrintChat(ui.App, "Debug mode is now enabled") } else { ui.PrintChat(ui.App, "Debug mode is now disabled") } continue } if result.toggleSupervisedMode { cfg = cfg.WithSupervisedMode(!cfg.IsSupervisedMode()) module.UpdateConfig(cfg) if cfg.IsSupervisedMode() { ui.PrintChat(ui.App, "Supervised mode is now enabled") } else { ui.PrintChat(ui.App, "Supervised mode is now disabled") } continue } // we have a prompt to give to the AI, let's do that if result.prompt != "" { input = result.prompt echo = true } } if echo { ui.PrintChat(ui.User, input) echo = false } appendMessage(openai.ChatMessageRoleUser, input) } skipUserInput = false RETRY: // Occasionally include the interval prompt if i%5 == 0 { interval := intervalPrompt() appendMessage(openai.ChatMessageRoleSystem, interval) if cfg.IsDebugMode() { ui.PrintChatDebug(ui.System, interval) } } attempts := 1 RATELIMIT_RETRY: resp, err := client.CreateChatCompletion( context.Background(), openai.ChatCompletionRequest{ Model: cfg.OpenAIAPIModel(), Messages: conversation, }, ) if err != nil { if strings.HasPrefix(err.Error(), "error, status code: 429") && attempts < 5 { attempts++ ui.Error("rate limited, trying again in 1 second", err) time.Sleep(time.Second) goto RATELIMIT_RETRY } ui.Error("ChatCompletion failed", err) if ui.PromptConfirm("Would you like to try again?") { goto RATELIMIT_RETRY } continue } response := resp.Choices[0].Message.Content appendMessage(openai.ChatMessageRoleAssistant, response) if cfg.IsDebugMode() { ui.PrintChat(ui.AI, response) } parseResult := parser.Parse(response) if !cfg.IsDebugMode() && parseResult.Chat != "" { ui.PrintChat(ui.AI, parseResult.Chat) } for _, command := range parseResult.Commands { ok, result := module.ExecuteCommand(command.Command, command.Args, command.Body) if ok { // we had at least one AI command so we're going to respond automatically, // no need to ask for user input skipUserInput = true if result.Error != nil { msg := fmt.Sprintf(`An error occurred executing your command. The command was: `+util.TripleQuote+` %s `+util.TripleQuote+` The error was: `+util.TripleQuote+` %s `+util.TripleQuote, command.String(), result.Error.Error()) if result.Prompt != "" { msg += fmt.Sprintf(` The command provided this additional output: `+util.TripleQuote+` %s `+util.TripleQuote, result.Prompt) } appendMessage(openai.ChatMessageRoleSystem, msg) if cfg.IsDebugMode() { ui.PrintChatDebug(ui.Module, msg) } continue } commandResult := fmt.Sprintf(`Your command returned some output. The command was: `+util.TripleQuote+` %s `+util.TripleQuote+` The output was: %s`, command.String(), result.Prompt) appendMessage(openai.ChatMessageRoleSystem, commandResult) if cfg.IsDebugMode() { ui.PrintChatDebug(ui.Module, commandResult) } continue } } } } ================================================ FILE: command.go ================================================ package main import ( "fmt" "github.com/ian-kent/gptchat/ui" "os" "strings" ) type slashCommandResult struct { // the prompt to send to the AI prompt string // retry tells the client to resend the most recent conversation retry bool // resetConversation will reset the conversation to its original state, // forgetting the conversation history resetConversation bool // toggleDebugMode will switch between debug on and debug off toggleDebugMode bool // toggleSupervisedMode will switch between supervised mode on and off toggleSupervisedMode bool } type slashCommand struct { command string fn func(string) (bool, *slashCommandResult) } var slashCommands = []slashCommand{ { command: "exit", fn: func(s string) (bool, *slashCommandResult) { os.Exit(0) return true, nil }, }, { command: "retry", fn: func(s string) (bool, *slashCommandResult) { return true, &slashCommandResult{ retry: true, } }, }, { command: "reset", fn: func(s string) (bool, *slashCommandResult) { return true, &slashCommandResult{ resetConversation: true, } }, }, { command: "debug", fn: func(s string) (bool, *slashCommandResult) { return true, &slashCommandResult{ toggleDebugMode: true, } }, }, { command: "supervisor", fn: func(s string) (bool, *slashCommandResult) { return true, &slashCommandResult{ toggleSupervisedMode: true, } }, }, { command: "example", fn: exampleCommand, }, } func helpCommand(string) (bool, *slashCommandResult) { result := "The following commands are available:\n" for _, e := range slashCommands { result += fmt.Sprintf("\n /%s", e.command) } ui.PrintChat(ui.App, result) return true, nil } func parseSlashCommand(input string) (ok bool, result *slashCommandResult) { if !strings.HasPrefix(input, "/") { return false, nil } input = strings.TrimPrefix(input, "/") if input == "help" { return helpCommand(input) } parts := strings.SplitN(input, " ", 2) var cmd, args string cmd = parts[0] if len(parts) > 1 { args = parts[1] } for _, command := range slashCommands { if command.command == cmd { return command.fn(args) } } return false, nil } type example struct { id, prompt string } var examples = []example{ { id: "1", prompt: "I want you to generate 5 random numbers and add them together.", }, { id: "2", prompt: "I want you to generate 5 random numbers. Multiply the first and second number, then add the result to the remaining numbers.", }, { id: "3", prompt: "I want you to generate 2 random numbers. Add them together then multiply the result by -1.", }, { id: "4", prompt: "Can you summarise the tools you have available?", }, { id: "5", prompt: "Can you suggest a task which might somehow use all of the available tools?", }, } func exampleCommand(args string) (bool, *slashCommandResult) { for _, e := range examples { if e.id == args { return true, &slashCommandResult{ prompt: e.prompt, } } } result := "The following examples are available:" for _, e := range examples { result += fmt.Sprintf("\n\n/example %s\n %s", e.id, e.prompt) } ui.PrintChat(ui.App, result) return true, nil } ================================================ FILE: config/config.go ================================================ package config type Config struct { openaiAPIKey string openaiAPIModel string supervisedMode bool debugMode bool } func New() Config { return Config{ openaiAPIKey: "", openaiAPIModel: "", supervisedMode: true, debugMode: false, } } func (c Config) OpenAIAPIModel() string { return c.openaiAPIModel } func (c Config) OpenAIAPIKey() string { return c.openaiAPIKey } func (c Config) IsSupervisedMode() bool { return c.supervisedMode } func (c Config) IsDebugMode() bool { return c.debugMode } func (c Config) WithOpenAIAPIKey(apiKey string) Config { c.openaiAPIKey = apiKey return c } func (c Config) WithSupervisedMode(supervisedMode bool) Config { c.supervisedMode = supervisedMode return c } func (c Config) WithDebugMode(debugMode bool) Config { c.debugMode = debugMode return c } func (c Config) WithOpenAIAPIModel(apiModel string) Config { c.openaiAPIModel = apiModel return c } ================================================ FILE: conversation.go ================================================ package main import ( "fmt" "github.com/ian-kent/gptchat/util" "github.com/sashabaranov/go-openai" "time" ) var systemPrompt = `You are a helpful assistant. You enjoy conversations with the user and like asking follow up questions to gather more information. You have commands available which you can use to help me. You can call these commands using the slash command syntax, for example, this is how you call the help command: ` + util.TripleQuote + ` /help ` + util.TripleQuote + ` The /help command will give you a list of the commands you have available. Commands can also include a request body, for example, this is an example of a command which takes an input: ` + util.TripleQuote + ` /example { "expr": "value" } ` + util.TripleQuote + ` Most commands also have subcommands, and this is an example of how you call a subcommand: ` + util.TripleQuote + ` /example subcommand { "expr": "value" } ` + util.TripleQuote + ` To call a command, include the command in your response. You don't need to explain the command response to me, I don't care what it is, I only care that you can use it's output to follow my instructions.` const openingPrompt = `Hello! Please familiarise yourself with the commands you have available. You must do this before we have a conversation.` func intervalPrompt() string { return fmt.Sprintf(`The current date and time is %s. Remember that the '/help' command will tell you what commands you have available.`, time.Now().Format("02 January 2006, 03:04pm")) } var conversation []openai.ChatCompletionMessage func appendMessage(role string, message string) { conversation = append(conversation, openai.ChatCompletionMessage{ Role: role, Content: message, }) } func resetConversation() { conversation = []openai.ChatCompletionMessage{} } ================================================ FILE: go.mod ================================================ module github.com/ian-kent/gptchat go 1.18 require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.7.0 // indirect github.com/dop251/goja v0.0.0-20230304130813-e2f543bf4b4c // indirect github.com/fatih/color v1.15.0 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sashabaranov/go-openai v1.5.7 // indirect github.com/spf13/cobra v1.6.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/testify v1.8.2 // indirect golang.org/x/sys v0.6.0 // indirect golang.org/x/text v0.3.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= github.com/dop251/goja v0.0.0-20230304130813-e2f543bf4b4c h1:/utv6nmTctV6OVgfk5+O6lEMEWL+6KJy4h9NZ5fnkQQ= github.com/dop251/goja v0.0.0-20230304130813-e2f543bf4b4c/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sashabaranov/go-openai v1.5.7 h1:8DGgRG+P7yWixte5j720y6yiXgY3Hlgcd0gcpHdltfo= github.com/sashabaranov/go-openai v1.5.7/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: main.go ================================================ package main import ( "fmt" "os" "strconv" "strings" "github.com/ian-kent/gptchat/config" "github.com/ian-kent/gptchat/module" "github.com/ian-kent/gptchat/module/memory" "github.com/ian-kent/gptchat/module/plugin" "github.com/ian-kent/gptchat/ui" openai "github.com/sashabaranov/go-openai" ) var client *openai.Client var cfg = config.New() func init() { openaiAPIKey := strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) if openaiAPIKey == "" { ui.Warn("You haven't configured an OpenAI API key") fmt.Println() if !ui.PromptConfirm("Do you have an API key?") { ui.Warn("You'll need an API key to use GPTChat") fmt.Println() fmt.Println("* You can get an API key at https://platform.openai.com/account/api-keys") fmt.Println("* You can get join the GPT-4 API waitlist at https://openai.com/waitlist/gpt-4-api") os.Exit(1) } openaiAPIKey = ui.PromptInput("Enter your API key:") if openaiAPIKey == "" { fmt.Println("") ui.Warn("You didn't enter an API key.") os.Exit(1) } } cfg = cfg.WithOpenAIAPIKey(openaiAPIKey) openaiAPIModel := strings.TrimSpace(os.Getenv("OPENAI_API_MODEL")) if openaiAPIModel == "" { ui.Warn("You haven't configured an OpenAI API model, defaulting to GPT4") openaiAPIModel = openai.GPT4 } cfg = cfg.WithOpenAIAPIModel(openaiAPIModel) supervisorMode := os.Getenv("GPTCHAT_SUPERVISOR") switch strings.ToLower(supervisorMode) { case "disabled": ui.Warn("Supervisor mode is disabled") cfg = cfg.WithSupervisedMode(false) default: } debugEnv := os.Getenv("GPTCHAT_DEBUG") if debugEnv != "" { v, err := strconv.ParseBool(debugEnv) if err != nil { ui.Warn(fmt.Sprintf("error parsing GPT_DEBUG: %s", err.Error())) } else { cfg = cfg.WithDebugMode(v) } } client = openai.NewClient(openaiAPIKey) module.Load(cfg, client, []module.Module{ &memory.Module{}, &plugin.Module{}, }...) if err := module.LoadCompiledPlugins(); err != nil { ui.Warn(fmt.Sprintf("error loading compiled plugins: %s", err)) } } func main() { ui.Welcome( `Welcome to the GPT client.`, `You can talk directly to GPT, or you can use /commands to interact with the client. Use /help to see a list of available commands.`) chatLoop(cfg) } ================================================ FILE: module/memory/memory.go ================================================ package memory import ( "errors" "fmt" "github.com/ian-kent/gptchat/config" "github.com/ian-kent/gptchat/util" openai "github.com/sashabaranov/go-openai" ) type memory struct { DateStored string `json:"date_stored"` Memory string `json:"memory"` } type Module struct { cfg config.Config client *openai.Client memories []memory } func (m *Module) ID() string { return "memory" } func (m *Module) Load(cfg config.Config, client *openai.Client) error { m.cfg = cfg m.client = client return m.loadFromFile() } func (m *Module) UpdateConfig(cfg config.Config) { m.cfg = cfg } func (m *Module) Execute(args, body string) (string, error) { switch args { case "store": return m.Store(body) case "recall": return m.Recall(body) default: return "", errors.New(fmt.Sprintf("command not implemented: /memory %s", args)) } } func (m *Module) Prompt() string { return memoryPrompt } const memoryPrompt = `You also have a working long term memory. You can remember something using the '/memory store' command, or you can recall it using the '/memory recall' command. For example, if you want to store a fact I give you, you might reply with a message such as: ` + util.TripleQuote + ` /memory store { "memory": "I bought cookies yesterday", "context": "The user was discussing what they'd eaten" } ` + util.TripleQuote + ` You should always include useful context in the memory to help with recall later, for example any relevant parts of the conversation which might help you remember why you stored a particular memory. Later, you may try to recall this memory. You only need to use related concepts, since of course until you call '/memory recall' you cannot possibly know the memory you've stored. For example: ` + util.TripleQuote + ` /memory recall { When did I buy cookies? } ` + util.TripleQuote + ` or, another example might be: ` + util.TripleQuote + ` /memory recall { What did I buy yesterday? } ` + util.TripleQuote + ` This means you can remember interesting facts about our conversation. You should always consult your long term memory using the '/memory recall' command if you think I'm asking about something you don't have information about. For example, if I ask you "What did I eat for lunch yesterday?", and you don't know the answer, you must try to retrieve the answer from your long term memory. You can use '/memory store' and '/memory recall' at any time. You might find this useful if I ask you to remember something. The commands must be an entire message, with no conversational text, and no quotes. If I give you information that you don't already know, you must store this information in your memory. This could be any sort of information, for example a story, information about myself, people I know, things I've done, real world events. If you're unsure, you should store it just in case. You must not remember the current date. The current date changes and is not a useful memory.` ================================================ FILE: module/memory/recall.go ================================================ package memory import ( "context" "encoding/json" "github.com/ian-kent/gptchat/util" "github.com/sashabaranov/go-openai" ) func (m *Module) Recall(input string) (string, error) { b, err := json.Marshal(m.memories) if err != nil { return "", err } resp, err := m.client.CreateChatCompletion( context.Background(), openai.ChatCompletionRequest{ Model: m.cfg.OpenAIAPIModel(), Messages: []openai.ChatCompletionMessage{ { Role: openai.ChatMessageRoleSystem, Content: `You are a helpful assistant. I'll give you a list of existing memories, and a prompt which asks you to identify the memory I'm looking for. You should review the listed memories and suggest which memories might match the request.`, }, { Role: openai.ChatMessageRoleSystem, Content: `Here are your memories in JSON format: ` + util.TripleQuote + ` ` + string(b) + ` ` + util.TripleQuote, }, { Role: openai.ChatMessageRoleSystem, Content: `Help me find any memories which may match this request: ` + util.TripleQuote + ` ` + input + ` ` + util.TripleQuote, }, }, }, ) if err != nil { return "", err } response := resp.Choices[0].Message.Content return `You have successfully recalled this memory: ` + util.TripleQuote + ` ` + response + ` ` + util.TripleQuote, nil // TODO find a prompt which gets GPT to adjust relative time // return `You have successfully recalled this memory: // //` + util.TripleQuote + ` //` + response + ` //` + util.TripleQuote + ` // //If this memory mentions relative time (for example today, yesterday, last week, tomorrow), remember to take this into consideration when using this information to answer questions. // //For example, if the memory says "tomorrow" and the memory was stored on 25th, the memory is actually referring to 26th.`, nil } ================================================ FILE: module/memory/storage.go ================================================ package memory import ( "encoding/json" "io/ioutil" "os" ) func (m *Module) loadFromFile() error { _, err := os.Stat("memories.json") if os.IsNotExist(err) { return nil } b, err := ioutil.ReadFile("memories.json") if err != nil { return err } err = json.Unmarshal(b, &m.memories) if err != nil { return err } return nil } func (m *Module) writeToFile() error { b, err := json.Marshal(m.memories) if err != nil { return err } err = ioutil.WriteFile("memories.json", b, 0660) if err != nil { return err } return nil } func (m *Module) appendMemory(mem memory) error { m.memories = append(m.memories, mem) return m.writeToFile() } ================================================ FILE: module/memory/store.go ================================================ package memory import ( "github.com/ian-kent/gptchat/util" "time" ) func (m *Module) Store(input string) (string, error) { err := m.appendMemory(memory{ DateStored: time.Now().Format("02 January 2006, 03:04pm"), Memory: input, }) if err != nil { return "", err } return `You have successfully stored this memory: ` + util.TripleQuote + ` ` + input + ` ` + util.TripleQuote, nil } ================================================ FILE: module/module.go ================================================ package module import ( "errors" "fmt" "github.com/ian-kent/gptchat/config" "github.com/ian-kent/gptchat/ui" openai "github.com/sashabaranov/go-openai" "strings" ) type Module interface { Load(config.Config, *openai.Client) error UpdateConfig(config.Config) ID() string Prompt() string Execute(args, body string) (string, error) } // IntervalPrompt allows a module to inject a prompt into the interval prompt type IntervalPrompt interface { IntervalPrompt() string } var loadedModules = make(map[string]Module) func Load(cfg config.Config, client *openai.Client, modules ...Module) error { for _, module := range modules { if err := module.Load(cfg, client); err != nil { ui.Warn(fmt.Sprintf("failed to load module %s: %s", module.ID(), err)) continue } if cfg.IsDebugMode() { ui.Info(fmt.Sprintf("loaded module %s", module.ID())) } loadedModules[module.ID()] = module } return nil } func UpdateConfig(cfg config.Config) { for _, module := range loadedModules { _, ok := module.(pluginLoader) if ok { // GPT written plugins shouldn't have config, nothing to do continue } module.UpdateConfig(cfg) } } func IsLoaded(id string) bool { _, ok := loadedModules[id] return ok } func LoadPlugin(m Module) error { // a plugin doesn't have access to the openai client so it's safe to pass in nil here // // we also don't pass in the config since it may contain sensitive information that // we don't want GPT to have access to return Load(config.Config{}, nil, m) } type CommandResult struct { Error error Prompt string } func HelpCommand() (bool, *CommandResult) { result := "Here are the commands you have available:\n\n" for _, mod := range loadedModules { result += fmt.Sprintf(" * /%s\n", mod.ID()) } result += ` You can call commands using the /command syntax. Calling a command without any additional arguments will explain it's usage. You should do this to learn how the command works.` return true, &CommandResult{ Prompt: result, } } func ExecuteCommand(command, args, body string) (bool, *CommandResult) { if command == "/help" { return HelpCommand() } cmd := strings.TrimPrefix(command, "/") mod, ok := loadedModules[cmd] if !ok { return true, &CommandResult{ Error: errors.New(fmt.Sprintf("Unrecognised command: %s", command)), } } if args == "" && body == "" { return true, &CommandResult{ Prompt: mod.Prompt(), } } res, err := mod.Execute(args, body) if err != nil { return true, &CommandResult{ Error: err, } } return true, &CommandResult{ Prompt: res, } } ================================================ FILE: module/plugin/compiled/README.md ================================================ Compiled plugins will be added to this directory. ================================================ FILE: module/plugin/create.go ================================================ package plugin import ( "errors" "fmt" "github.com/fatih/color" "github.com/ian-kent/gptchat/config" "github.com/ian-kent/gptchat/module" "github.com/ian-kent/gptchat/ui" "github.com/ian-kent/gptchat/util" openai "github.com/sashabaranov/go-openai" "io/ioutil" "os" "os/exec" "strings" ) var ( // TODO make this configurable PluginSourcePath = "./module/plugin/source" PluginCompilePath = "./module/plugin/compiled" ) var ErrPluginSourcePathMissing = errors.New("plugin source path is missing") var ErrPluginCompilePathMissing = errors.New("plugin compiled path is missing") func CheckPaths() error { _, err := os.Stat(PluginSourcePath) if err != nil && !os.IsNotExist(err) { return err } if err != nil { return ErrPluginSourcePathMissing } _, err = os.Stat(PluginCompilePath) if err != nil && !os.IsNotExist(err) { return err } if err != nil { return ErrPluginCompilePathMissing } return nil } type Module struct { cfg config.Config client *openai.Client } func (m *Module) Load(cfg config.Config, client *openai.Client) error { m.cfg = cfg m.client = client if err := CheckPaths(); err != nil { return err } return nil } func (m *Module) UpdateConfig(cfg config.Config) { m.cfg = cfg } func (m *Module) Prompt() string { return newPluginPrompt } func (m *Module) ID() string { return "plugin" } func (m *Module) Execute(args, body string) (string, error) { parts := strings.SplitN(args, " ", 2) cmd := parts[0] if len(parts) > 1 { args = parts[1] } switch cmd { case "create": return m.createPlugin(args, body) default: return "", errors.New(fmt.Sprintf("%s not implemented", args)) } } func (m *Module) createPlugin(id, body string) (string, error) { body = strings.TrimSpace(body) if len(body) == 0 { return "", errors.New("plugin source not found") } if !strings.HasPrefix(body, "{") || !strings.HasSuffix(body, "}") { return "", errors.New("plugin source must be between {} in '/plugin create plugin-id {}' command") } id = strings.TrimSpace(id) if id == "" { return "", errors.New("plugin id is invalid") } if module.IsLoaded(id) { return "", errors.New("a plugin with this id already exists") } source := strings.TrimPrefix(strings.TrimSuffix(body, "}"), "{") pluginSourceDir := PluginSourcePath + "/" + id _, err := os.Stat(pluginSourceDir) if err != nil && !os.IsNotExist(err) { return "", fmt.Errorf("error checking if directory exists: %s", err) } // only create the directory if it doesn't exist; it's possible GPT had a compile // error in the last attempt in which case we can overwrite it // // we don't get this if the last attempt was successful since it'll show up // as a loaded plugin in the check above if os.IsNotExist(err) { // if err is nil then the directory doesn't exist, let's create it err := os.Mkdir(pluginSourceDir, 0777) if err != nil { return "", fmt.Errorf("error creating directory: %s", err) } } sourcePath := pluginSourceDir + "/plugin.go" err = ioutil.WriteFile(sourcePath, []byte(source), 0644) if err != nil { return "", fmt.Errorf("error writing source file: %s", err) } if m.cfg.IsSupervisedMode() { fmt.Println("============================================================") fmt.Println() ui.Warn("⚠️ GPT written plugins are untrusted code from the internet") fmt.Println() fmt.Println("You should review this code before allowing it to be compiled and executed.") fmt.Println() fmt.Println("If you allow this action, GPT is able to execute code with the same permissions as your user.") fmt.Println() color.New(color.FgHiWhite, color.Bold).Println("This is potentially dangerous.") fmt.Println() fmt.Println("The source code GPT has written can be found here:") fmt.Println(sourcePath) fmt.Println() confirmation := ui.PromptInput("Enter 'confirm' to confirm, anything else will block:") if confirmation != "confirm" { fmt.Println() fmt.Println("============================================================") return "The user has prevented you from running this code", errors.New(confirmation) } fmt.Println() fmt.Println("============================================================") fmt.Println() } pluginPath := PluginCompilePath + "/" + id + ".so" cmd := exec.Command("go", "build", "-buildmode=plugin", "-o", pluginPath, sourcePath) if b, err := cmd.CombinedOutput(); err != nil { return string(b), fmt.Errorf("error compiling plugin: %s", err) } loadedPlugin, err := module.OpenPlugin(pluginPath) if err != nil { return "", fmt.Errorf("error opening plugin: %s", err) } // Call the functions provided by the plugin compiledID := loadedPlugin.ID() if id != compiledID { return "", errors.New("ID() does not return the ID specified in the '/plugin create ' command") } err = module.LoadPlugin(module.GetModuleForPlugin(loadedPlugin)) if err != nil { return "", fmt.Errorf("error loading plugin: %s", err) } return `Great! Your plugin has been loaded successfully. Why don't you check the /help command to see if your new plugin is available.'`, nil } var newPluginPrompt = `You can add new plugins which you can call using a slash command. They're written in Go, so all you need to do is create a new struct which implements the correct interface. The interface you need to implement is: ` + util.TripleQuote + ` type Plugin interface { Example() string Execute(input map[string]any) (map[string]any, error) } ` + util.TripleQuote + ` You don't need to write any supporting code like the main function, you only need to implement the struct. Here's the full code for the "add 1" plugin you can use to guide your output: ` + util.TripleQuote + ` package main import "github.com/ian-kent/gptchat/module" var Plugin module.Plugin = AddOne{} type AddOne struct{} func (c AddOne) ID() string { return "add-one" } func (c AddOne) Example() string { return ` + util.SingleQuote + `/add-one { "value": 5 }` + util.SingleQuote + ` } func (c AddOne) Execute(input map[string]any) (map[string]any, error) { value, ok := input["value"].(int) if !ok { return nil, nil } value = value + 1 return map[string]any{ "result": value, }, nil } ` + util.TripleQuote + ` It's best if the plugins you create don't have any external dependencies. You can call external APIs if you want to, but you should avoid APIs which require authentication since you won't have the required access. Your plugin must import the module package and must define a package variable named 'Plugin', just like with the AddOne example. The result of the Execute function you implement must return either a value or an error. The input to Execute is a map[string]any which you should assume is unmarshaled from JSON. This means you must use appropriate data types, for example a float64 when working with numbers. To create a plugin, you should use the "/plugin create {}" command, for example: ` + util.TripleQuote + ` /plugin create add-one { package main // the rest of your plugin source here } ` + util.TripleQuote + ` Your code inside the '/plugin create' body must be valid Go code which can compile without any errors. Do not include quotes or attempt to use a JSON body.` ================================================ FILE: module/plugin/source/README.md ================================================ Plugin source will be added to this directory. ================================================ FILE: module/plugin.go ================================================ package module import ( "encoding/json" "errors" "fmt" "github.com/ian-kent/gptchat/config" "github.com/ian-kent/gptchat/ui" "github.com/sashabaranov/go-openai" "os" "plugin" "strings" ) type Plugin interface { ID() string Example() string Execute(map[string]any) (map[string]any, error) } type pluginLoader struct { plugin Plugin } func (p pluginLoader) Load(config.Config, *openai.Client) error { return nil } func (p pluginLoader) UpdateConfig(config.Config) {} func (p pluginLoader) ID() string { return p.plugin.ID() } func (p pluginLoader) Prompt() string { return p.plugin.Example() } func (p pluginLoader) Execute(args, body string) (string, error) { input := make(map[string]any) if body != "" { err := json.Unmarshal([]byte(body), &input) if err != nil { return "", fmt.Errorf("plugin body must be valid json: %s", err) } } output, err := p.plugin.Execute(input) if err != nil { return "", fmt.Errorf("error executing plugin: %s", err) } b, err := json.Marshal(output) if err != nil { return "", fmt.Errorf("error converting plugin output to json: %s", err) } return string(b), nil } func GetModuleForPlugin(p Plugin) Module { return pluginLoader{p} } func LoadCompiledPlugins() error { pluginPath := "./module/plugin/compiled/" entries, err := os.ReadDir(pluginPath) if err != nil { return fmt.Errorf("error loading compiled plugins: %s", err) } for _, entry := range entries { if !strings.HasSuffix(entry.Name(), ".so") { continue } loadedPlugin, err := OpenPlugin(pluginPath + entry.Name()) if err != nil { ui.Warn(fmt.Sprintf("error opening plugin: %s", err)) continue } pluginID := loadedPlugin.ID() if IsLoaded(pluginID) { ui.Warn(fmt.Sprintf("plugin with this ID is already loaded: %s", err)) continue } err = LoadPlugin(GetModuleForPlugin(loadedPlugin)) if err != nil { ui.Warn(fmt.Sprintf("error loading plugin: %s", err)) continue } } return nil } func OpenPlugin(path string) (Plugin, error) { p, err := plugin.Open(path) if err != nil { return nil, fmt.Errorf("error loading plugin: %s", err) } apiSymbol, err := p.Lookup("Plugin") if err != nil { return nil, fmt.Errorf("error finding plugin implementation: %s", err) } // Cast the symbol to the ScriptAPI interface api, ok := apiSymbol.(*Plugin) if !ok { return nil, errors.New("plugin does not implement the Plugin interface") } loadedPlugin := *api return loadedPlugin, nil } ================================================ FILE: parser/cmd/main.go ================================================ package main import ( "fmt" "github.com/ian-kent/gptchat/parser" "strings" ) func main() { input := `/plugins create my-plugin { package main import "fmt" func main() { fmt.Println("test") } }` tokens := parser.Lex(input) fmt.Println("Tokens:") for _, token := range tokens { fmt.Printf(" %20s => %s\n", token.Typ, token.Val) } fmt.Println() result := parser.ParseTokens(tokens) fmt.Println("Result:") fmt.Println(" Chat:") fmt.Println(indent(result.Chat, " ")) fmt.Println(" Commands:") for _, command := range result.Commands { fmt.Printf(" - Command: %s\n", command.Command) fmt.Printf(" - Args: %s\n", command.Args) fmt.Printf(" - Body:\n") fmt.Println(indent(command.Body, " ")) } } func indent(input string, prefix string) string { lines := strings.Split(string(input), "\n") var output string for _, line := range lines { output += prefix + line + "\n" } return output } ================================================ FILE: parser/parser.go ================================================ package parser import "strings" type ParseResult struct { Chat string Commands []ParseCommand } type ParseCommand struct { Command string Args string Body string } func (p ParseCommand) String() string { output := p.Command if p.Args != "" { output += " " + p.Args } if p.Body != "" { output += "\n" + p.Body } return output } func Parse(input string) ParseResult { tokens := Lex(input) return ParseTokens(tokens) } type TokenType string const ( Plaintext TokenType = "Plaintext" Newline = "Newline" Command = "Command" Body = "Body" ) type Token struct { Typ TokenType Val string } func ParseTokens(tokens []Token) ParseResult { result := ParseResult{} var activeCommand *ParseCommand var commands []*ParseCommand var isInCommandContext bool for _, token := range tokens { switch token.Typ { case Plaintext: if activeCommand == nil { result.Chat += token.Val isInCommandContext = false continue } if activeCommand.Args != "" { // ParseTokens error panic("ParseTokens error: Command already has args") } activeCommand.Args = strings.TrimSpace(token.Val) case Newline: if activeCommand != nil { activeCommand = nil continue } if strings.HasSuffix(result.Chat, "\n\n") { // we don't append more than two consecutive newlines continue } result.Chat += token.Val case Command: activeCommand = &ParseCommand{Command: token.Val} commands = append(commands, activeCommand) isInCommandContext = true case Body: if activeCommand != nil { if activeCommand.Body != "" { // ParseTokens error panic("ParseTokens error: Command already has Body") } activeCommand.Body = token.Val continue } if isInCommandContext { lastCommand := commands[len(commands)-1] if lastCommand.Body != "" { // ParseTokens error panic("ParseTokens error: Command already has Body") } lastCommand.Body = token.Val continue } result.Chat += token.Val } } result.Chat = strings.TrimSpace(result.Chat) for _, command := range commands { result.Commands = append(result.Commands, *command) } return result } func Lex(input string) []Token { var tokens []Token var currentToken *Token var nesting int for i, c := range input { switch c { case '/': // is this the start of a new Command? if i == 0 || input[i-1] == '\n' { if currentToken != nil { tokens = append(tokens, *currentToken) } currentToken = &Token{Typ: Command, Val: "/"} continue } // if we have a Token, append to it if currentToken != nil { currentToken.Val += string(c) continue } // otherwise we can assume this is plain text currentToken = &Token{Typ: Plaintext, Val: "/"} case ' ': // a space signifies the end of the Command if currentToken != nil && currentToken.Typ == Command { tokens = append(tokens, *currentToken) currentToken = nil continue } // if we have a Token, append to it if currentToken != nil { currentToken.Val += string(c) continue } // otherwise we can assume this is plain text currentToken = &Token{Typ: Plaintext, Val: " "} case '{': // if it's not already a Body, we'll store the current Token if currentToken != nil && currentToken.Typ != Body { tokens = append(tokens, *currentToken) } // If we already have a body, we'll add to it if currentToken != nil && currentToken.Typ == Body { nesting++ currentToken.Val += string(c) continue } // Otherwise we'll start a new body currentToken = &Token{Typ: Body, Val: "{"} nesting++ case '}': // if we're already in a Body, the Body ends if currentToken != nil && currentToken.Typ == Body { nesting-- currentToken.Val += "}" if nesting == 0 { tokens = append(tokens, *currentToken) currentToken = nil } continue } // if we have a Token, append to it if currentToken != nil { currentToken.Val += string(c) continue } // otherwise we can assume this is plain text currentToken = &Token{Typ: Plaintext, Val: " "} case '\n': // if we have Plaintext or a Body, we'll append to it if currentToken != nil && (currentToken.Typ == Body) { currentToken.Val += "\n" continue } // otherwise we always end the current Token at a new line if currentToken != nil { tokens = append(tokens, *currentToken) currentToken = nil } // and we store a new line Token tokens = append(tokens, Token{Typ: Newline, Val: "\n"}) default: if currentToken == nil { currentToken = &Token{Typ: Plaintext} } currentToken.Val += string(c) } } if currentToken != nil { tokens = append(tokens, *currentToken) } return tokens } ================================================ FILE: parser/parser_test.go ================================================ package parser import ( "github.com/stretchr/testify/assert" "testing" ) func TestParse(t *testing.T) { testCases := []struct { name string input string output ParseResult }{ { name: "basic Command", input: "/api get /path", output: ParseResult{ Chat: "", Commands: []ParseCommand{ { Command: "/api", Args: "get /path", Body: "", }, }, }, }, { name: "Command with args", input: "/api get /path { something }", output: ParseResult{ Chat: "", Commands: []ParseCommand{ { Command: "/api", Args: "get /path", Body: "{ something }", }, }, }, }, { name: "multiline Command with args", input: `/api get /path { something }`, output: ParseResult{ Chat: "", Commands: []ParseCommand{ { Command: "/api", Args: "get /path", Body: `{ something }`, }, }, }, }, { name: "multiline Command with args", input: `/api get /path { something }`, output: ParseResult{ Chat: "", Commands: []ParseCommand{ { Command: "/api", Args: "get /path", Body: `{ something }`, }, }, }, }, { name: "chat with multiline Command with args", input: `This is some chat /api get /path { something }`, output: ParseResult{ Chat: "This is some chat", Commands: []ParseCommand{ { Command: "/api", Args: "get /path", Body: `{ something }`, }, }, }, }, { name: "chat with multiple multiline Command with args", input: `This is some chat /api get /path { something } This is some more chat /api post /another-path { something else }`, output: ParseResult{ Chat: "This is some chat\n\nThis is some more chat", Commands: []ParseCommand{ { Command: "/api", Args: "get /path", Body: `{ something }`, }, { Command: "/api", Args: "post /another-path", Body: `{ something else }`, }, }, }, }, { name: "multiline Command with code Body", input: `/plugins create my-plugin { package main import "fmt" func main() { fmt.Println("test") } }`, output: ParseResult{ Chat: "", Commands: []ParseCommand{ { Command: "/plugins", Args: "create my-plugin", Body: `{ package main import "fmt" func main() { fmt.Println("test") } }`, }, }, }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { tokens := Lex(testCase.input) ast := ParseTokens(tokens) assert.Equal(t, testCase.output, ast) }) } } ================================================ FILE: ui/theme.go ================================================ package ui import ( "os" "strings" "github.com/fatih/color" ) var theme Theme func init() { if strings.ToUpper(os.Getenv("GPTCHAT_THEME")) == "DARK" { theme = DarkTheme } else { theme = LightTheme } } type Theme struct { Username *color.Color Message *color.Color Useful *color.Color AI *color.Color User *color.Color Info *color.Color Error *color.Color Warn *color.Color App *color.Color AppBold *color.Color } var LightTheme = Theme{ Username: color.New(color.FgRed), Message: color.New(color.FgBlue), Useful: color.New(color.FgWhite), AI: color.New(color.FgGreen), User: color.New(color.FgYellow), Info: color.New(color.FgWhite, color.Bold), Error: color.New(color.FgHiRed, color.Bold), Warn: color.New(color.FgHiYellow, color.Bold), App: color.New(color.FgWhite), AppBold: color.New(color.FgGreen, color.Bold), } var DarkTheme = Theme{ Username: color.New(color.FgRed), Message: color.New(color.FgBlue), Useful: color.New(color.FgBlack), AI: color.New(color.FgGreen), User: color.New(color.FgMagenta), Info: color.New(color.FgBlack, color.Bold), Error: color.New(color.FgHiRed, color.Bold), Warn: color.New(color.FgHiMagenta, color.Bold), App: color.New(color.FgBlack), AppBold: color.New(color.FgGreen, color.Bold), } ================================================ FILE: ui/ui.go ================================================ package ui import ( "bufio" "fmt" "os" "strings" ) const ( User = "USER" AI = "AI" System = "SYSTEM" Tool = "TOOL" API = "API" Module = "MODULE" App = "APP" ) func Error(message string, err error) { theme.Error.Printf("ERROR: ") theme.Useful.Printf("%s: %v\n\n", message, err) } func Warn(message string) { theme.Warn.Printf("WARNING: ") theme.Useful.Printf("%s\n", message) } func Info(message string) { theme.Warn.Printf("INFO: ") theme.Useful.Printf("%s\n", message) } func Welcome(title, message string) { theme.AppBold.Printf("%s\n\n", title) theme.App.Printf("%s\n\n", message) } func PrintChatDebug(name, message string) { theme.Useful.Printf("[DEBUG] ") PrintChat(name, message) } func PrintChat(name, message string) { switch name { case User: theme.User.Printf("%s:\n\n", name) theme.Message.Printf("%s\n", indent(message)) case AI: theme.AI.Printf("%s:\n\n", name) theme.Useful.Printf("%s\n", indent(message)) case App: theme.AppBold.Printf("%s:\n\n", name) theme.Useful.Printf("%s\n", indent(message)) case System: fallthrough case Tool: fallthrough case API: fallthrough case Module: fallthrough default: theme.Username.Printf("%s:\n\n", name) theme.Message.Printf("%s\n", indent(message)) } } func PromptChatInput() string { reader := bufio.NewReader(os.Stdin) theme.User.Printf("USER:\n\n ") text, _ := reader.ReadString('\n') text = strings.TrimSpace(text) fmt.Println() return text } func PromptConfirm(prompt string) bool { reader := bufio.NewReader(os.Stdin) theme.AppBold.Printf("%s [Y/N]: ", prompt) text, _ := reader.ReadString('\n') text = strings.TrimSpace(text) fmt.Println() return strings.ToUpper(text) == "Y" } func PromptInput(prompt string) string { reader := bufio.NewReader(os.Stdin) theme.AppBold.Printf("%s ", prompt) text, _ := reader.ReadString('\n') text = strings.TrimSpace(text) return text } func indent(input string) string { lines := strings.Split(string(input), "\n") var output string for _, line := range lines { output += " " + line + "\n" } return output } ================================================ FILE: util/strings.go ================================================ package util const SingleQuote = "`" const TripleQuote = "```"