)
const (
- // instructionsFileName is the file of the user's instructions in the
- // directory of an agent.
- instructionsFileName = "instructions.md"
+ // agentInstructionsFileName is the file of the user's instructions in
+ // the directory of an agent.
+ agentInstructionsFileName = "instructions.md"
// agentIDBytes is the number of random bytes of an agent ID.
agentIDBytes = 16
)
-// AgentConfig holds the settings of the config.json of an agent. ID is random,
-// made once when the file is made, and never changes. The cache key comes from
-// the ID, so a rename keeps the prompt cache and two agents with one name do
-// not share a key. Workdir is the directory where the commands of the agent
-// run.
-type AgentConfig struct {
- Name string `json:"name"`
- ID string `json:"id"`
- Model string `json:"model"`
- Effort string `json:"effort"`
- Workdir string `json:"workdir"`
-}
-
-// Agent is one agent and its directory. The directory holds config.json, the
-// user's instructions in instructions.md, the chat, and the usage file.
-type Agent struct {
- dir string
- config AgentConfig
-}
-
// openAgent reads the config.json of the agent in dir. When the file does not
// exist, it makes the directory and writes the file with the defaults and a new
// ID. A missing field of an existing file takes the default, but the ID must be
}, nil
}
+// Agent is one agent and its directory. The directory holds config.json, the
+// user's instructions in instructions.md, the chat, and the usage file.
+type Agent struct {
+ dir string
+ config AgentConfig
+}
+
+// CacheKey returns the prompt cache key of every model call of the agent.
+func (a *Agent) CacheKey() string {
+ return "onechat-" + a.config.ID
+}
+
+// Prompt returns the system prompt of the agent with the user's instructions
+// from instructions.md. It reads the file on each call, so a change applies at
+// the next call. A missing file means no instructions.
+func (a *Agent) Prompt() (string, error) {
+ data, err := os.ReadFile(filepath.Join(a.dir, agentInstructionsFileName))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return "", fmt.Errorf("read instructions: %w", err)
+ }
+
+ return prompt(a.config.Name, string(data)), nil
+}
+
+// AgentConfig holds the settings of the config.json of an agent. ID is random,
+// made once when the file is made, and never changes. The cache key comes from
+// the ID, so a rename keeps the prompt cache and two agents with one name do
+// not share a key. Workdir is the directory where the commands of the agent
+// run.
+type AgentConfig struct {
+ Name string `json:"name"`
+ ID string `json:"id"`
+ Model string `json:"model"`
+ Effort string `json:"effort"`
+ Workdir string `json:"workdir"`
+}
+
+// validate makes sure that each setting has a valid value.
+func (c AgentConfig) validate() error {
+ if c.Name == "" || strings.ContainsAny(c.Name, "\r\n") {
+ return errors.New("name must be one line of text")
+ }
+
+ id, err := hex.DecodeString(c.ID)
+ if err != nil || len(id) != agentIDBytes {
+ return fmt.Errorf("id must be %d hexadecimal digits", 2*agentIDBytes)
+ }
+
+ if c.Model == "" || c.Effort == "" {
+ return errors.New("agent needs a model and an effort")
+ }
+
+ if !filepath.IsAbs(c.Workdir) {
+ return fmt.Errorf("workdir %q is not an absolute path", c.Workdir)
+ }
+
+ return nil
+}
+
// createAgent writes the config.json of a new agent in dir with the defaults
// and a new ID.
func createAgent(dir string) (*Agent, error) {
Workdir: home,
}, nil
}
-
-// validate makes sure that each setting has a valid value.
-func (c AgentConfig) validate() error {
- if c.Name == "" || strings.ContainsAny(c.Name, "\r\n") {
- return errors.New("name must be one line of text")
- }
-
- id, err := hex.DecodeString(c.ID)
- if err != nil || len(id) != agentIDBytes {
- return fmt.Errorf("id must be %d hexadecimal digits", 2*agentIDBytes)
- }
-
- if c.Model == "" || c.Effort == "" {
- return errors.New("agent needs a model and an effort")
- }
-
- if !filepath.IsAbs(c.Workdir) {
- return fmt.Errorf("workdir %q is not an absolute path", c.Workdir)
- }
-
- return nil
-}
-
-// CacheKey returns the prompt cache key of every model call of the agent.
-func (a *Agent) CacheKey() string {
- return "onechat-" + a.config.ID
-}
-
-// Prompt returns the system prompt of the agent with the user's instructions
-// from instructions.md. It reads the file on each call, so a change applies at
-// the next call. A missing file means no instructions.
-func (a *Agent) Prompt() (string, error) {
- data, err := os.ReadFile(filepath.Join(a.dir, instructionsFileName))
- if err != nil && !errors.Is(err, os.ErrNotExist) {
- return "", fmt.Errorf("read instructions: %w", err)
- }
-
- return prompt(a.config.Name, string(data)), nil
-}
config := agent.config
config.Name = "Lena"
- writeAgentConfig(t, dir, config)
+ agentWriteTestConfig(t, dir, config)
renamed, err := openAgent(dir)
require.NoError(t, err)
require.NoError(t, err)
require.Equal(t, prompt(agent.config.Name, ""), text)
- require.NoError(t, os.WriteFile(filepath.Join(dir, instructionsFileName), []byte("I am Lena.\n"), 0o600))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, agentInstructionsFileName), []byte("I am Lena.\n"), 0o600))
text, err = agent.Prompt()
require.NoError(t, err)
require.Equal(t, prompt(agent.config.Name, "I am Lena."), text)
}
-// writeAgentConfig writes config as the config.json of the agent in dir.
-func writeAgentConfig(t *testing.T, dir string, config AgentConfig) {
+// agentWriteTestConfig writes config as the config.json of the agent in dir.
+func agentWriteTestConfig(t *testing.T, dir string, config AgentConfig) {
t.Helper()
data, err := json.Marshal(config)
require.IsType(t, &ChatGPTAuth{}, auth)
require.Equal(t, filepath.Join(dir, chatgptTokenFileName), auth.(*ChatGPTAuth).path)
- config.Endpoint = endpointAPI
+ config.Endpoint = configEndpointAPI
address, auth, err = modelEndpoint(config, dir)
require.NoError(t, err)
require.Equal(t, modelAPIURL, address)
)
const (
- // endpointChatGPT names the ChatGPT backend with the subscription login.
- endpointChatGPT = "chatgpt"
+ // configEndpointChatGPT names the ChatGPT backend with the subscription
+ // login.
+ configEndpointChatGPT = "chatgpt"
- // endpointAPI names the OpenAI API with an API key.
- endpointAPI = "api"
+ // configEndpointAPI names the OpenAI API with an API key.
+ configEndpointAPI = "api"
)
-// Config holds the settings of config.json in the data directory.
-type Config struct {
- Endpoint string `json:"endpoint"`
- Compactor ModelConfig `json:"compactor"`
-}
-
-// ModelConfig names a model and its reasoning effort.
-type ModelConfig struct {
- Model string `json:"model"`
- Effort string `json:"effort"`
-}
-
-// defaultConfig returns the settings that apply when config.json does not
-// set them.
-func defaultConfig() Config {
- return Config{
- Endpoint: endpointChatGPT,
- Compactor: ModelConfig{
- Model: "gpt-6.1-sol",
- Effort: "high",
- },
- }
-}
-
// loadConfig reads config.json in dir over the defaults. A missing file gives
// the defaults. An unknown field is an error, so a typo does not become a
// silent default.
return config, nil
}
+// Config holds the settings of config.json in the data directory.
+type Config struct {
+ Endpoint string `json:"endpoint"`
+ Compactor ModelConfig `json:"compactor"`
+}
+
// validate makes sure that each setting has a valid value.
func (c Config) validate() error {
- if c.Endpoint != endpointChatGPT && c.Endpoint != endpointAPI {
- return fmt.Errorf("endpoint %q is not %q or %q", c.Endpoint, endpointChatGPT, endpointAPI)
+ if c.Endpoint != configEndpointChatGPT && c.Endpoint != configEndpointAPI {
+ return fmt.Errorf("endpoint %q is not %q or %q", c.Endpoint, configEndpointChatGPT, configEndpointAPI)
}
if c.Compactor.Model == "" || c.Compactor.Effort == "" {
return nil
}
-// dataDir returns the data directory and creates it with mode 0700 when it
-// does not exist. It is ONECHAT_DIR when that is set. Otherwise it is
-// $XDG_DATA_HOME/onechat when XDG_DATA_HOME is an absolute path, or else
-// ~/.local/share/onechat.
-func dataDir() (string, error) {
- dir := os.Getenv("ONECHAT_DIR")
- if dir == "" {
- var err error
- dir, err = defaultDataDir()
- if err != nil {
- return "", err
- }
- }
-
- err := os.MkdirAll(dir, 0o700)
- if err != nil {
- return "", fmt.Errorf("create data directory: %w", err)
- }
-
- return dir, nil
+// ModelConfig names a model and its reasoning effort.
+type ModelConfig struct {
+ Model string `json:"model"`
+ Effort string `json:"effort"`
}
-// defaultDataDir returns the data directory for an unset ONECHAT_DIR. The XDG
-// specification says to ignore a relative XDG_DATA_HOME.
-func defaultDataDir() (string, error) {
- xdg := os.Getenv("XDG_DATA_HOME")
- if filepath.IsAbs(xdg) {
- return filepath.Join(xdg, "onechat"), nil
- }
-
- home, err := os.UserHomeDir()
- if err != nil {
- return "", fmt.Errorf("find data directory: set ONECHAT_DIR: %w", err)
+// defaultConfig returns the settings that apply when config.json does not
+// set them.
+func defaultConfig() Config {
+ return Config{
+ Endpoint: configEndpointChatGPT,
+ Compactor: ModelConfig{
+ Model: "gpt-6.1-sol",
+ Effort: "high",
+ },
}
-
- return filepath.Join(home, ".local", "share", "onechat"), nil
}
func TestLoadConfigKeepsDefaultsForMissingFields(t *testing.T) {
dir := t.TempDir()
- writeConfig(t, dir, `{"endpoint": "api", "compactor": {"effort": "xhigh"}}`)
+ configWriteTest(t, dir, `{"endpoint": "api", "compactor": {"effort": "xhigh"}}`)
config, err := loadConfig(dir)
require.NoError(t, err)
require.Equal(t, Config{
- Endpoint: endpointAPI,
+ Endpoint: configEndpointAPI,
Compactor: ModelConfig{
Model: "gpt-6.1-sol",
Effort: "xhigh",
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
- writeConfig(t, dir, tc.text)
+ configWriteTest(t, dir, tc.text)
_, err := loadConfig(dir)
require.Error(t, err)
}
}
-func TestDataDirFollowsTheOrder(t *testing.T) {
- root := t.TempDir()
- testCases := []struct {
- name string
- dir string
- xdg string
- home string
- want string
- }{
- {
- name: "ONECHAT_DIR first",
- dir: filepath.Join(root, "a"),
- xdg: filepath.Join(root, "xdg"),
- home: root,
- want: filepath.Join(root, "a"),
- },
- {
- name: "absolute XDG_DATA_HOME",
- dir: "",
- xdg: filepath.Join(root, "xdg"),
- home: root,
- want: filepath.Join(root, "xdg", "onechat"),
- },
- {
- name: "relative XDG_DATA_HOME ignored",
- dir: "",
- xdg: "xdg",
- home: root,
- want: filepath.Join(root, ".local", "share", "onechat"),
- },
- {
- name: "home directory last",
- dir: "",
- xdg: "",
- home: root,
- want: filepath.Join(root, ".local", "share", "onechat"),
- },
- }
-
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- t.Setenv("ONECHAT_DIR", tc.dir)
- t.Setenv("XDG_DATA_HOME", tc.xdg)
- t.Setenv("HOME", tc.home)
-
- dir, err := dataDir()
- require.NoError(t, err)
- require.Equal(t, tc.want, dir)
-
- info, err := os.Stat(dir)
- require.NoError(t, err)
- require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
- })
- }
-}
-
-func TestDataDirFailsWithoutAHome(t *testing.T) {
- t.Setenv("ONECHAT_DIR", "")
- t.Setenv("XDG_DATA_HOME", "")
- t.Setenv("HOME", "")
-
- _, err := dataDir()
- require.ErrorContains(t, err, "ONECHAT_DIR")
-}
-
-func writeConfig(t *testing.T, dir, text string) {
+func configWriteTest(t *testing.T, dir, text string) {
t.Helper()
err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(text), 0o600)
--- /dev/null
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// dataDir returns the data directory and creates it with mode 0700 when it does
+// not exist. It is ONECHAT_DIR when that is set. Otherwise it is
+// $XDG_DATA_HOME/onechat when XDG_DATA_HOME is an absolute path, or else
+// ~/.local/share/onechat.
+func dataDir() (string, error) {
+ dir := os.Getenv("ONECHAT_DIR")
+ if dir == "" {
+ var err error
+ dir, err = defaultDataDir()
+ if err != nil {
+ return "", err
+ }
+ }
+
+ err := os.MkdirAll(dir, 0o700)
+ if err != nil {
+ return "", fmt.Errorf("create data directory: %w", err)
+ }
+
+ return dir, nil
+}
+
+// defaultDataDir returns the data directory for an unset ONECHAT_DIR. The XDG
+// specification says to ignore a relative XDG_DATA_HOME.
+func defaultDataDir() (string, error) {
+ xdg := os.Getenv("XDG_DATA_HOME")
+ if filepath.IsAbs(xdg) {
+ return filepath.Join(xdg, "onechat"), nil
+ }
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("find data directory: set ONECHAT_DIR: %w", err)
+ }
+
+ return filepath.Join(home, ".local", "share", "onechat"), nil
+}
--- /dev/null
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDataDirFollowsTheOrder(t *testing.T) {
+ root := t.TempDir()
+ testCases := []struct {
+ name string
+ dir string
+ xdg string
+ home string
+ want string
+ }{
+ {
+ name: "ONECHAT_DIR first",
+ dir: filepath.Join(root, "a"),
+ xdg: filepath.Join(root, "xdg"),
+ home: root,
+ want: filepath.Join(root, "a"),
+ },
+ {
+ name: "absolute XDG_DATA_HOME",
+ dir: "",
+ xdg: filepath.Join(root, "xdg"),
+ home: root,
+ want: filepath.Join(root, "xdg", "onechat"),
+ },
+ {
+ name: "relative XDG_DATA_HOME ignored",
+ dir: "",
+ xdg: "xdg",
+ home: root,
+ want: filepath.Join(root, ".local", "share", "onechat"),
+ },
+ {
+ name: "home directory last",
+ dir: "",
+ xdg: "",
+ home: root,
+ want: filepath.Join(root, ".local", "share", "onechat"),
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv("ONECHAT_DIR", tc.dir)
+ t.Setenv("XDG_DATA_HOME", tc.xdg)
+ t.Setenv("HOME", tc.home)
+
+ dir, err := dataDir()
+ require.NoError(t, err)
+ require.Equal(t, tc.want, dir)
+
+ info, err := os.Stat(dir)
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
+ })
+ }
+}
+
+func TestDataDirFailsWithoutAHome(t *testing.T) {
+ t.Setenv("ONECHAT_DIR", "")
+ t.Setenv("XDG_DATA_HOME", "")
+ t.Setenv("HOME", "")
+
+ _, err := dataDir()
+ require.ErrorContains(t, err, "ONECHAT_DIR")
+}
// OPENAI_API_KEY, and there is no fallback from one to the other. All clients
// share the authorizer, so one refresh runs at a time.
func modelEndpoint(config Config, dir string) (string, ModelAuthorizer, error) {
- if config.Endpoint == endpointAPI {
+ if config.Endpoint == configEndpointAPI {
key := os.Getenv("OPENAI_API_KEY")
if key == "" {
return "", nil, errors.New("endpoint api needs OPENAI_API_KEY")