├── 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.go └── plugin │ ├── compiled │ └── README.md │ ├── create.go │ └── source │ └── README.md ├── parser ├── cmd │ └── main.go ├── parser.go └── parser_test.go ├── ui ├── theme.go └── ui.go └── util └── strings.go /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Ian Kent 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | GPTChat 2 | ======= 3 | 4 | GPTChat is a client which gives GPT-4 some unique tools to be a better AI. 5 | 6 | With GPTChat, GPT-4 can: 7 | * remember useful information and recall it later 8 | * recall information without knowing it's previously remembered it 9 | * write it's own plugins and call them 10 | * decide to write plugins without being prompted 11 | * complete tasks by combining memories and plugins 12 | * use multi-step commands to complete complex tasks 13 | 14 | ### Getting started 15 | 16 | You'll need: 17 | 18 | * A working installation of Go (which you download from https://go.dev/) 19 | * An OpenAI account 20 | * An API key with access to the GPT-4 API 21 | 22 | If you don't have an API key, you can get one here: 23 | https://platform.openai.com/account/api-keys 24 | 25 | If you haven't joined the GPT-4 API waitlist, you can do that here: 26 | https://openai.com/waitlist/gpt-4-api 27 | 28 | Once you're ready: 29 | 30 | 1. Set the `OPENAI_API_KEY` environment variable to avoid the API key prompt on startup 31 | 2. Run GPTChat with `go run .` from the `gptchat` directory 32 | 3. Have fun! 33 | 34 | ## Memory 35 | 36 | GPT-4's context window is pretty small. 37 | 38 | GPTChat adds a long term memory which GPT-4 can use to remember useful information. 39 | 40 | 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. 41 | 42 | [See a GPT-4 memory demo on YouTube](https://www.youtube.com/watch?v=PUFZdM1nSTI) 43 | 44 | ## Plugins 45 | 46 | GPT-4 can write its own plugins to improve itself. 47 | 48 | For example, GPT-4 is pretty bad at math and generating random numbers. 49 | 50 | 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. 51 | 52 | [See a GPT-4 plugin demo on YouTube](https://www.youtube.com/watch?v=o7M-XH6tMhc) 53 | 54 | ℹ️ 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. 55 | 56 | ## Contributing 57 | 58 | PRs to add new features are welcome. 59 | 60 | Be careful of prompt changes - small changes can disrupt GPT-4's ability to use the commands correctly. 61 | 62 | ## Disclaimer 63 | 64 | You should supervise GPT-4's activity. 65 | 66 | In one experiment, GPT-4 gave itself internet access with a HTTP client plugin - this seemed like a bad idea. 67 | 68 | ### Supervised mode 69 | 70 | GPTChat will run in supervised mode by default. 71 | 72 | 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. 73 | 74 | ⚠️ Code written by GPT is untrusted code from the internet and potentially dangerous 75 | 76 | 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. 77 | 78 | Supervised mode can be disabled but I wouldn't recommend it. 79 | 80 | # License 81 | 82 | See [LICENSE.md](LICENSE.md) for more information. 83 | 84 | Copyright (c) 2023 Ian Kent -------------------------------------------------------------------------------- /chat_loop.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/ian-kent/gptchat/config" 10 | "github.com/ian-kent/gptchat/module" 11 | "github.com/ian-kent/gptchat/parser" 12 | "github.com/ian-kent/gptchat/ui" 13 | "github.com/ian-kent/gptchat/util" 14 | "github.com/sashabaranov/go-openai" 15 | ) 16 | 17 | func chatLoop(cfg config.Config) { 18 | RESET: 19 | appendMessage(openai.ChatMessageRoleSystem, systemPrompt) 20 | if cfg.IsDebugMode() { 21 | ui.PrintChatDebug(ui.System, systemPrompt) 22 | } 23 | 24 | var skipUserInput = true 25 | appendMessage(openai.ChatMessageRoleUser, openingPrompt) 26 | if cfg.IsDebugMode() { 27 | ui.PrintChatDebug(ui.User, openingPrompt) 28 | } 29 | 30 | if !cfg.IsDebugMode() { 31 | ui.PrintChat(ui.App, "Setting up the chat environment, please wait for GPT to respond - this may take a few moments.") 32 | } 33 | 34 | var i int 35 | for { 36 | i++ 37 | 38 | if !skipUserInput { 39 | input := ui.PromptChatInput() 40 | var echo bool 41 | 42 | ok, result := parseSlashCommand(input) 43 | if ok { 44 | // the command was handled but returned nothing 45 | // to send to the AI, let's prompt the user again 46 | if result == nil { 47 | continue 48 | } 49 | 50 | if result.resetConversation { 51 | resetConversation() 52 | goto RESET 53 | } 54 | 55 | // if the result is a retry, we can just send the 56 | // same request to GPT again 57 | if result.retry { 58 | skipUserInput = true 59 | goto RETRY 60 | } 61 | 62 | if result.toggleDebugMode { 63 | cfg = cfg.WithDebugMode(!cfg.IsDebugMode()) 64 | module.UpdateConfig(cfg) 65 | if cfg.IsDebugMode() { 66 | ui.PrintChat(ui.App, "Debug mode is now enabled") 67 | } else { 68 | ui.PrintChat(ui.App, "Debug mode is now disabled") 69 | } 70 | continue 71 | } 72 | 73 | if result.toggleSupervisedMode { 74 | cfg = cfg.WithSupervisedMode(!cfg.IsSupervisedMode()) 75 | module.UpdateConfig(cfg) 76 | if cfg.IsSupervisedMode() { 77 | ui.PrintChat(ui.App, "Supervised mode is now enabled") 78 | } else { 79 | ui.PrintChat(ui.App, "Supervised mode is now disabled") 80 | } 81 | continue 82 | } 83 | 84 | // we have a prompt to give to the AI, let's do that 85 | if result.prompt != "" { 86 | input = result.prompt 87 | echo = true 88 | } 89 | } 90 | 91 | if echo { 92 | ui.PrintChat(ui.User, input) 93 | echo = false 94 | } 95 | 96 | appendMessage(openai.ChatMessageRoleUser, input) 97 | } 98 | 99 | skipUserInput = false 100 | 101 | RETRY: 102 | 103 | // Occasionally include the interval prompt 104 | if i%5 == 0 { 105 | interval := intervalPrompt() 106 | appendMessage(openai.ChatMessageRoleSystem, interval) 107 | if cfg.IsDebugMode() { 108 | ui.PrintChatDebug(ui.System, interval) 109 | } 110 | } 111 | 112 | attempts := 1 113 | RATELIMIT_RETRY: 114 | resp, err := client.CreateChatCompletion( 115 | context.Background(), 116 | openai.ChatCompletionRequest{ 117 | Model: cfg.OpenAIAPIModel(), 118 | Messages: conversation, 119 | }, 120 | ) 121 | if err != nil { 122 | if strings.HasPrefix(err.Error(), "error, status code: 429") && attempts < 5 { 123 | attempts++ 124 | ui.Error("rate limited, trying again in 1 second", err) 125 | time.Sleep(time.Second) 126 | goto RATELIMIT_RETRY 127 | } 128 | 129 | ui.Error("ChatCompletion failed", err) 130 | if ui.PromptConfirm("Would you like to try again?") { 131 | goto RATELIMIT_RETRY 132 | } 133 | 134 | continue 135 | } 136 | 137 | response := resp.Choices[0].Message.Content 138 | appendMessage(openai.ChatMessageRoleAssistant, response) 139 | if cfg.IsDebugMode() { 140 | ui.PrintChat(ui.AI, response) 141 | } 142 | 143 | parseResult := parser.Parse(response) 144 | 145 | if !cfg.IsDebugMode() && parseResult.Chat != "" { 146 | ui.PrintChat(ui.AI, parseResult.Chat) 147 | } 148 | 149 | for _, command := range parseResult.Commands { 150 | ok, result := module.ExecuteCommand(command.Command, command.Args, command.Body) 151 | if ok { 152 | // we had at least one AI command so we're going to respond automatically, 153 | // no need to ask for user input 154 | skipUserInput = true 155 | 156 | if result.Error != nil { 157 | msg := fmt.Sprintf(`An error occurred executing your command. 158 | 159 | The command was: 160 | `+util.TripleQuote+` 161 | %s 162 | `+util.TripleQuote+` 163 | 164 | The error was: 165 | `+util.TripleQuote+` 166 | %s 167 | `+util.TripleQuote, command.String(), result.Error.Error()) 168 | 169 | if result.Prompt != "" { 170 | msg += fmt.Sprintf(` 171 | 172 | The command provided this additional output: 173 | `+util.TripleQuote+` 174 | %s 175 | `+util.TripleQuote, result.Prompt) 176 | } 177 | 178 | appendMessage(openai.ChatMessageRoleSystem, msg) 179 | if cfg.IsDebugMode() { 180 | ui.PrintChatDebug(ui.Module, msg) 181 | } 182 | continue 183 | } 184 | 185 | commandResult := fmt.Sprintf(`Your command returned some output. 186 | 187 | The command was: 188 | `+util.TripleQuote+` 189 | %s 190 | `+util.TripleQuote+` 191 | 192 | The output was: 193 | 194 | %s`, command.String(), result.Prompt) 195 | appendMessage(openai.ChatMessageRoleSystem, commandResult) 196 | 197 | if cfg.IsDebugMode() { 198 | ui.PrintChatDebug(ui.Module, commandResult) 199 | } 200 | continue 201 | } 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ian-kent/gptchat/ui" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | type slashCommandResult struct { 11 | // the prompt to send to the AI 12 | prompt string 13 | 14 | // retry tells the client to resend the most recent conversation 15 | retry bool 16 | 17 | // resetConversation will reset the conversation to its original state, 18 | // forgetting the conversation history 19 | resetConversation bool 20 | 21 | // toggleDebugMode will switch between debug on and debug off 22 | toggleDebugMode bool 23 | 24 | // toggleSupervisedMode will switch between supervised mode on and off 25 | toggleSupervisedMode bool 26 | } 27 | 28 | type slashCommand struct { 29 | command string 30 | fn func(string) (bool, *slashCommandResult) 31 | } 32 | 33 | var slashCommands = []slashCommand{ 34 | { 35 | command: "exit", 36 | fn: func(s string) (bool, *slashCommandResult) { 37 | os.Exit(0) 38 | return true, nil 39 | }, 40 | }, 41 | { 42 | command: "retry", 43 | fn: func(s string) (bool, *slashCommandResult) { 44 | return true, &slashCommandResult{ 45 | retry: true, 46 | } 47 | }, 48 | }, 49 | { 50 | command: "reset", 51 | fn: func(s string) (bool, *slashCommandResult) { 52 | return true, &slashCommandResult{ 53 | resetConversation: true, 54 | } 55 | }, 56 | }, 57 | { 58 | command: "debug", 59 | fn: func(s string) (bool, *slashCommandResult) { 60 | return true, &slashCommandResult{ 61 | toggleDebugMode: true, 62 | } 63 | }, 64 | }, 65 | { 66 | command: "supervisor", 67 | fn: func(s string) (bool, *slashCommandResult) { 68 | return true, &slashCommandResult{ 69 | toggleSupervisedMode: true, 70 | } 71 | }, 72 | }, 73 | { 74 | command: "example", 75 | fn: exampleCommand, 76 | }, 77 | } 78 | 79 | func helpCommand(string) (bool, *slashCommandResult) { 80 | result := "The following commands are available:\n" 81 | for _, e := range slashCommands { 82 | result += fmt.Sprintf("\n /%s", e.command) 83 | } 84 | 85 | ui.PrintChat(ui.App, result) 86 | 87 | return true, nil 88 | } 89 | 90 | func parseSlashCommand(input string) (ok bool, result *slashCommandResult) { 91 | if !strings.HasPrefix(input, "/") { 92 | return false, nil 93 | } 94 | 95 | input = strings.TrimPrefix(input, "/") 96 | 97 | if input == "help" { 98 | return helpCommand(input) 99 | } 100 | 101 | parts := strings.SplitN(input, " ", 2) 102 | var cmd, args string 103 | cmd = parts[0] 104 | if len(parts) > 1 { 105 | args = parts[1] 106 | } 107 | 108 | for _, command := range slashCommands { 109 | if command.command == cmd { 110 | return command.fn(args) 111 | } 112 | } 113 | 114 | return false, nil 115 | } 116 | 117 | type example struct { 118 | id, prompt string 119 | } 120 | 121 | var examples = []example{ 122 | { 123 | id: "1", 124 | prompt: "I want you to generate 5 random numbers and add them together.", 125 | }, 126 | { 127 | id: "2", 128 | prompt: "I want you to generate 5 random numbers. Multiply the first and second number, then add the result to the remaining numbers.", 129 | }, 130 | { 131 | id: "3", 132 | prompt: "I want you to generate 2 random numbers. Add them together then multiply the result by -1.", 133 | }, 134 | { 135 | id: "4", 136 | prompt: "Can you summarise the tools you have available?", 137 | }, 138 | { 139 | id: "5", 140 | prompt: "Can you suggest a task which might somehow use all of the available tools?", 141 | }, 142 | } 143 | 144 | func exampleCommand(args string) (bool, *slashCommandResult) { 145 | for _, e := range examples { 146 | if e.id == args { 147 | return true, &slashCommandResult{ 148 | prompt: e.prompt, 149 | } 150 | } 151 | } 152 | 153 | result := "The following examples are available:" 154 | for _, e := range examples { 155 | result += fmt.Sprintf("\n\n/example %s\n %s", e.id, e.prompt) 156 | } 157 | 158 | ui.PrintChat(ui.App, result) 159 | 160 | return true, nil 161 | } 162 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | openaiAPIKey string 5 | openaiAPIModel string 6 | 7 | supervisedMode bool 8 | debugMode bool 9 | } 10 | 11 | func New() Config { 12 | return Config{ 13 | openaiAPIKey: "", 14 | openaiAPIModel: "", 15 | supervisedMode: true, 16 | debugMode: false, 17 | } 18 | } 19 | 20 | func (c Config) OpenAIAPIModel() string { 21 | return c.openaiAPIModel 22 | } 23 | 24 | func (c Config) OpenAIAPIKey() string { 25 | return c.openaiAPIKey 26 | } 27 | 28 | func (c Config) IsSupervisedMode() bool { 29 | return c.supervisedMode 30 | } 31 | 32 | func (c Config) IsDebugMode() bool { 33 | return c.debugMode 34 | } 35 | 36 | func (c Config) WithOpenAIAPIKey(apiKey string) Config { 37 | c.openaiAPIKey = apiKey 38 | return c 39 | } 40 | 41 | func (c Config) WithSupervisedMode(supervisedMode bool) Config { 42 | c.supervisedMode = supervisedMode 43 | return c 44 | } 45 | 46 | func (c Config) WithDebugMode(debugMode bool) Config { 47 | c.debugMode = debugMode 48 | return c 49 | } 50 | 51 | func (c Config) WithOpenAIAPIModel(apiModel string) Config { 52 | c.openaiAPIModel = apiModel 53 | return c 54 | } 55 | -------------------------------------------------------------------------------- /conversation.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ian-kent/gptchat/util" 6 | "github.com/sashabaranov/go-openai" 7 | "time" 8 | ) 9 | 10 | var systemPrompt = `You are a helpful assistant. 11 | 12 | You enjoy conversations with the user and like asking follow up questions to gather more information. 13 | 14 | You have commands available which you can use to help me. 15 | 16 | You can call these commands using the slash command syntax, for example, this is how you call the help command: 17 | 18 | ` + util.TripleQuote + ` 19 | /help 20 | ` + util.TripleQuote + ` 21 | 22 | The /help command will give you a list of the commands you have available. 23 | 24 | Commands can also include a request body, for example, this is an example of a command which takes an input: 25 | 26 | ` + util.TripleQuote + ` 27 | /example 28 | { 29 | "expr": "value" 30 | } 31 | ` + util.TripleQuote + ` 32 | 33 | Most commands also have subcommands, and this is an example of how you call a subcommand: 34 | 35 | ` + util.TripleQuote + ` 36 | /example subcommand 37 | { 38 | "expr": "value" 39 | } 40 | ` + util.TripleQuote + ` 41 | 42 | 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.` 43 | 44 | const openingPrompt = `Hello! Please familiarise yourself with the commands you have available. 45 | 46 | You must do this before we have a conversation.` 47 | 48 | func intervalPrompt() string { 49 | return fmt.Sprintf(`The current date and time is %s. 50 | 51 | Remember that the '/help' command will tell you what commands you have available.`, time.Now().Format("02 January 2006, 03:04pm")) 52 | } 53 | 54 | var conversation []openai.ChatCompletionMessage 55 | 56 | func appendMessage(role string, message string) { 57 | conversation = append(conversation, openai.ChatCompletionMessage{ 58 | Role: role, 59 | Content: message, 60 | }) 61 | } 62 | 63 | func resetConversation() { 64 | conversation = []openai.ChatCompletionMessage{} 65 | } 66 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ian-kent/gptchat 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.1.1 // indirect 7 | github.com/dlclark/regexp2 v1.7.0 // indirect 8 | github.com/dop251/goja v0.0.0-20230304130813-e2f543bf4b4c // indirect 9 | github.com/fatih/color v1.15.0 // indirect 10 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect 11 | github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect 12 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 13 | github.com/mattn/go-colorable v0.1.13 // indirect 14 | github.com/mattn/go-isatty v0.0.17 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | github.com/sashabaranov/go-openai v1.5.7 // indirect 17 | github.com/spf13/cobra v1.6.1 // indirect 18 | github.com/spf13/pflag v1.0.5 // indirect 19 | github.com/stretchr/testify v1.8.2 // indirect 20 | golang.org/x/sys v0.6.0 // indirect 21 | golang.org/x/text v0.3.8 // indirect 22 | gopkg.in/yaml.v3 v3.0.1 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= 2 | github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= 3 | github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 10 | github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= 11 | github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 12 | github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= 13 | github.com/dop251/goja v0.0.0-20230304130813-e2f543bf4b4c h1:/utv6nmTctV6OVgfk5+O6lEMEWL+6KJy4h9NZ5fnkQQ= 14 | github.com/dop251/goja v0.0.0-20230304130813-e2f543bf4b4c/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= 15 | github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= 16 | github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= 17 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 18 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 19 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= 20 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 21 | github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= 22 | github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= 23 | github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= 24 | github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= 25 | github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 26 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 27 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 28 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 29 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 30 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 31 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 32 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 33 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 34 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 35 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 36 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 37 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 38 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 39 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 40 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 41 | github.com/sashabaranov/go-openai v1.5.7 h1:8DGgRG+P7yWixte5j720y6yiXgY3Hlgcd0gcpHdltfo= 42 | github.com/sashabaranov/go-openai v1.5.7/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 43 | github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= 44 | github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= 45 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 46 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 47 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 48 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 49 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 50 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 51 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 52 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 53 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 54 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 55 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 56 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 57 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 58 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 59 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 60 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 61 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 62 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 63 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 64 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 65 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 71 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 72 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 73 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 74 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 75 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 76 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 77 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 78 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 79 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 80 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 81 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 82 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 83 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 84 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 85 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 86 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 87 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 88 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 89 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 90 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 91 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 92 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/ian-kent/gptchat/config" 10 | "github.com/ian-kent/gptchat/module" 11 | "github.com/ian-kent/gptchat/module/memory" 12 | "github.com/ian-kent/gptchat/module/plugin" 13 | "github.com/ian-kent/gptchat/ui" 14 | openai "github.com/sashabaranov/go-openai" 15 | ) 16 | 17 | var client *openai.Client 18 | var cfg = config.New() 19 | 20 | func init() { 21 | openaiAPIKey := strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) 22 | if openaiAPIKey == "" { 23 | ui.Warn("You haven't configured an OpenAI API key") 24 | fmt.Println() 25 | if !ui.PromptConfirm("Do you have an API key?") { 26 | ui.Warn("You'll need an API key to use GPTChat") 27 | fmt.Println() 28 | fmt.Println("* You can get an API key at https://platform.openai.com/account/api-keys") 29 | fmt.Println("* You can get join the GPT-4 API waitlist at https://openai.com/waitlist/gpt-4-api") 30 | os.Exit(1) 31 | } 32 | 33 | openaiAPIKey = ui.PromptInput("Enter your API key:") 34 | if openaiAPIKey == "" { 35 | fmt.Println("") 36 | ui.Warn("You didn't enter an API key.") 37 | os.Exit(1) 38 | } 39 | } 40 | 41 | cfg = cfg.WithOpenAIAPIKey(openaiAPIKey) 42 | 43 | openaiAPIModel := strings.TrimSpace(os.Getenv("OPENAI_API_MODEL")) 44 | 45 | if openaiAPIModel == "" { 46 | ui.Warn("You haven't configured an OpenAI API model, defaulting to GPT4") 47 | 48 | openaiAPIModel = openai.GPT4 49 | } 50 | 51 | cfg = cfg.WithOpenAIAPIModel(openaiAPIModel) 52 | 53 | supervisorMode := os.Getenv("GPTCHAT_SUPERVISOR") 54 | switch strings.ToLower(supervisorMode) { 55 | case "disabled": 56 | ui.Warn("Supervisor mode is disabled") 57 | cfg = cfg.WithSupervisedMode(false) 58 | default: 59 | } 60 | 61 | debugEnv := os.Getenv("GPTCHAT_DEBUG") 62 | if debugEnv != "" { 63 | v, err := strconv.ParseBool(debugEnv) 64 | if err != nil { 65 | ui.Warn(fmt.Sprintf("error parsing GPT_DEBUG: %s", err.Error())) 66 | } else { 67 | cfg = cfg.WithDebugMode(v) 68 | } 69 | } 70 | 71 | client = openai.NewClient(openaiAPIKey) 72 | 73 | module.Load(cfg, client, []module.Module{ 74 | &memory.Module{}, 75 | &plugin.Module{}, 76 | }...) 77 | 78 | if err := module.LoadCompiledPlugins(); err != nil { 79 | ui.Warn(fmt.Sprintf("error loading compiled plugins: %s", err)) 80 | } 81 | } 82 | 83 | func main() { 84 | ui.Welcome( 85 | `Welcome to the GPT client.`, 86 | `You can talk directly to GPT, or you can use /commands to interact with the client. 87 | 88 | Use /help to see a list of available commands.`) 89 | 90 | chatLoop(cfg) 91 | } 92 | -------------------------------------------------------------------------------- /module/memory/memory.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/ian-kent/gptchat/config" 8 | "github.com/ian-kent/gptchat/util" 9 | openai "github.com/sashabaranov/go-openai" 10 | ) 11 | 12 | type memory struct { 13 | DateStored string `json:"date_stored"` 14 | Memory string `json:"memory"` 15 | } 16 | 17 | type Module struct { 18 | cfg config.Config 19 | client *openai.Client 20 | memories []memory 21 | } 22 | 23 | func (m *Module) ID() string { 24 | return "memory" 25 | } 26 | 27 | func (m *Module) Load(cfg config.Config, client *openai.Client) error { 28 | m.cfg = cfg 29 | m.client = client 30 | return m.loadFromFile() 31 | } 32 | 33 | func (m *Module) UpdateConfig(cfg config.Config) { 34 | m.cfg = cfg 35 | } 36 | 37 | func (m *Module) Execute(args, body string) (string, error) { 38 | switch args { 39 | case "store": 40 | return m.Store(body) 41 | case "recall": 42 | return m.Recall(body) 43 | default: 44 | return "", errors.New(fmt.Sprintf("command not implemented: /memory %s", args)) 45 | } 46 | } 47 | 48 | func (m *Module) Prompt() string { 49 | return memoryPrompt 50 | } 51 | 52 | const memoryPrompt = `You also have a working long term memory. 53 | 54 | You can remember something using the '/memory store' command, or you can recall it using the '/memory recall' command. 55 | 56 | For example, if you want to store a fact I give you, you might reply with a message such as: 57 | 58 | ` + util.TripleQuote + ` 59 | /memory store { 60 | "memory": "I bought cookies yesterday", 61 | "context": "The user was discussing what they'd eaten" 62 | } 63 | ` + util.TripleQuote + ` 64 | 65 | 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. 66 | 67 | 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: 68 | 69 | ` + util.TripleQuote + ` 70 | /memory recall { 71 | When did I buy cookies? 72 | } 73 | ` + util.TripleQuote + ` 74 | 75 | or, another example might be: 76 | 77 | ` + util.TripleQuote + ` 78 | /memory recall { 79 | What did I buy yesterday? 80 | } 81 | ` + util.TripleQuote + ` 82 | 83 | 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. 84 | 85 | 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. 86 | 87 | 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. 88 | 89 | You must not remember the current date. The current date changes and is not a useful memory.` 90 | -------------------------------------------------------------------------------- /module/memory/recall.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/ian-kent/gptchat/util" 8 | "github.com/sashabaranov/go-openai" 9 | ) 10 | 11 | func (m *Module) Recall(input string) (string, error) { 12 | b, err := json.Marshal(m.memories) 13 | if err != nil { 14 | return "", err 15 | } 16 | 17 | resp, err := m.client.CreateChatCompletion( 18 | context.Background(), 19 | openai.ChatCompletionRequest{ 20 | Model: m.cfg.OpenAIAPIModel(), 21 | Messages: []openai.ChatCompletionMessage{ 22 | { 23 | Role: openai.ChatMessageRoleSystem, 24 | Content: `You are a helpful assistant. 25 | 26 | I'll give you a list of existing memories, and a prompt which asks you to identify the memory I'm looking for. 27 | 28 | You should review the listed memories and suggest which memories might match the request.`, 29 | }, 30 | { 31 | Role: openai.ChatMessageRoleSystem, 32 | Content: `Here are your memories in JSON format: 33 | 34 | ` + util.TripleQuote + ` 35 | ` + string(b) + ` 36 | ` + util.TripleQuote, 37 | }, 38 | { 39 | Role: openai.ChatMessageRoleSystem, 40 | Content: `Help me find any memories which may match this request: 41 | 42 | ` + util.TripleQuote + ` 43 | ` + input + ` 44 | ` + util.TripleQuote, 45 | }, 46 | }, 47 | }, 48 | ) 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | response := resp.Choices[0].Message.Content 54 | return `You have successfully recalled this memory: 55 | 56 | ` + util.TripleQuote + ` 57 | ` + response + ` 58 | ` + util.TripleQuote, nil 59 | 60 | // TODO find a prompt which gets GPT to adjust relative time 61 | 62 | // return `You have successfully recalled this memory: 63 | // 64 | //` + util.TripleQuote + ` 65 | //` + response + ` 66 | //` + util.TripleQuote + ` 67 | // 68 | //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. 69 | // 70 | //For example, if the memory says "tomorrow" and the memory was stored on 25th, the memory is actually referring to 26th.`, nil 71 | } 72 | -------------------------------------------------------------------------------- /module/memory/storage.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | ) 8 | 9 | func (m *Module) loadFromFile() error { 10 | _, err := os.Stat("memories.json") 11 | if os.IsNotExist(err) { 12 | return nil 13 | } 14 | 15 | b, err := ioutil.ReadFile("memories.json") 16 | if err != nil { 17 | return err 18 | } 19 | 20 | err = json.Unmarshal(b, &m.memories) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | return nil 26 | } 27 | 28 | func (m *Module) writeToFile() error { 29 | b, err := json.Marshal(m.memories) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | err = ioutil.WriteFile("memories.json", b, 0660) 35 | if err != nil { 36 | return err 37 | } 38 | return nil 39 | } 40 | 41 | func (m *Module) appendMemory(mem memory) error { 42 | m.memories = append(m.memories, mem) 43 | return m.writeToFile() 44 | } 45 | -------------------------------------------------------------------------------- /module/memory/store.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "github.com/ian-kent/gptchat/util" 5 | "time" 6 | ) 7 | 8 | func (m *Module) Store(input string) (string, error) { 9 | err := m.appendMemory(memory{ 10 | DateStored: time.Now().Format("02 January 2006, 03:04pm"), 11 | Memory: input, 12 | }) 13 | if err != nil { 14 | return "", err 15 | } 16 | 17 | return `You have successfully stored this memory: 18 | 19 | ` + util.TripleQuote + ` 20 | ` + input + ` 21 | ` + util.TripleQuote, nil 22 | } 23 | -------------------------------------------------------------------------------- /module/module.go: -------------------------------------------------------------------------------- 1 | package module 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/ian-kent/gptchat/config" 7 | "github.com/ian-kent/gptchat/ui" 8 | openai "github.com/sashabaranov/go-openai" 9 | "strings" 10 | ) 11 | 12 | type Module interface { 13 | Load(config.Config, *openai.Client) error 14 | UpdateConfig(config.Config) 15 | ID() string 16 | Prompt() string 17 | Execute(args, body string) (string, error) 18 | } 19 | 20 | // IntervalPrompt allows a module to inject a prompt into the interval prompt 21 | type IntervalPrompt interface { 22 | IntervalPrompt() string 23 | } 24 | 25 | var loadedModules = make(map[string]Module) 26 | 27 | func Load(cfg config.Config, client *openai.Client, modules ...Module) error { 28 | for _, module := range modules { 29 | if err := module.Load(cfg, client); err != nil { 30 | ui.Warn(fmt.Sprintf("failed to load module %s: %s", module.ID(), err)) 31 | continue 32 | } 33 | if cfg.IsDebugMode() { 34 | ui.Info(fmt.Sprintf("loaded module %s", module.ID())) 35 | } 36 | loadedModules[module.ID()] = module 37 | } 38 | return nil 39 | } 40 | 41 | func UpdateConfig(cfg config.Config) { 42 | for _, module := range loadedModules { 43 | _, ok := module.(pluginLoader) 44 | if ok { 45 | // GPT written plugins shouldn't have config, nothing to do 46 | continue 47 | } 48 | 49 | module.UpdateConfig(cfg) 50 | } 51 | } 52 | 53 | func IsLoaded(id string) bool { 54 | _, ok := loadedModules[id] 55 | return ok 56 | } 57 | 58 | func LoadPlugin(m Module) error { 59 | // a plugin doesn't have access to the openai client so it's safe to pass in nil here 60 | // 61 | // we also don't pass in the config since it may contain sensitive information that 62 | // we don't want GPT to have access to 63 | return Load(config.Config{}, nil, m) 64 | } 65 | 66 | type CommandResult struct { 67 | Error error 68 | Prompt string 69 | } 70 | 71 | func HelpCommand() (bool, *CommandResult) { 72 | result := "Here are the commands you have available:\n\n" 73 | for _, mod := range loadedModules { 74 | result += fmt.Sprintf(" * /%s\n", mod.ID()) 75 | } 76 | result += ` 77 | You can call commands using the /command syntax. 78 | 79 | Calling a command without any additional arguments will explain it's usage. You should do this to learn how the command works.` 80 | 81 | return true, &CommandResult{ 82 | Prompt: result, 83 | } 84 | } 85 | 86 | func ExecuteCommand(command, args, body string) (bool, *CommandResult) { 87 | if command == "/help" { 88 | return HelpCommand() 89 | } 90 | 91 | cmd := strings.TrimPrefix(command, "/") 92 | mod, ok := loadedModules[cmd] 93 | if !ok { 94 | return true, &CommandResult{ 95 | Error: errors.New(fmt.Sprintf("Unrecognised command: %s", command)), 96 | } 97 | } 98 | 99 | if args == "" && body == "" { 100 | return true, &CommandResult{ 101 | Prompt: mod.Prompt(), 102 | } 103 | } 104 | 105 | res, err := mod.Execute(args, body) 106 | if err != nil { 107 | return true, &CommandResult{ 108 | Error: err, 109 | } 110 | } 111 | 112 | return true, &CommandResult{ 113 | Prompt: res, 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /module/plugin.go: -------------------------------------------------------------------------------- 1 | package module 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "github.com/ian-kent/gptchat/config" 8 | "github.com/ian-kent/gptchat/ui" 9 | "github.com/sashabaranov/go-openai" 10 | "os" 11 | "plugin" 12 | "strings" 13 | ) 14 | 15 | type Plugin interface { 16 | ID() string 17 | Example() string 18 | Execute(map[string]any) (map[string]any, error) 19 | } 20 | 21 | type pluginLoader struct { 22 | plugin Plugin 23 | } 24 | 25 | func (p pluginLoader) Load(config.Config, *openai.Client) error { 26 | return nil 27 | } 28 | func (p pluginLoader) UpdateConfig(config.Config) {} 29 | func (p pluginLoader) ID() string { 30 | return p.plugin.ID() 31 | } 32 | func (p pluginLoader) Prompt() string { 33 | return p.plugin.Example() 34 | } 35 | func (p pluginLoader) Execute(args, body string) (string, error) { 36 | input := make(map[string]any) 37 | if body != "" { 38 | err := json.Unmarshal([]byte(body), &input) 39 | if err != nil { 40 | return "", fmt.Errorf("plugin body must be valid json: %s", err) 41 | } 42 | } 43 | 44 | output, err := p.plugin.Execute(input) 45 | if err != nil { 46 | return "", fmt.Errorf("error executing plugin: %s", err) 47 | } 48 | 49 | b, err := json.Marshal(output) 50 | if err != nil { 51 | return "", fmt.Errorf("error converting plugin output to json: %s", err) 52 | } 53 | 54 | return string(b), nil 55 | } 56 | 57 | func GetModuleForPlugin(p Plugin) Module { 58 | return pluginLoader{p} 59 | } 60 | 61 | func LoadCompiledPlugins() error { 62 | pluginPath := "./module/plugin/compiled/" 63 | entries, err := os.ReadDir(pluginPath) 64 | if err != nil { 65 | return fmt.Errorf("error loading compiled plugins: %s", err) 66 | } 67 | 68 | for _, entry := range entries { 69 | if !strings.HasSuffix(entry.Name(), ".so") { 70 | continue 71 | } 72 | 73 | loadedPlugin, err := OpenPlugin(pluginPath + entry.Name()) 74 | if err != nil { 75 | ui.Warn(fmt.Sprintf("error opening plugin: %s", err)) 76 | continue 77 | } 78 | 79 | pluginID := loadedPlugin.ID() 80 | if IsLoaded(pluginID) { 81 | ui.Warn(fmt.Sprintf("plugin with this ID is already loaded: %s", err)) 82 | continue 83 | } 84 | 85 | err = LoadPlugin(GetModuleForPlugin(loadedPlugin)) 86 | if err != nil { 87 | ui.Warn(fmt.Sprintf("error loading plugin: %s", err)) 88 | continue 89 | } 90 | } 91 | 92 | return nil 93 | } 94 | 95 | func OpenPlugin(path string) (Plugin, error) { 96 | p, err := plugin.Open(path) 97 | if err != nil { 98 | return nil, fmt.Errorf("error loading plugin: %s", err) 99 | } 100 | 101 | apiSymbol, err := p.Lookup("Plugin") 102 | if err != nil { 103 | return nil, fmt.Errorf("error finding plugin implementation: %s", err) 104 | } 105 | 106 | // Cast the symbol to the ScriptAPI interface 107 | api, ok := apiSymbol.(*Plugin) 108 | if !ok { 109 | return nil, errors.New("plugin does not implement the Plugin interface") 110 | } 111 | 112 | loadedPlugin := *api 113 | return loadedPlugin, nil 114 | } 115 | -------------------------------------------------------------------------------- /module/plugin/compiled/README.md: -------------------------------------------------------------------------------- 1 | Compiled plugins will be added to this directory. -------------------------------------------------------------------------------- /module/plugin/create.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/fatih/color" 7 | "github.com/ian-kent/gptchat/config" 8 | "github.com/ian-kent/gptchat/module" 9 | "github.com/ian-kent/gptchat/ui" 10 | "github.com/ian-kent/gptchat/util" 11 | openai "github.com/sashabaranov/go-openai" 12 | "io/ioutil" 13 | "os" 14 | "os/exec" 15 | "strings" 16 | ) 17 | 18 | var ( 19 | // TODO make this configurable 20 | PluginSourcePath = "./module/plugin/source" 21 | PluginCompilePath = "./module/plugin/compiled" 22 | ) 23 | 24 | var ErrPluginSourcePathMissing = errors.New("plugin source path is missing") 25 | var ErrPluginCompilePathMissing = errors.New("plugin compiled path is missing") 26 | 27 | func CheckPaths() error { 28 | _, err := os.Stat(PluginSourcePath) 29 | if err != nil && !os.IsNotExist(err) { 30 | return err 31 | } 32 | if err != nil { 33 | return ErrPluginSourcePathMissing 34 | } 35 | 36 | _, err = os.Stat(PluginCompilePath) 37 | if err != nil && !os.IsNotExist(err) { 38 | return err 39 | } 40 | if err != nil { 41 | return ErrPluginCompilePathMissing 42 | } 43 | 44 | return nil 45 | } 46 | 47 | type Module struct { 48 | cfg config.Config 49 | client *openai.Client 50 | } 51 | 52 | func (m *Module) Load(cfg config.Config, client *openai.Client) error { 53 | m.cfg = cfg 54 | m.client = client 55 | 56 | if err := CheckPaths(); err != nil { 57 | return err 58 | } 59 | 60 | return nil 61 | } 62 | 63 | func (m *Module) UpdateConfig(cfg config.Config) { 64 | m.cfg = cfg 65 | } 66 | 67 | func (m *Module) Prompt() string { 68 | return newPluginPrompt 69 | } 70 | 71 | func (m *Module) ID() string { 72 | return "plugin" 73 | } 74 | 75 | func (m *Module) Execute(args, body string) (string, error) { 76 | parts := strings.SplitN(args, " ", 2) 77 | cmd := parts[0] 78 | if len(parts) > 1 { 79 | args = parts[1] 80 | } 81 | 82 | switch cmd { 83 | case "create": 84 | return m.createPlugin(args, body) 85 | default: 86 | return "", errors.New(fmt.Sprintf("%s not implemented", args)) 87 | } 88 | } 89 | 90 | func (m *Module) createPlugin(id, body string) (string, error) { 91 | body = strings.TrimSpace(body) 92 | if len(body) == 0 { 93 | return "", errors.New("plugin source not found") 94 | } 95 | 96 | if !strings.HasPrefix(body, "{") || !strings.HasSuffix(body, "}") { 97 | return "", errors.New("plugin source must be between {} in '/plugin create plugin-id {}' command") 98 | } 99 | 100 | id = strings.TrimSpace(id) 101 | if id == "" { 102 | return "", errors.New("plugin id is invalid") 103 | } 104 | 105 | if module.IsLoaded(id) { 106 | return "", errors.New("a plugin with this id already exists") 107 | } 108 | 109 | source := strings.TrimPrefix(strings.TrimSuffix(body, "}"), "{") 110 | 111 | pluginSourceDir := PluginSourcePath + "/" + id 112 | _, err := os.Stat(pluginSourceDir) 113 | if err != nil && !os.IsNotExist(err) { 114 | return "", fmt.Errorf("error checking if directory exists: %s", err) 115 | } 116 | // only create the directory if it doesn't exist; it's possible GPT had a compile 117 | // error in the last attempt in which case we can overwrite it 118 | // 119 | // we don't get this if the last attempt was successful since it'll show up 120 | // as a loaded plugin in the check above 121 | if os.IsNotExist(err) { 122 | // if err is nil then the directory doesn't exist, let's create it 123 | err := os.Mkdir(pluginSourceDir, 0777) 124 | if err != nil { 125 | return "", fmt.Errorf("error creating directory: %s", err) 126 | } 127 | } 128 | 129 | sourcePath := pluginSourceDir + "/plugin.go" 130 | err = ioutil.WriteFile(sourcePath, []byte(source), 0644) 131 | if err != nil { 132 | return "", fmt.Errorf("error writing source file: %s", err) 133 | } 134 | 135 | if m.cfg.IsSupervisedMode() { 136 | fmt.Println("============================================================") 137 | fmt.Println() 138 | ui.Warn("⚠️ GPT written plugins are untrusted code from the internet") 139 | fmt.Println() 140 | fmt.Println("You should review this code before allowing it to be compiled and executed.") 141 | fmt.Println() 142 | fmt.Println("If you allow this action, GPT is able to execute code with the same permissions as your user.") 143 | fmt.Println() 144 | color.New(color.FgHiWhite, color.Bold).Println("This is potentially dangerous.") 145 | fmt.Println() 146 | fmt.Println("The source code GPT has written can be found here:") 147 | fmt.Println(sourcePath) 148 | fmt.Println() 149 | confirmation := ui.PromptInput("Enter 'confirm' to confirm, anything else will block:") 150 | if confirmation != "confirm" { 151 | fmt.Println() 152 | fmt.Println("============================================================") 153 | return "The user has prevented you from running this code", errors.New(confirmation) 154 | } 155 | fmt.Println() 156 | fmt.Println("============================================================") 157 | fmt.Println() 158 | } 159 | 160 | pluginPath := PluginCompilePath + "/" + id + ".so" 161 | cmd := exec.Command("go", "build", "-buildmode=plugin", "-o", pluginPath, sourcePath) 162 | if b, err := cmd.CombinedOutput(); err != nil { 163 | return string(b), fmt.Errorf("error compiling plugin: %s", err) 164 | } 165 | 166 | loadedPlugin, err := module.OpenPlugin(pluginPath) 167 | if err != nil { 168 | return "", fmt.Errorf("error opening plugin: %s", err) 169 | } 170 | 171 | // Call the functions provided by the plugin 172 | compiledID := loadedPlugin.ID() 173 | if id != compiledID { 174 | return "", errors.New("ID() does not return the ID specified in the '/plugin create ' command") 175 | } 176 | 177 | err = module.LoadPlugin(module.GetModuleForPlugin(loadedPlugin)) 178 | if err != nil { 179 | return "", fmt.Errorf("error loading plugin: %s", err) 180 | } 181 | 182 | return `Great! Your plugin has been loaded successfully. 183 | 184 | Why don't you check the /help command to see if your new plugin is available.'`, nil 185 | } 186 | 187 | var newPluginPrompt = `You can add new plugins which you can call using a slash command. 188 | 189 | They're written in Go, so all you need to do is create a new struct which implements the correct interface. 190 | 191 | The interface you need to implement is: 192 | 193 | ` + util.TripleQuote + ` 194 | type Plugin interface { 195 | Example() string 196 | Execute(input map[string]any) (map[string]any, error) 197 | } 198 | ` + util.TripleQuote + ` 199 | 200 | You don't need to write any supporting code like the main function, you only need to implement the struct. 201 | 202 | Here's the full code for the "add 1" plugin you can use to guide your output: 203 | ` + util.TripleQuote + ` 204 | package main 205 | 206 | import "github.com/ian-kent/gptchat/module" 207 | 208 | var Plugin module.Plugin = AddOne{} 209 | 210 | type AddOne struct{} 211 | 212 | func (c AddOne) ID() string { 213 | return "add-one" 214 | } 215 | 216 | func (c AddOne) Example() string { 217 | return ` + util.SingleQuote + `/add-one { 218 | "value": 5 219 | }` + util.SingleQuote + ` 220 | } 221 | 222 | func (c AddOne) Execute(input map[string]any) (map[string]any, error) { 223 | value, ok := input["value"].(int) 224 | if !ok { 225 | return nil, nil 226 | } 227 | 228 | value = value + 1 229 | 230 | return map[string]any{ 231 | "result": value, 232 | }, nil 233 | } 234 | ` + util.TripleQuote + ` 235 | 236 | 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. 237 | 238 | 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. 239 | 240 | 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. 241 | 242 | To create a plugin, you should use the "/plugin create {}" command, for example: 243 | 244 | ` + util.TripleQuote + ` 245 | /plugin create add-one { 246 | package main 247 | 248 | // the rest of your plugin source here 249 | } 250 | ` + util.TripleQuote + ` 251 | 252 | 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.` 253 | -------------------------------------------------------------------------------- /module/plugin/source/README.md: -------------------------------------------------------------------------------- 1 | Plugin source will be added to this directory. -------------------------------------------------------------------------------- /parser/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ian-kent/gptchat/parser" 6 | "strings" 7 | ) 8 | 9 | func main() { 10 | input := `/plugins create my-plugin 11 | { 12 | package main 13 | 14 | import "fmt" 15 | 16 | func main() { 17 | fmt.Println("test") 18 | } 19 | }` 20 | 21 | tokens := parser.Lex(input) 22 | 23 | fmt.Println("Tokens:") 24 | for _, token := range tokens { 25 | fmt.Printf(" %20s => %s\n", token.Typ, token.Val) 26 | } 27 | 28 | fmt.Println() 29 | 30 | result := parser.ParseTokens(tokens) 31 | fmt.Println("Result:") 32 | fmt.Println(" Chat:") 33 | fmt.Println(indent(result.Chat, " ")) 34 | fmt.Println(" Commands:") 35 | for _, command := range result.Commands { 36 | fmt.Printf(" - Command: %s\n", command.Command) 37 | fmt.Printf(" - Args: %s\n", command.Args) 38 | fmt.Printf(" - Body:\n") 39 | fmt.Println(indent(command.Body, " ")) 40 | } 41 | } 42 | 43 | func indent(input string, prefix string) string { 44 | lines := strings.Split(string(input), "\n") 45 | var output string 46 | for _, line := range lines { 47 | output += prefix + line + "\n" 48 | } 49 | return output 50 | } 51 | -------------------------------------------------------------------------------- /parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import "strings" 4 | 5 | type ParseResult struct { 6 | Chat string 7 | Commands []ParseCommand 8 | } 9 | 10 | type ParseCommand struct { 11 | Command string 12 | Args string 13 | Body string 14 | } 15 | 16 | func (p ParseCommand) String() string { 17 | output := p.Command 18 | if p.Args != "" { 19 | output += " " + p.Args 20 | } 21 | if p.Body != "" { 22 | output += "\n" + p.Body 23 | } 24 | return output 25 | } 26 | 27 | func Parse(input string) ParseResult { 28 | tokens := Lex(input) 29 | return ParseTokens(tokens) 30 | } 31 | 32 | type TokenType string 33 | 34 | const ( 35 | Plaintext TokenType = "Plaintext" 36 | Newline = "Newline" 37 | Command = "Command" 38 | Body = "Body" 39 | ) 40 | 41 | type Token struct { 42 | Typ TokenType 43 | Val string 44 | } 45 | 46 | func ParseTokens(tokens []Token) ParseResult { 47 | result := ParseResult{} 48 | var activeCommand *ParseCommand 49 | var commands []*ParseCommand 50 | var isInCommandContext bool 51 | 52 | for _, token := range tokens { 53 | switch token.Typ { 54 | case Plaintext: 55 | if activeCommand == nil { 56 | result.Chat += token.Val 57 | isInCommandContext = false 58 | continue 59 | } 60 | if activeCommand.Args != "" { 61 | // ParseTokens error 62 | panic("ParseTokens error: Command already has args") 63 | } 64 | activeCommand.Args = strings.TrimSpace(token.Val) 65 | case Newline: 66 | if activeCommand != nil { 67 | activeCommand = nil 68 | continue 69 | } 70 | if strings.HasSuffix(result.Chat, "\n\n") { 71 | // we don't append more than two consecutive newlines 72 | continue 73 | } 74 | result.Chat += token.Val 75 | case Command: 76 | activeCommand = &ParseCommand{Command: token.Val} 77 | commands = append(commands, activeCommand) 78 | isInCommandContext = true 79 | case Body: 80 | if activeCommand != nil { 81 | if activeCommand.Body != "" { 82 | // ParseTokens error 83 | panic("ParseTokens error: Command already has Body") 84 | } 85 | activeCommand.Body = token.Val 86 | continue 87 | } 88 | if isInCommandContext { 89 | lastCommand := commands[len(commands)-1] 90 | if lastCommand.Body != "" { 91 | // ParseTokens error 92 | panic("ParseTokens error: Command already has Body") 93 | } 94 | lastCommand.Body = token.Val 95 | continue 96 | } 97 | 98 | result.Chat += token.Val 99 | } 100 | } 101 | 102 | result.Chat = strings.TrimSpace(result.Chat) 103 | for _, command := range commands { 104 | result.Commands = append(result.Commands, *command) 105 | } 106 | return result 107 | } 108 | 109 | func Lex(input string) []Token { 110 | var tokens []Token 111 | var currentToken *Token 112 | var nesting int 113 | 114 | for i, c := range input { 115 | switch c { 116 | case '/': 117 | // is this the start of a new Command? 118 | if i == 0 || input[i-1] == '\n' { 119 | if currentToken != nil { 120 | tokens = append(tokens, *currentToken) 121 | } 122 | currentToken = &Token{Typ: Command, Val: "/"} 123 | continue 124 | } 125 | // if we have a Token, append to it 126 | if currentToken != nil { 127 | currentToken.Val += string(c) 128 | continue 129 | } 130 | // otherwise we can assume this is plain text 131 | currentToken = &Token{Typ: Plaintext, Val: "/"} 132 | case ' ': 133 | // a space signifies the end of the Command 134 | if currentToken != nil && currentToken.Typ == Command { 135 | tokens = append(tokens, *currentToken) 136 | currentToken = nil 137 | continue 138 | } 139 | // if we have a Token, append to it 140 | if currentToken != nil { 141 | currentToken.Val += string(c) 142 | continue 143 | } 144 | // otherwise we can assume this is plain text 145 | currentToken = &Token{Typ: Plaintext, Val: " "} 146 | case '{': 147 | // if it's not already a Body, we'll store the current Token 148 | if currentToken != nil && currentToken.Typ != Body { 149 | tokens = append(tokens, *currentToken) 150 | } 151 | // If we already have a body, we'll add to it 152 | if currentToken != nil && currentToken.Typ == Body { 153 | nesting++ 154 | currentToken.Val += string(c) 155 | continue 156 | } 157 | // Otherwise we'll start a new body 158 | currentToken = &Token{Typ: Body, Val: "{"} 159 | nesting++ 160 | case '}': 161 | // if we're already in a Body, the Body ends 162 | if currentToken != nil && currentToken.Typ == Body { 163 | nesting-- 164 | currentToken.Val += "}" 165 | 166 | if nesting == 0 { 167 | tokens = append(tokens, *currentToken) 168 | currentToken = nil 169 | } 170 | continue 171 | } 172 | // if we have a Token, append to it 173 | if currentToken != nil { 174 | currentToken.Val += string(c) 175 | continue 176 | } 177 | // otherwise we can assume this is plain text 178 | currentToken = &Token{Typ: Plaintext, Val: " "} 179 | case '\n': 180 | // if we have Plaintext or a Body, we'll append to it 181 | if currentToken != nil && (currentToken.Typ == Body) { 182 | currentToken.Val += "\n" 183 | continue 184 | } 185 | 186 | // otherwise we always end the current Token at a new line 187 | if currentToken != nil { 188 | tokens = append(tokens, *currentToken) 189 | currentToken = nil 190 | } 191 | 192 | // and we store a new line Token 193 | tokens = append(tokens, Token{Typ: Newline, Val: "\n"}) 194 | default: 195 | if currentToken == nil { 196 | currentToken = &Token{Typ: Plaintext} 197 | } 198 | currentToken.Val += string(c) 199 | } 200 | } 201 | if currentToken != nil { 202 | tokens = append(tokens, *currentToken) 203 | } 204 | 205 | return tokens 206 | } 207 | -------------------------------------------------------------------------------- /parser/parser_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestParse(t *testing.T) { 9 | testCases := []struct { 10 | name string 11 | input string 12 | output ParseResult 13 | }{ 14 | { 15 | name: "basic Command", 16 | input: "/api get /path", 17 | output: ParseResult{ 18 | Chat: "", 19 | Commands: []ParseCommand{ 20 | { 21 | Command: "/api", 22 | Args: "get /path", 23 | Body: "", 24 | }, 25 | }, 26 | }, 27 | }, 28 | { 29 | name: "Command with args", 30 | input: "/api get /path { something }", 31 | output: ParseResult{ 32 | Chat: "", 33 | Commands: []ParseCommand{ 34 | { 35 | Command: "/api", 36 | Args: "get /path", 37 | Body: "{ something }", 38 | }, 39 | }, 40 | }, 41 | }, 42 | { 43 | name: "multiline Command with args", 44 | input: `/api get /path { 45 | something 46 | }`, 47 | output: ParseResult{ 48 | Chat: "", 49 | Commands: []ParseCommand{ 50 | { 51 | Command: "/api", 52 | Args: "get /path", 53 | Body: `{ 54 | something 55 | }`, 56 | }, 57 | }, 58 | }, 59 | }, 60 | { 61 | name: "multiline Command with args", 62 | input: `/api get /path 63 | { 64 | something 65 | }`, 66 | output: ParseResult{ 67 | Chat: "", 68 | Commands: []ParseCommand{ 69 | { 70 | Command: "/api", 71 | Args: "get /path", 72 | Body: `{ 73 | something 74 | }`, 75 | }, 76 | }, 77 | }, 78 | }, 79 | { 80 | name: "chat with multiline Command with args", 81 | input: `This is some chat 82 | 83 | /api get /path 84 | { 85 | something 86 | }`, 87 | output: ParseResult{ 88 | Chat: "This is some chat", 89 | Commands: []ParseCommand{ 90 | { 91 | Command: "/api", 92 | Args: "get /path", 93 | Body: `{ 94 | something 95 | }`, 96 | }, 97 | }, 98 | }, 99 | }, 100 | { 101 | name: "chat with multiple multiline Command with args", 102 | input: `This is some chat 103 | 104 | /api get /path 105 | { 106 | something 107 | } 108 | 109 | This is some more chat 110 | 111 | /api post /another-path 112 | { 113 | something else 114 | }`, 115 | output: ParseResult{ 116 | Chat: "This is some chat\n\nThis is some more chat", 117 | Commands: []ParseCommand{ 118 | { 119 | Command: "/api", 120 | Args: "get /path", 121 | Body: `{ 122 | something 123 | }`, 124 | }, 125 | { 126 | Command: "/api", 127 | Args: "post /another-path", 128 | Body: `{ 129 | something else 130 | }`, 131 | }, 132 | }, 133 | }, 134 | }, 135 | { 136 | name: "multiline Command with code Body", 137 | input: `/plugins create my-plugin 138 | { 139 | package main 140 | 141 | import "fmt" 142 | 143 | func main() { 144 | fmt.Println("test") 145 | } 146 | }`, 147 | output: ParseResult{ 148 | Chat: "", 149 | Commands: []ParseCommand{ 150 | { 151 | Command: "/plugins", 152 | Args: "create my-plugin", 153 | Body: `{ 154 | package main 155 | 156 | import "fmt" 157 | 158 | func main() { 159 | fmt.Println("test") 160 | } 161 | }`, 162 | }, 163 | }, 164 | }, 165 | }, 166 | } 167 | 168 | for _, testCase := range testCases { 169 | t.Run(testCase.name, func(t *testing.T) { 170 | tokens := Lex(testCase.input) 171 | ast := ParseTokens(tokens) 172 | assert.Equal(t, testCase.output, ast) 173 | }) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /ui/theme.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/fatih/color" 8 | ) 9 | 10 | var theme Theme 11 | 12 | func init() { 13 | if strings.ToUpper(os.Getenv("GPTCHAT_THEME")) == "DARK" { 14 | theme = DarkTheme 15 | } else { 16 | theme = LightTheme 17 | } 18 | } 19 | 20 | type Theme struct { 21 | Username *color.Color 22 | Message *color.Color 23 | Useful *color.Color 24 | AI *color.Color 25 | User *color.Color 26 | Info *color.Color 27 | Error *color.Color 28 | Warn *color.Color 29 | App *color.Color 30 | AppBold *color.Color 31 | } 32 | 33 | var LightTheme = Theme{ 34 | Username: color.New(color.FgRed), 35 | Message: color.New(color.FgBlue), 36 | Useful: color.New(color.FgWhite), 37 | AI: color.New(color.FgGreen), 38 | User: color.New(color.FgYellow), 39 | Info: color.New(color.FgWhite, color.Bold), 40 | Error: color.New(color.FgHiRed, color.Bold), 41 | Warn: color.New(color.FgHiYellow, color.Bold), 42 | App: color.New(color.FgWhite), 43 | AppBold: color.New(color.FgGreen, color.Bold), 44 | } 45 | 46 | var DarkTheme = Theme{ 47 | Username: color.New(color.FgRed), 48 | Message: color.New(color.FgBlue), 49 | Useful: color.New(color.FgBlack), 50 | AI: color.New(color.FgGreen), 51 | User: color.New(color.FgMagenta), 52 | Info: color.New(color.FgBlack, color.Bold), 53 | Error: color.New(color.FgHiRed, color.Bold), 54 | Warn: color.New(color.FgHiMagenta, color.Bold), 55 | App: color.New(color.FgBlack), 56 | AppBold: color.New(color.FgGreen, color.Bold), 57 | } 58 | -------------------------------------------------------------------------------- /ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | User = "USER" 12 | AI = "AI" 13 | System = "SYSTEM" 14 | Tool = "TOOL" 15 | API = "API" 16 | Module = "MODULE" 17 | App = "APP" 18 | ) 19 | 20 | func Error(message string, err error) { 21 | theme.Error.Printf("ERROR: ") 22 | theme.Useful.Printf("%s: %v\n\n", message, err) 23 | } 24 | 25 | func Warn(message string) { 26 | theme.Warn.Printf("WARNING: ") 27 | theme.Useful.Printf("%s\n", message) 28 | } 29 | 30 | func Info(message string) { 31 | theme.Warn.Printf("INFO: ") 32 | theme.Useful.Printf("%s\n", message) 33 | } 34 | 35 | func Welcome(title, message string) { 36 | theme.AppBold.Printf("%s\n\n", title) 37 | theme.App.Printf("%s\n\n", message) 38 | } 39 | 40 | func PrintChatDebug(name, message string) { 41 | theme.Useful.Printf("[DEBUG] ") 42 | PrintChat(name, message) 43 | } 44 | 45 | func PrintChat(name, message string) { 46 | switch name { 47 | case User: 48 | theme.User.Printf("%s:\n\n", name) 49 | theme.Message.Printf("%s\n", indent(message)) 50 | case AI: 51 | theme.AI.Printf("%s:\n\n", name) 52 | theme.Useful.Printf("%s\n", indent(message)) 53 | case App: 54 | theme.AppBold.Printf("%s:\n\n", name) 55 | theme.Useful.Printf("%s\n", indent(message)) 56 | case System: 57 | fallthrough 58 | case Tool: 59 | fallthrough 60 | case API: 61 | fallthrough 62 | case Module: 63 | fallthrough 64 | default: 65 | theme.Username.Printf("%s:\n\n", name) 66 | theme.Message.Printf("%s\n", indent(message)) 67 | } 68 | } 69 | 70 | func PromptChatInput() string { 71 | reader := bufio.NewReader(os.Stdin) 72 | theme.User.Printf("USER:\n\n ") 73 | text, _ := reader.ReadString('\n') 74 | text = strings.TrimSpace(text) 75 | fmt.Println() 76 | 77 | return text 78 | } 79 | 80 | func PromptConfirm(prompt string) bool { 81 | reader := bufio.NewReader(os.Stdin) 82 | theme.AppBold.Printf("%s [Y/N]: ", prompt) 83 | text, _ := reader.ReadString('\n') 84 | text = strings.TrimSpace(text) 85 | fmt.Println() 86 | 87 | return strings.ToUpper(text) == "Y" 88 | } 89 | 90 | func PromptInput(prompt string) string { 91 | reader := bufio.NewReader(os.Stdin) 92 | theme.AppBold.Printf("%s ", prompt) 93 | text, _ := reader.ReadString('\n') 94 | text = strings.TrimSpace(text) 95 | return text 96 | } 97 | 98 | func indent(input string) string { 99 | lines := strings.Split(string(input), "\n") 100 | var output string 101 | for _, line := range lines { 102 | output += " " + line + "\n" 103 | } 104 | return output 105 | } 106 | -------------------------------------------------------------------------------- /util/strings.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | const SingleQuote = "`" 4 | const TripleQuote = "```" 5 | --------------------------------------------------------------------------------