]> git.giacomolli.uk - onechat/commitdiff
Order daemon and storage declarations
authorAnderson A. Giacomolli <redacted>
Wed, 11 Nov 2026 13:48:26 +0000 (10:48 -0300)
committerAnderson A. Giacomolli <redacted>
Wed, 11 Nov 2026 13:48:26 +0000 (10:48 -0300)
Daemon, storage, configuration, and build files put types before
constructors and grouped methods. Shared test helpers use that order,
and ordinary tests keep their tests-first order. Constants and variables
precede types so every declaration category is easy to find.

The declaration text and function bodies stay unchanged.

Issue: 947bddb

20 files changed:
agent.go
backup_archive.go
build_queue.go
build_run.go
chain.go
chatgpt.go
chatgpt_fake_helper_test.go
compactor.go
config.go
daemon.go
daemon_agent.go
daemon_log.go
daemon_message.go
daemon_message_helper_test.go
log.go
records.go
routes.go
schedule.go
schedule_cron.go
socket.go

index d95a81cadad9547c982e928303aff9cce5391234..8226a43fb0e4deebc20cef17c9ff2997ef7f281e 100644 (file)
--- a/agent.go
+++ b/agent.go
@@ -25,6 +25,28 @@ const (
        agentIDBytes = 16
 )
 
        agentIDBytes = 16
 )
 
+// 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
+}
+
+// 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. Responsibility and Parent are set together for a child agent.
+type AgentConfig struct {
+       Name           string `json:"name"`
+       ID             string `json:"id"`
+       Model          string `json:"model"`
+       Effort         string `json:"effort"`
+       Workdir        string `json:"workdir"`
+       Responsibility string `json:"responsibility,omitempty"`
+       Parent         string `json:"parent,omitempty"`
+}
+
 // 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
 // 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
@@ -73,13 +95,6 @@ func openAgent(dir string) (*Agent, error) {
        }, nil
 }
 
        }, 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
-}
-
 // Images returns the shared data-directory image store, also for a child.
 func (a *Agent) Images() *ImageStore {
        root := a.dir
 // Images returns the shared data-directory image store, also for a child.
 func (a *Agent) Images() *ImageStore {
        root := a.dir
@@ -295,21 +310,6 @@ func (a *Agent) instructions() (string, error) {
        return string(data), nil
 }
 
        return 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. Responsibility and Parent are set together for a child agent.
-type AgentConfig struct {
-       Name           string `json:"name"`
-       ID             string `json:"id"`
-       Model          string `json:"model"`
-       Effort         string `json:"effort"`
-       Workdir        string `json:"workdir"`
-       Responsibility string `json:"responsibility,omitempty"`
-       Parent         string `json:"parent,omitempty"`
-}
-
 // validate makes sure that each setting has a valid value.
 func (c AgentConfig) validate() error {
        if c.Name == "" || strings.ContainsAny(c.Name, "\r\n") {
 // validate makes sure that each setting has a valid value.
 func (c AgentConfig) validate() error {
        if c.Name == "" || strings.ContainsAny(c.Name, "\r\n") {
index 0c3544fdf4b7e4be3b0621e269e90bfbd6aa9817..35a389cbcde7856ac14d41d02eb90311628c5019 100644 (file)
@@ -13,6 +13,25 @@ import (
        "strings"
 )
 
        "strings"
 )
 
+// BackupReader checks cancellation between reads of an archive or file.
+type BackupReader struct {
+       ctx    context.Context
+       reader io.Reader
+}
+
+func (r BackupReader) Read(data []byte) (int, error) {
+       if r.ctx.Err() != nil {
+               return 0, r.ctx.Err()
+       }
+
+       count, err := r.reader.Read(data)
+       if err != nil {
+               return count, fmt.Errorf("read backup data: %w", err)
+       }
+
+       return count, nil
+}
+
 // backupArchive writes the files in root to path through a private temporary
 // file and a rename. Only directories and regular files enter the archive.
 func backupArchive(ctx context.Context, root, path string) (size int64, err error) {
 // backupArchive writes the files in root to path through a private temporary
 // file and a rename. Only directories and regular files enter the archive.
 func backupArchive(ctx context.Context, root, path string) (size int64, err error) {
@@ -197,25 +216,6 @@ func backupExtract(ctx context.Context, path, root string) (err error) {
        return nil
 }
 
        return nil
 }
 
-// BackupReader checks cancellation between reads of an archive or file.
-type BackupReader struct {
-       ctx    context.Context
-       reader io.Reader
-}
-
-func (r BackupReader) Read(data []byte) (int, error) {
-       if r.ctx.Err() != nil {
-               return 0, r.ctx.Err()
-       }
-
-       count, err := r.reader.Read(data)
-       if err != nil {
-               return count, fmt.Errorf("read backup data: %w", err)
-       }
-
-       return count, nil
-}
-
 // backupCopyFile copies the size fixed at the snapshot of path. Growth after
 // that point is not part of the copy, and a shorter file is an error.
 func backupCopyFile(ctx context.Context, out io.Writer, path string, size int64) (err error) {
 // backupCopyFile copies the size fixed at the snapshot of path. Growth after
 // that point is not part of the copy, and a shorter file is an error.
 func backupCopyFile(ctx context.Context, out io.Writer, path string, size int64) (err error) {
index 7db24bee8a1d2387a90de6fe509be9d517544fa9..540e396091f4023528d6d31edabcb61f438b8856 100644 (file)
@@ -14,14 +14,6 @@ const (
        buildLeafWindow = 8
 )
 
        buildLeafWindow = 8
 )
 
-func newBuildQueue() *BuildQueue {
-       return &BuildQueue{
-               queued:  make(map[NodeKey]bool),
-               running: make(map[NodeKey]bool),
-               failed:  make(map[NodeKey]bool),
-       }
-}
-
 // BuildQueue holds the nodes that wait for a model build. A leaf joins it when
 // its message is too long to be its own node. A merge joins it when both of its
 // children are built and their texts do not fit in one node. So the core never
 // BuildQueue holds the nodes that wait for a model build. A leaf joins it when
 // its message is too long to be its own node. A merge joins it when both of its
 // children are built and their texts do not fit in one node. So the core never
@@ -42,6 +34,17 @@ type BuildQueue struct {
        retry   []NodeKey
 }
 
        retry   []NodeKey
 }
 
+// BuildMergeHeap holds the merges that can start, by the end of their context.
+type BuildMergeHeap []NodeKey
+
+func newBuildQueue() *BuildQueue {
+       return &BuildQueue{
+               queued:  make(map[NodeKey]bool),
+               running: make(map[NodeKey]bool),
+               failed:  make(map[NodeKey]bool),
+       }
+}
+
 // AddLeaf adds the leaf of message id. The IDs must come in increasing order.
 func (q *BuildQueue) AddLeaf(id int) {
        q.leaves = append(q.leaves, id)
 // AddLeaf adds the leaf of message id. The IDs must come in increasing order.
 func (q *BuildQueue) AddLeaf(id int) {
        q.leaves = append(q.leaves, id)
@@ -135,9 +138,6 @@ func (q *BuildQueue) nextLeaf() (NodeKey, bool) {
        return NodeKey{}, false
 }
 
        return NodeKey{}, false
 }
 
-// BuildMergeHeap holds the merges that can start, by the end of their context.
-type BuildMergeHeap []NodeKey
-
 func (h BuildMergeHeap) Len() int {
        return len(h)
 }
 func (h BuildMergeHeap) Len() int {
        return len(h)
 }
index cc12cf26d2d66542bbab2216a96350079e32d422..707a928070cc0452940b4a7dd434120085779277 100644 (file)
@@ -33,6 +33,13 @@ type BuildRun struct {
        usagePath string
 }
 
        usagePath string
 }
 
+// DatedMessage is one message with an optional RFC 3339 date.
+type DatedMessage struct {
+       Kind string `json:"kind"`
+       Text string `json:"text"`
+       Date string `json:"date,omitempty"`
+}
+
 // Run appends the entries. It waits for the builds only before a user message,
 // as a turn does, so up to buildMax builds run at once in between, and it waits
 // for all builds at the end. It returns the number of messages appended and why
 // Run appends the entries. It waits for the builds only before a user message,
 // as a turn does, so up to buildMax builds run at once in between, and it waits
 // for all builds at the end. It returns the number of messages appended and why
@@ -122,13 +129,6 @@ func (r *BuildRun) waitBuilds(ctx context.Context) error {
        return r.memory.Wait(ctx)
 }
 
        return r.memory.Wait(ctx)
 }
 
-// DatedMessage is one message with an optional RFC 3339 date.
-type DatedMessage struct {
-       Kind string `json:"kind"`
-       Text string `json:"text"`
-       Date string `json:"date,omitempty"`
-}
-
 // buildReadUsage reads the records of usage.jsonl at path, in the order of the
 // start of their calls. The file has them in the order of the end of their
 // calls, which differs when calls run at once.
 // buildReadUsage reads the records of usage.jsonl at path, in the order of the
 // start of their calls. The file has them in the order of the end of their
 // calls, which differs when calls run at once.
index 23c7dec14a6053d8ec38d5201a06f6dc916b4532..59ce563524543d058a7809633451f09a867f02ef 100644 (file)
--- a/chain.go
+++ b/chain.go
@@ -35,6 +35,22 @@ type Chain struct {
        images  *ImageStore
 }
 
        images  *ImageStore
 }
 
+// ChainFunctionCall holds the fields of a function call output item.
+type ChainFunctionCall struct {
+       Type      string `json:"type"`
+       Name      string `json:"name"`
+       Arguments string `json:"arguments"`
+       CallID    string `json:"call_id"`
+}
+
+// ChainFunctionCallOutput is the input item with the result of a function
+// call.
+type ChainFunctionCallOutput struct {
+       Type   string `json:"type"`
+       CallID string `json:"call_id"`
+       Output any    `json:"output"`
+}
+
 // Run runs the chain from input, the input of its first request, and returns
 // the text of the final answer. An error of a log write wraps
 // errChainLogWrite.
 // Run runs the chain from input, the input of its first request, and returns
 // the text of the final answer. An error of a log write wraps
 // errChainLogWrite.
@@ -134,22 +150,6 @@ func (c *Chain) handle(ctx context.Context, input []any, response ModelResponse)
        return input, "", nil
 }
 
        return input, "", nil
 }
 
-// ChainFunctionCall holds the fields of a function call output item.
-type ChainFunctionCall struct {
-       Type      string `json:"type"`
-       Name      string `json:"name"`
-       Arguments string `json:"arguments"`
-       CallID    string `json:"call_id"`
-}
-
-// ChainFunctionCallOutput is the input item with the result of a function
-// call.
-type ChainFunctionCallOutput struct {
-       Type   string `json:"type"`
-       CallID string `json:"call_id"`
-       Output any    `json:"output"`
-}
-
 // chainInputItem returns an output item as an input item for the next request.
 // The calls do not store their items, so the item goes back whole, without its
 // id.
 // chainInputItem returns an output item as an input item for the next request.
 // The calls do not store their items, so the item goes back whole, without its
 // id.
index 8849adaf69c70d511d1075217494a2072cd50006..ad40abd8d365627ff7d4720ebc394b4112f3857e 100644 (file)
@@ -51,37 +51,6 @@ const (
 // must run "onechat login".
 var errChatGPTLoginNeeded = errors.New(`login needed: run "onechat login"`)
 
 // must run "onechat login".
 var errChatGPTLoginNeeded = errors.New(`login needed: run "onechat login"`)
 
-// chatgptLogin runs "onechat login": the device code login into the token file
-// of the data directory. It holds the lock of the token file, so it does not
-// run while the daemon runs.
-func chatgptLogin(ctx context.Context, out io.Writer) (err error) {
-       dir, err := dataDir()
-       if err != nil {
-               return err
-       }
-
-       tokens, err := lockTokens(dir)
-       if err != nil {
-               return err
-       }
-
-       defer func() {
-               err = errors.Join(err, tokens.Close())
-       }()
-
-       return newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).Login(ctx, out)
-}
-
-// newChatGPTAuth returns the authorizer for the token file at path, with the
-// login service at issuer.
-func newChatGPTAuth(path, issuer string) *ChatGPTAuth {
-       return &ChatGPTAuth{
-               path:   path,
-               issuer: issuer,
-               http:   &http.Client{},
-       }
-}
-
 // ChatGPTAuth authorizes model calls to the ChatGPT backend with the tokens in
 // the token file. The tokens belong to OneChat only. The Codex CLI rotates the
 // refresh token on each refresh, so two programs with one token file log each
 // ChatGPTAuth authorizes model calls to the ChatGPT backend with the tokens in
 // the token file. The tokens belong to OneChat only. The Codex CLI rotates the
 // refresh token on each refresh, so two programs with one token file log each
@@ -98,6 +67,47 @@ type ChatGPTAuth struct {
        http   *http.Client
 }
 
        http   *http.Client
 }
 
+// ChatGPTDeviceCode is the answer of the user code endpoint. The interval is a
+// number or a string of seconds.
+type ChatGPTDeviceCode struct {
+       DeviceAuthID string          `json:"device_auth_id"`
+       UserCode     string          `json:"user_code"`
+       UserCodeAlt  string          `json:"usercode"`
+       Interval     json.RawMessage `json:"interval"`
+}
+
+// ChatGPTTokenFile is the content of the token file.
+type ChatGPTTokenFile struct {
+       AccessToken  string `json:"access_token"`
+       RefreshToken string `json:"refresh_token"`
+       IDToken      string `json:"id_token"`
+       AccountID    string `json:"account_id"`
+}
+
+// ChatGPTTokenResponse is the answer of the token endpoint.
+type ChatGPTTokenResponse struct {
+       AccessToken  string `json:"access_token"`
+       RefreshToken string `json:"refresh_token"`
+       IDToken      string `json:"id_token"`
+}
+
+// ChatGPTDeviceGrant is the answer of the device token endpoint after the user
+// approves the code.
+type ChatGPTDeviceGrant struct {
+       AuthorizationCode string `json:"authorization_code"`
+       CodeVerifier      string `json:"code_verifier"`
+}
+
+// newChatGPTAuth returns the authorizer for the token file at path, with the
+// login service at issuer.
+func newChatGPTAuth(path, issuer string) *ChatGPTAuth {
+       return &ChatGPTAuth{
+               path:   path,
+               issuer: issuer,
+               http:   &http.Client{},
+       }
+}
+
 // Authorize sets the access token and the account ID, after a refresh when the
 // token expires within chatgptRefreshMargin.
 func (a *ChatGPTAuth) Authorize(ctx context.Context, header http.Header) error {
 // Authorize sets the access token and the account ID, after a refresh when the
 // token expires within chatgptRefreshMargin.
 func (a *ChatGPTAuth) Authorize(ctx context.Context, header http.Header) error {
@@ -365,35 +375,25 @@ func (a *ChatGPTAuth) post(ctx context.Context, path, contentType string, body [
        return response.StatusCode, nil
 }
 
        return response.StatusCode, nil
 }
 
-// ChatGPTDeviceCode is the answer of the user code endpoint. The interval is a
-// number or a string of seconds.
-type ChatGPTDeviceCode struct {
-       DeviceAuthID string          `json:"device_auth_id"`
-       UserCode     string          `json:"user_code"`
-       UserCodeAlt  string          `json:"usercode"`
-       Interval     json.RawMessage `json:"interval"`
-}
+// chatgptLogin runs "onechat login": the device code login into the token file
+// of the data directory. It holds the lock of the token file, so it does not
+// run while the daemon runs.
+func chatgptLogin(ctx context.Context, out io.Writer) (err error) {
+       dir, err := dataDir()
+       if err != nil {
+               return err
+       }
 
 
-// ChatGPTTokenFile is the content of the token file.
-type ChatGPTTokenFile struct {
-       AccessToken  string `json:"access_token"`
-       RefreshToken string `json:"refresh_token"`
-       IDToken      string `json:"id_token"`
-       AccountID    string `json:"account_id"`
-}
+       tokens, err := lockTokens(dir)
+       if err != nil {
+               return err
+       }
 
 
-// ChatGPTTokenResponse is the answer of the token endpoint.
-type ChatGPTTokenResponse struct {
-       AccessToken  string `json:"access_token"`
-       RefreshToken string `json:"refresh_token"`
-       IDToken      string `json:"id_token"`
-}
+       defer func() {
+               err = errors.Join(err, tokens.Close())
+       }()
 
 
-// ChatGPTDeviceGrant is the answer of the device token endpoint after the user
-// approves the code.
-type ChatGPTDeviceGrant struct {
-       AuthorizationCode string `json:"authorization_code"`
-       CodeVerifier      string `json:"code_verifier"`
+       return newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).Login(ctx, out)
 }
 
 // chatgptTokenExpiry returns the expiry of a JWT from its exp claim.
 }
 
 // chatgptTokenExpiry returns the expiry of a JWT from its exp claim.
index ddd4c6736861643642d5e50a8861bcb24a13c0b1..0dca6bb7f5c67872eb0106d7f487bae7f94e058a 100644 (file)
@@ -35,6 +35,27 @@ type ChatGPTFake struct {
        access      []string
 }
 
        access      []string
 }
 
+func newChatGPTFake(t *testing.T) *ChatGPTFake {
+       t.Helper()
+
+       f := &ChatGPTFake{}
+       mux := http.NewServeMux()
+       mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode)
+       mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken)
+       mux.HandleFunc("POST /oauth/token", f.oauthToken)
+
+       f.Server = httptest.NewServer(mux)
+       t.Cleanup(f.Close)
+
+       return f
+}
+
+func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth {
+       t.Helper()
+
+       return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL)
+}
+
 // token returns a new access token that expires after ttl, for a token file
 // that a test writes.
 func (f *ChatGPTFake) token(ttl time.Duration) string {
 // token returns a new access token that expires after ttl, for a token file
 // that a test writes.
 func (f *ChatGPTFake) token(ttl time.Duration) string {
@@ -166,27 +187,6 @@ func (f *ChatGPTFake) oauthToken(w http.ResponseWriter, r *http.Request) {
        chatgptWriteJSON(w, answer)
 }
 
        chatgptWriteJSON(w, answer)
 }
 
-func newChatGPTFake(t *testing.T) *ChatGPTFake {
-       t.Helper()
-
-       f := &ChatGPTFake{}
-       mux := http.NewServeMux()
-       mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode)
-       mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken)
-       mux.HandleFunc("POST /oauth/token", f.oauthToken)
-
-       f.Server = httptest.NewServer(mux)
-       t.Cleanup(f.Close)
-
-       return f
-}
-
-func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth {
-       t.Helper()
-
-       return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL)
-}
-
 func chatgptWriteTestTokens(t *testing.T, auth *ChatGPTAuth, access, refresh string) {
        t.Helper()
 
 func chatgptWriteTestTokens(t *testing.T, auth *ChatGPTAuth, access, refresh string) {
        t.Helper()
 
index 2b239015ce763f56d1a23a4621647f056023b07b..e1014288abb988a88a9a091e5e4261400438dc44 100644 (file)
@@ -15,20 +15,6 @@ const compactorTries = 5
 // bytes, and a sample line as the ruler gets its content copied.
 var compactorRuler = strings.Repeat("-", nodeBytes)
 
 // bytes, and a sample line as the ruler gets its content copied.
 var compactorRuler = strings.Repeat("-", nodeBytes)
 
-// newCompactor returns the compactor of an agent, which sends its calls through
-// client with model. instructions gives the system prompt of the agent at each
-// build. tools are the tools of that agent, so a build sends the same prompt
-// and tools as its turns.
-func newCompactor(client *ModelClient, instructions func() (string, error), model ModelConfig, tools []ModelTool, images *ImageStore) *Compactor {
-       return &Compactor{
-               client:       client,
-               model:        model,
-               instructions: instructions,
-               tools:        tools,
-               images:       images,
-       }
-}
-
 // Compactor builds the nodes of one agent with a model. A compaction is a call
 // like a turn, with the same system prompt and tools, so it reads them from the
 // prompt cache of the turns. The tools are never called. After them comes one
 // Compactor builds the nodes of one agent with a model. A compaction is a call
 // like a turn, with the same system prompt and tools, so it reads them from the
 // prompt cache of the turns. The tools are never called. After them comes one
@@ -52,6 +38,20 @@ type Compactor struct {
        images       *ImageStore
 }
 
        images       *ImageStore
 }
 
+// newCompactor returns the compactor of an agent, which sends its calls through
+// client with model. instructions gives the system prompt of the agent at each
+// build. tools are the tools of that agent, so a build sends the same prompt
+// and tools as its turns.
+func newCompactor(client *ModelClient, instructions func() (string, error), model ModelConfig, tools []ModelTool, images *ImageStore) *Compactor {
+       return &Compactor{
+               client:       client,
+               model:        model,
+               instructions: instructions,
+               tools:        tools,
+               images:       images,
+       }
+}
+
 // Summarize returns the line of the node of task.
 func (c *Compactor) Summarize(ctx context.Context, task Task) (string, error) {
        text, err := compactorTask(task)
 // Summarize returns the line of the node of task.
 func (c *Compactor) Summarize(ctx context.Context, task Task) (string, error) {
        text, err := compactorTask(task)
index 1d68b67a39c28716ba4651fc8b8bafd34dfc7d14..fb24c20111448c3baac9597ae2c7e2b6eab98307 100644 (file)
--- a/config.go
+++ b/config.go
@@ -19,6 +19,19 @@ const (
        configEndpointAPI = "api"
 )
 
        configEndpointAPI = "api"
 )
 
+// Config holds the settings of config.json in the data directory.
+type Config struct {
+       Endpoint  string      `json:"endpoint"`
+       Voice     VoiceConfig `json:"voice"`
+       Compactor ModelConfig `json:"compactor"`
+}
+
+// ModelConfig names a model and its reasoning effort.
+type ModelConfig struct {
+       Model  string `json:"model"`
+       Effort string `json:"effort"`
+}
+
 // 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.
 // 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.
@@ -52,13 +65,6 @@ func loadConfig(dir string) (Config, error) {
        return config, nil
 }
 
        return config, nil
 }
 
-// Config holds the settings of config.json in the data directory.
-type Config struct {
-       Endpoint  string      `json:"endpoint"`
-       Voice     VoiceConfig `json:"voice"`
-       Compactor ModelConfig `json:"compactor"`
-}
-
 // validate makes sure that each setting has a valid value.
 func (c Config) validate() error {
        if c.Endpoint != configEndpointChatGPT && c.Endpoint != configEndpointAPI {
 // validate makes sure that each setting has a valid value.
 func (c Config) validate() error {
        if c.Endpoint != configEndpointChatGPT && c.Endpoint != configEndpointAPI {
@@ -72,12 +78,6 @@ func (c Config) validate() error {
        return nil
 }
 
        return nil
 }
 
-// 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 {
 // defaultConfig returns the settings that apply when config.json does not
 // set them.
 func defaultConfig() Config {
index 31a671478cefea874dc86fe6aa0fe8d3bdbacf0a..dd75d316316a4195ef2b79b4fc42b3c4b27e6bcf 100644 (file)
--- a/daemon.go
+++ b/daemon.go
@@ -24,105 +24,6 @@ const (
        daemonClientEvents = 1024
 )
 
        daemonClientEvents = 1024
 )
 
-// daemon runs "onechat daemon": it owns the root, its children, and their
-// files, and serves the clients on the Unix socket in the data directory until
-// ctx ends. When telegram.json exists, it also runs the Telegram bot. It also
-// keeps a linked WhatsApp device connected. It holds the lock of the token
-// file, so it is the only process that refreshes the tokens and opens the
-// WhatsApp session. Its log lines go to out, through the loggers of
-// daemonLogger.
-func daemon(ctx context.Context, out io.Writer) (err error) {
-       dir, err := dataDir()
-       if err != nil {
-               return err
-       }
-
-       // Check the socket path before the daemon writes a file, so that a data
-       // directory with a long path stays empty.
-       socket := filepath.Join(dir, socketFileName)
-       err = checkSocketPath(socket)
-       if err != nil {
-               return err
-       }
-
-       config, err := loadConfig(dir)
-       if err != nil {
-               return err
-       }
-
-       botSettings, hasBot, err := loadTelegramConfig(dir)
-       if err != nil {
-               return err
-       }
-
-       tokens, err := lockTokens(dir)
-       if err != nil {
-               return err
-       }
-
-       defer func() {
-               err = errors.Join(err, tokens.Close())
-       }()
-
-       whatsappSetDeviceProps()
-
-       logger := daemonLogger(out, "onechat: ")
-       whatsapp, err := openWhatsApp(ctx, dir, time.Now, daemonLogger(out, "whatsapp: "), whatsappNewClient)
-       if err != nil {
-               return err
-       }
-
-       defer func() {
-               err = errors.Join(err, whatsapp.Close())
-       }()
-
-       address, auth, err := modelEndpoint(config, dir)
-       if err != nil {
-               return err
-       }
-
-       agents, err := openDaemonAgents(ctx, dir, address, auth, config.Compactor, time.Now, logger)
-       if err != nil {
-               return err
-       }
-
-       defer func() {
-               err = errors.Join(err, agents.Close())
-       }()
-
-       whatsapp.inbox.voice = &VoiceTranscriber{config: config.Voice}
-
-       root := agents[0]
-       agent := root.agent
-       schedules := root.schedules
-
-       listener, err := daemonListenSocket(socket)
-       if err != nil {
-               return err
-       }
-
-       linked := "not linked"
-       if whatsapp.isLinked() {
-               linked = "linked"
-       }
-
-       logger.Info("ready", "version", version, "socket", listener.Addr(), "model", agent.config.Model, "effort", agent.config.Effort, "workdir", agent.config.Workdir, "whatsapp", linked, "schedules", schedules.Len(), "children", agents.ChildNames())
-
-       var telegram *TelegramBot
-
-       if hasBot {
-               telegram = newTelegramBot(newTelegramAPI(telegramAPIURL, botSettings.Token), botSettings.Owner, filepath.Join(agent.dir, telegramOffsetFileName), daemonLogger(out, "telegram: "))
-
-               telegram.voice = &VoiceTranscriber{config: config.Voice}
-
-               logger.Info("Telegram bot started")
-       }
-
-       return daemonServe(ctx, listener, telegram, whatsapp, schedules, func(notify func(Event)) (*TurnLoop, error) {
-               return root.Loop(whatsapp, notify)
-       }, agents[1:]...)
-}
-
 // DaemonHub holds the connected clients and sends each event to all of them. It
 // runs the WhatsApp commands of the clients on whatsapp. snapshot locks all
 // agents and subagents during a backup copy.
 // DaemonHub holds the connected clients and sends each event to all of them. It
 // runs the WhatsApp commands of the clients on whatsapp. snapshot locks all
 // agents and subagents during a backup copy.
@@ -135,6 +36,14 @@ type DaemonHub struct {
        workers sync.WaitGroup
 }
 
        workers sync.WaitGroup
 }
 
+// DaemonClient is one connected client. Its events wait in a channel for the
+// writer, so a slow client does not stop the agent.
+type DaemonClient struct {
+       conn   net.Conn
+       events chan Event
+       once   sync.Once
+}
+
 // Accept serves each client of listener until the listener closes. A new
 // client gets a ready event with the name of the agent, and a busy event when a
 // turn runs.
 // Accept serves each client of listener until the listener closes. A new
 // client gets a ready event with the name of the agent, and a busy event when a
 // turn runs.
@@ -292,12 +201,103 @@ func (h *DaemonHub) CloseAll() {
        h.workers.Wait()
 }
 
        h.workers.Wait()
 }
 
-// DaemonClient is one connected client. Its events wait in a channel for the
-// writer, so a slow client does not stop the agent.
-type DaemonClient struct {
-       conn   net.Conn
-       events chan Event
-       once   sync.Once
+// daemon runs "onechat daemon": it owns the root, its children, and their
+// files, and serves the clients on the Unix socket in the data directory until
+// ctx ends. When telegram.json exists, it also runs the Telegram bot. It also
+// keeps a linked WhatsApp device connected. It holds the lock of the token
+// file, so it is the only process that refreshes the tokens and opens the
+// WhatsApp session. Its log lines go to out, through the loggers of
+// daemonLogger.
+func daemon(ctx context.Context, out io.Writer) (err error) {
+       dir, err := dataDir()
+       if err != nil {
+               return err
+       }
+
+       // Check the socket path before the daemon writes a file, so that a data
+       // directory with a long path stays empty.
+       socket := filepath.Join(dir, socketFileName)
+       err = checkSocketPath(socket)
+       if err != nil {
+               return err
+       }
+
+       config, err := loadConfig(dir)
+       if err != nil {
+               return err
+       }
+
+       botSettings, hasBot, err := loadTelegramConfig(dir)
+       if err != nil {
+               return err
+       }
+
+       tokens, err := lockTokens(dir)
+       if err != nil {
+               return err
+       }
+
+       defer func() {
+               err = errors.Join(err, tokens.Close())
+       }()
+
+       whatsappSetDeviceProps()
+
+       logger := daemonLogger(out, "onechat: ")
+       whatsapp, err := openWhatsApp(ctx, dir, time.Now, daemonLogger(out, "whatsapp: "), whatsappNewClient)
+       if err != nil {
+               return err
+       }
+
+       defer func() {
+               err = errors.Join(err, whatsapp.Close())
+       }()
+
+       address, auth, err := modelEndpoint(config, dir)
+       if err != nil {
+               return err
+       }
+
+       agents, err := openDaemonAgents(ctx, dir, address, auth, config.Compactor, time.Now, logger)
+       if err != nil {
+               return err
+       }
+
+       defer func() {
+               err = errors.Join(err, agents.Close())
+       }()
+
+       whatsapp.inbox.voice = &VoiceTranscriber{config: config.Voice}
+
+       root := agents[0]
+       agent := root.agent
+       schedules := root.schedules
+
+       listener, err := daemonListenSocket(socket)
+       if err != nil {
+               return err
+       }
+
+       linked := "not linked"
+       if whatsapp.isLinked() {
+               linked = "linked"
+       }
+
+       logger.Info("ready", "version", version, "socket", listener.Addr(), "model", agent.config.Model, "effort", agent.config.Effort, "workdir", agent.config.Workdir, "whatsapp", linked, "schedules", schedules.Len(), "children", agents.ChildNames())
+
+       var telegram *TelegramBot
+
+       if hasBot {
+               telegram = newTelegramBot(newTelegramAPI(telegramAPIURL, botSettings.Token), botSettings.Owner, filepath.Join(agent.dir, telegramOffsetFileName), daemonLogger(out, "telegram: "))
+
+               telegram.voice = &VoiceTranscriber{config: config.Voice}
+
+               logger.Info("Telegram bot started")
+       }
+
+       return daemonServe(ctx, listener, telegram, whatsapp, schedules, func(notify func(Event)) (*TurnLoop, error) {
+               return root.Loop(whatsapp, notify)
+       }, agents[1:]...)
 }
 
 // daemonListenSocket listens on the Unix socket at path, with mode 0600. The
 }
 
 // daemonListenSocket listens on the Unix socket at path, with mode 0600. The
index 227d23b9c24865ca1f04e1b9e8f7e2e48871c786..682fed78c4329d0752894470873910f92596d3de 100644 (file)
@@ -9,6 +9,22 @@ import (
        "time"
 )
 
        "time"
 )
 
+// DaemonAgent owns the open files and model client of one persistent agent. Its
+// loop must stop before Close releases the files.
+type DaemonAgent struct {
+       agent     *Agent
+       memory    *Memory
+       client    *ModelClient
+       subagents *Subagents
+       schedules *Schedules
+       clock     func() time.Time
+       logger    *slog.Logger
+       openChild func(context.Context, *Agent) (*DaemonAgent, error)
+}
+
+// DaemonAgents holds the root first and then its children in directory order.
+type DaemonAgents []*DaemonAgent
+
 // openDaemonAgents opens the root and its direct children. An invalid child
 // stops startup, and each opened agent is closed when startup fails.
 func openDaemonAgents(ctx context.Context, dir, address string, auth ModelAuthorizer, compactor ModelConfig, clock func() time.Time, logger *slog.Logger) (agents DaemonAgents, err error) {
 // openDaemonAgents opens the root and its direct children. An invalid child
 // stops startup, and each opened agent is closed when startup fails.
 func openDaemonAgents(ctx context.Context, dir, address string, auth ModelAuthorizer, compactor ModelConfig, clock func() time.Time, logger *slog.Logger) (agents DaemonAgents, err error) {
@@ -79,19 +95,6 @@ func openDaemonAgent(ctx context.Context, agent *Agent, address string, auth Mod
        }, nil
 }
 
        }, nil
 }
 
-// DaemonAgent owns the open files and model client of one persistent agent. Its
-// loop must stop before Close releases the files.
-type DaemonAgent struct {
-       agent     *Agent
-       memory    *Memory
-       client    *ModelClient
-       subagents *Subagents
-       schedules *Schedules
-       clock     func() time.Time
-       logger    *slog.Logger
-       openChild func(context.Context, *Agent) (*DaemonAgent, error)
-}
-
 // Loop makes the turn loop with its event destination. Only the root has a
 // WhatsApp link. Child events stay with their own logs.
 func (a *DaemonAgent) Loop(whatsapp *WhatsApp, notify func(Event)) (*TurnLoop, error) {
 // Loop makes the turn loop with its event destination. Only the root has a
 // WhatsApp link. Child events stay with their own logs.
 func (a *DaemonAgent) Loop(whatsapp *WhatsApp, notify func(Event)) (*TurnLoop, error) {
@@ -110,9 +113,6 @@ func (a *DaemonAgent) Close() error {
        return errors.Join(a.subagents.Close(), a.memory.Close())
 }
 
        return errors.Join(a.subagents.Close(), a.memory.Close())
 }
 
-// DaemonAgents holds the root first and then its children in directory order.
-type DaemonAgents []*DaemonAgent
-
 // Close closes all agents, including after a partial startup.
 func (a DaemonAgents) Close() error {
        var err error
 // Close closes all agents, including after a partial startup.
 func (a DaemonAgents) Close() error {
        var err error
index ef1360cc8ba88ac2eae59190e1b5db7b32fbc561..f1e4b0bf4ad0c4c111a9f1f4d3b63c71b99f00bf 100644 (file)
@@ -11,18 +11,6 @@ import (
        "time"
 )
 
        "time"
 )
 
-// daemonLogger returns a logger for one daemon part. JOURNAL_STREAM selects
-// syslog priorities without a time stamp. Other output carries the standard
-// time stamp and level.
-func daemonLogger(out io.Writer, prefix string) *slog.Logger {
-       return slog.New(&DaemonLogHandler{
-               out:     out,
-               prefix:  prefix,
-               journal: os.Getenv("JOURNAL_STREAM") != "",
-               mutex:   &sync.Mutex{},
-       })
-}
-
 // DaemonLogHandler writes short event lines with ordered attributes. Copies
 // share the writer lock. The err attribute follows all other attributes as a
 // cause. Journal lines carry a syslog priority instead of a time and level.
 // DaemonLogHandler writes short event lines with ordered attributes. Copies
 // share the writer lock. The err attribute follows all other attributes as a
 // cause. Journal lines carry a syslog priority instead of a time and level.
@@ -116,6 +104,18 @@ func (h *DaemonLogHandler) WithGroup(name string) slog.Handler {
        return &clone
 }
 
        return &clone
 }
 
+// daemonLogger returns a logger for one daemon part. JOURNAL_STREAM selects
+// syslog priorities without a time stamp. Other output carries the standard
+// time stamp and level.
+func daemonLogger(out io.Writer, prefix string) *slog.Logger {
+       return slog.New(&DaemonLogHandler{
+               out:     out,
+               prefix:  prefix,
+               journal: os.Getenv("JOURNAL_STREAM") != "",
+               mutex:   &sync.Mutex{},
+       })
+}
+
 // daemonLogAttr resolves and writes one attribute, or keeps its cause for the
 // end of the line. Groups flatten into dotted keys and empty attributes produce
 // no text.
 // daemonLogAttr resolves and writes one attribute, or keeps its cause for the
 // end of the line. Groups flatten into dotted keys and empty attributes produce
 // no text.
index 8ee26e83b4a5d1cbcf5692cefe8d8331494a85cd..b70947d7a0e934e3b44fa15b01d7574b7afabb05 100644 (file)
@@ -12,6 +12,43 @@ import (
        "sync"
 )
 
        "sync"
 )
 
+// DaemonMessages owns the durable queue between the root and its children and
+// the routed inputs. The mutex serializes queue writes and delivery. Backups
+// take it before the locks of the loops, memory, and subagents. A stored log ID
+// makes delivery idempotent, including a stop between the log append and the
+// wake.
+type DaemonMessages struct {
+       mutex   sync.Mutex
+       path    string
+       loops   map[string]*TurnLoop
+       ordered []*TurnLoop
+       state   DaemonMessageFile
+       failure error
+       routes  *Routes
+}
+
+// DaemonMessageFile holds the sequence, unacknowledged deliveries, and receipts
+// for WhatsApp batches until the inbox removes them. A receipt stays when its
+// turn finishes before inbox removal.
+type DaemonMessageFile struct {
+       Next            int             `json:"next"`
+       Pending         []DaemonMessage `json:"pending"`
+       WhatsAppBatches []int           `json:"whatsapp_batches,omitempty"`
+}
+
+// DaemonMessage is one queued delivery. LogID is set before the log append. A
+// nil LogID means that delivery has not started. WhatsAppBatch marks a routed
+// external batch, whose event source is WhatsApp rather than its queue sender.
+type DaemonMessage struct {
+       ID            int    `json:"id"`
+       Source        string `json:"source"`
+       Target        string `json:"target"`
+       Kind          string `json:"kind"`
+       Text          string `json:"text"`
+       LogID         *int   `json:"log_id,omitempty"`
+       WhatsAppBatch int    `json:"whatsapp_batch,omitempty"`
+}
+
 // openDaemonMessages connects the root and its children. It restores queued
 // messages before the loops start. A queue entry stays until its recipient
 // finishes the turn that takes it, so a stop can interrupt delivery or a turn.
 // openDaemonMessages connects the root and its children. It restores queued
 // messages before the loops start. A queue entry stays until its recipient
 // finishes the turn that takes it, so a stop can interrupt delivery or a turn.
@@ -109,21 +146,6 @@ func openDaemonMessages(loops []*TurnLoop) (*DaemonMessages, error) {
        return m, nil
 }
 
        return m, nil
 }
 
-// DaemonMessages owns the durable queue between the root and its children and
-// the routed inputs. The mutex serializes queue writes and delivery. Backups
-// take it before the locks of the loops, memory, and subagents. A stored log ID
-// makes delivery idempotent, including a stop between the log append and the
-// wake.
-type DaemonMessages struct {
-       mutex   sync.Mutex
-       path    string
-       loops   map[string]*TurnLoop
-       ordered []*TurnLoop
-       state   DaemonMessageFile
-       failure error
-       routes  *Routes
-}
-
 // deliver records one queued message and wakes its recipient. The caller holds
 // the mutex, or owns the queue before the loops start.
 func (m *DaemonMessages) deliver(message *DaemonMessage) error {
 // deliver records one queued message and wakes its recipient. The caller holds
 // the mutex, or owns the queue before the loops start.
 func (m *DaemonMessages) deliver(message *DaemonMessage) error {
@@ -441,25 +463,3 @@ func (m *DaemonMessages) Finish(loop *TurnLoop, active []Message, reply string)
 
        return nil
 }
 
        return nil
 }
-
-// DaemonMessageFile holds the sequence, unacknowledged deliveries, and receipts
-// for WhatsApp batches until the inbox removes them. A receipt stays when its
-// turn finishes before inbox removal.
-type DaemonMessageFile struct {
-       Next            int             `json:"next"`
-       Pending         []DaemonMessage `json:"pending"`
-       WhatsAppBatches []int           `json:"whatsapp_batches,omitempty"`
-}
-
-// DaemonMessage is one queued delivery. LogID is set before the log append. A
-// nil LogID means that delivery has not started. WhatsAppBatch marks a routed
-// external batch, whose event source is WhatsApp rather than its queue sender.
-type DaemonMessage struct {
-       ID            int    `json:"id"`
-       Source        string `json:"source"`
-       Target        string `json:"target"`
-       Kind          string `json:"kind"`
-       Text          string `json:"text"`
-       LogID         *int   `json:"log_id,omitempty"`
-       WhatsAppBatch int    `json:"whatsapp_batch,omitempty"`
-}
index 2122a934650ca72c9251c00452a6d2d290d6f70d..2a627e2730aba4beef10c51a2522cc5db40346b3 100644 (file)
@@ -22,6 +22,41 @@ type DaemonMessageTest struct {
        done     []chan error
 }
 
        done     []chan error
 }
 
+func newDaemonMessageTest(t *testing.T, dir string, server *ModelFake) *DaemonMessageTest {
+       t.Helper()
+
+       _, err := os.Stat(filepath.Join(dir, daemonAgentDirName, "children"))
+       if errors.Is(err, os.ErrNotExist) {
+               daemonChildConfig(t, dir, "Garden")
+               daemonChildConfig(t, dir, "Library")
+       } else {
+               require.NoError(t, err)
+       }
+
+       test := &DaemonMessageTest{
+               logs: &SyncBuffer{},
+       }
+       test.agents, err = openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, daemonTestLogger(test.logs))
+       require.NoError(t, err)
+       t.Cleanup(func() {
+               test.Close(t)
+       })
+
+       for _, agent := range test.agents {
+               events := &TurnEventRecorder{}
+               loop, err := agent.Loop(nil, events.Add)
+               require.NoError(t, err)
+
+               test.events = append(test.events, events)
+               test.loops = append(test.loops, loop)
+       }
+
+       test.messages, err = openDaemonMessages(test.loops)
+       require.NoError(t, err)
+
+       return test
+}
+
 func (r *DaemonMessageTest) Start(t *testing.T, indices ...int) {
        t.Helper()
 
 func (r *DaemonMessageTest) Start(t *testing.T, indices ...int) {
        t.Helper()
 
@@ -67,38 +102,3 @@ func (r *DaemonMessageTest) Close(t *testing.T) {
 
        r.agents = nil
 }
 
        r.agents = nil
 }
-
-func newDaemonMessageTest(t *testing.T, dir string, server *ModelFake) *DaemonMessageTest {
-       t.Helper()
-
-       _, err := os.Stat(filepath.Join(dir, daemonAgentDirName, "children"))
-       if errors.Is(err, os.ErrNotExist) {
-               daemonChildConfig(t, dir, "Garden")
-               daemonChildConfig(t, dir, "Library")
-       } else {
-               require.NoError(t, err)
-       }
-
-       test := &DaemonMessageTest{
-               logs: &SyncBuffer{},
-       }
-       test.agents, err = openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, daemonTestLogger(test.logs))
-       require.NoError(t, err)
-       t.Cleanup(func() {
-               test.Close(t)
-       })
-
-       for _, agent := range test.agents {
-               events := &TurnEventRecorder{}
-               loop, err := agent.Loop(nil, events.Add)
-               require.NoError(t, err)
-
-               test.events = append(test.events, events)
-               test.loops = append(test.loops, loop)
-       }
-
-       test.messages, err = openDaemonMessages(test.loops)
-       require.NoError(t, err)
-
-       return test
-}
diff --git a/log.go b/log.go
index 6d0d4784dfa9390f9f4a905d7c95539104be338a..2b9f32f5952c1285dda18bd78ff6ed0886849686 100644 (file)
--- a/log.go
+++ b/log.go
@@ -21,6 +21,38 @@ var logKinds = map[string]bool{
        "note":  true,
 }
 
        "note":  true,
 }
 
+// Log is the append-only message log of one chat. It writes one JSON line for
+// each message to main/YYYY-MM-DD.jsonl, named for the local day of the
+// message. It never edits or deletes a line. Each line goes to the operating
+// system before Append returns, so a crash of the process loses no message. An
+// exclusive lock on the lock file makes sure that only one process writes the
+// log. After a failed write, the end of the file is not known, so the log
+// refuses all writes until it is opened again. The caller serializes all calls.
+//
+// The log keeps no message in memory. The index index/main.idx holds the
+// position of message i in record i, so a read of a message is one read of the
+// index and one of its day file. At open the log indexes the lines after the
+// last indexed one, so a missing index is built again from the day files.
+type Log struct {
+       clock   func() time.Time
+       lock    *os.File
+       journal Journal
+       index   *Records
+       failure error
+}
+
+// Message is one entry of the chat log. ID is its position in the log, from 0.
+// A rebuild assigns positions in the merged log. Size is the UTF-8 byte length
+// of Text. Date is the message time in RFC 3339 format, supplied at append or
+// taken from the clock.
+type Message struct {
+       ID   int    `json:"i"`
+       Kind string `json:"kind"`
+       Text string `json:"text"`
+       Size int    `json:"size"`
+       Date string `json:"date"`
+}
+
 // openLog locks the chat in root and indexes its new lines. The clock gives the
 // date of each new message.
 func openLog(ctx context.Context, root string, clock func() time.Time) (*Log, error) {
 // openLog locks the chat in root and indexes its new lines. The clock gives the
 // date of each new message.
 func openLog(ctx context.Context, root string, clock func() time.Time) (*Log, error) {
@@ -57,26 +89,6 @@ func openLog(ctx context.Context, root string, clock func() time.Time) (*Log, er
        return l, nil
 }
 
        return l, nil
 }
 
-// Log is the append-only message log of one chat. It writes one JSON line for
-// each message to main/YYYY-MM-DD.jsonl, named for the local day of the
-// message. It never edits or deletes a line. Each line goes to the operating
-// system before Append returns, so a crash of the process loses no message. An
-// exclusive lock on the lock file makes sure that only one process writes the
-// log. After a failed write, the end of the file is not known, so the log
-// refuses all writes until it is opened again. The caller serializes all calls.
-//
-// The log keeps no message in memory. The index index/main.idx holds the
-// position of message i in record i, so a read of a message is one read of the
-// index and one of its day file. At open the log indexes the lines after the
-// last indexed one, so a missing index is built again from the day files.
-type Log struct {
-       clock   func() time.Time
-       lock    *os.File
-       journal Journal
-       index   *Records
-       failure error
-}
-
 // Close releases the lock. The log refuses all calls after Close.
 func (l *Log) Close() error {
        err := l.journal.Close()
 // Close releases the lock. The log refuses all calls after Close.
 func (l *Log) Close() error {
        err := l.journal.Close()
@@ -234,18 +246,6 @@ func (l *Log) addIndex(place Position) error {
        return l.index.Append(record)
 }
 
        return l.index.Append(record)
 }
 
-// Message is one entry of the chat log. ID is its position in the log, from 0.
-// A rebuild assigns positions in the merged log. Size is the UTF-8 byte length
-// of Text. Date is the message time in RFC 3339 format, supplied at append or
-// taken from the clock.
-type Message struct {
-       ID   int    `json:"i"`
-       Kind string `json:"kind"`
-       Text string `json:"text"`
-       Size int    `json:"size"`
-       Date string `json:"date"`
-}
-
 // logValidateMessage makes sure that a message has a known kind, valid UTF-8
 // text, the size of its text, and a valid date.
 func logValidateMessage(message Message) error {
 // logValidateMessage makes sure that a message has a known kind, valid UTF-8
 // text, the size of its text, and a valid date.
 func logValidateMessage(message Message) error {
index abd88a897a99e63c07f3955cc0f4b96a1c41b0cb..52824cb505787e95a8645bb2a55cbf8964442336 100644 (file)
@@ -11,6 +11,16 @@ import (
 // positionBytes is the size of an encoded position.
 const positionBytes = 16
 
 // positionBytes is the size of an encoded position.
 const positionBytes = 16
 
+// Records is a file of fixed-size Records, numbered from 0. Record n starts at
+// byte n*size. A record that was never written reads as zeros. An index is data
+// that the day files can give again, so Records cuts a partial record at the
+// end of the file.
+type Records struct {
+       file  *os.File
+       size  int
+       count int
+}
+
 // openRecords opens or creates the record file at path with records of size
 // bytes.
 func openRecords(path string, size int) (*Records, error) {
 // openRecords opens or creates the record file at path with records of size
 // bytes.
 func openRecords(path string, size int) (*Records, error) {
@@ -40,32 +50,6 @@ func openRecords(path string, size int) (*Records, error) {
        return r, nil
 }
 
        return r, nil
 }
 
-// encodePosition writes place into the first positionBytes bytes of record.
-func encodePosition(record []byte, place Position) {
-       binary.LittleEndian.PutUint32(record[0:], place.day)
-       binary.LittleEndian.PutUint32(record[4:], place.length)
-       binary.LittleEndian.PutUint64(record[8:], place.offset)
-}
-
-// decodePosition reads a position from the first positionBytes bytes of record.
-func decodePosition(record []byte) Position {
-       return Position{
-               day:    binary.LittleEndian.Uint32(record[0:]),
-               length: binary.LittleEndian.Uint32(record[4:]),
-               offset: binary.LittleEndian.Uint64(record[8:]),
-       }
-}
-
-// Records is a file of fixed-size Records, numbered from 0. Record n starts at
-// byte n*size. A record that was never written reads as zeros. An index is data
-// that the day files can give again, so Records cuts a partial record at the
-// end of the file.
-type Records struct {
-       file  *os.File
-       size  int
-       count int
-}
-
 // Read returns record n, or false when the file does not reach it.
 func (r *Records) Read(n int) ([]byte, bool, error) {
        if n >= r.count {
 // Read returns record n, or false when the file does not reach it.
 func (r *Records) Read(n int) ([]byte, bool, error) {
        if n >= r.count {
@@ -102,3 +86,19 @@ func (r *Records) Append(record []byte) error {
 func (r *Records) Close() error {
        return r.file.Close()
 }
 func (r *Records) Close() error {
        return r.file.Close()
 }
+
+// encodePosition writes place into the first positionBytes bytes of record.
+func encodePosition(record []byte, place Position) {
+       binary.LittleEndian.PutUint32(record[0:], place.day)
+       binary.LittleEndian.PutUint32(record[4:], place.length)
+       binary.LittleEndian.PutUint64(record[8:], place.offset)
+}
+
+// decodePosition reads a position from the first positionBytes bytes of record.
+func decodePosition(record []byte) Position {
+       return Position{
+               day:    binary.LittleEndian.Uint32(record[0:]),
+               length: binary.LittleEndian.Uint32(record[4:]),
+               offset: binary.LittleEndian.Uint64(record[8:]),
+       }
+}
index 7dce691ee0bd657e4c35c7bf8cde035d834588dd..02e28e2aac24d1271ad5fc69fb908c02b8c46639 100644 (file)
--- a/routes.go
+++ b/routes.go
@@ -13,6 +13,16 @@ import (
        "go.mau.fi/whatsmeow/types"
 )
 
        "go.mau.fi/whatsmeow/types"
 )
 
+// Routes owns the root's source map. Writes replace the private file before
+// changing the map in memory. The mutex protects reads and writes from tools
+// and status commands. Logs omit source IDs, which can hold phone numbers.
+type Routes struct {
+       mutex   sync.Mutex
+       path    string
+       logger  *slog.Logger
+       targets map[string]string
+}
+
 // openRoutes reads the source-to-child map. A missing file has no routes.
 func openRoutes(path string, logger *slog.Logger) (*Routes, error) {
        r := &Routes{
 // openRoutes reads the source-to-child map. A missing file has no routes.
 func openRoutes(path string, logger *slog.Logger) (*Routes, error) {
        r := &Routes{
@@ -52,16 +62,6 @@ func openRoutes(path string, logger *slog.Logger) (*Routes, error) {
        return r, nil
 }
 
        return r, nil
 }
 
-// Routes owns the root's source map. Writes replace the private file before
-// changing the map in memory. The mutex protects reads and writes from tools
-// and status commands. Logs omit source IDs, which can hold phone numbers.
-type Routes struct {
-       mutex   sync.Mutex
-       path    string
-       logger  *slog.Logger
-       targets map[string]string
-}
-
 // Set maps source to a child name, or removes it when target is empty. The
 // caller checks that a nonempty target is a child of the root.
 func (r *Routes) Set(source, target string) error {
 // Set maps source to a child name, or removes it when target is empty. The
 // caller checks that a nonempty target is a child of the root.
 func (r *Routes) Set(source, target string) error {
index 28a5a767777bd341e880b046dde2fac9e8d7f675..5fa35da63cb00d6cbbb13aaf6aba6ae055120a23 100644 (file)
@@ -48,6 +48,40 @@ const (
        scheduleLateLine = "This run is late: the daemon was stopped at that time."
 )
 
        scheduleLateLine = "This run is late: the daemon was stopped at that time."
 )
 
+// Schedules holds the schedules of the agent: tasks that start a turn at one
+// time or at the times of a cron expression. Each change writes the file whole
+// before it returns, so a schedule survives a restart. The mutex protects the
+// schedules, because the tools change them from a turn while Run reads them.
+// changed tells Run that a set or a remove changed the times.
+type Schedules struct {
+       path    string
+       owner   string
+       clock   func() time.Time
+       logger  *slog.Logger
+       changed chan struct{}
+
+       mutex sync.Mutex
+       file  ScheduleFile
+}
+
+// ScheduleFile is the content of the file of the schedules: the ID of the last
+// schedule, so that an ID is never used again, and the schedules.
+type ScheduleFile struct {
+       Next      int        `json:"next"`
+       Schedules []Schedule `json:"schedules"`
+}
+
+// Schedule is one schedule: its owner, its ID, the task for the agent, the cron
+// expression of a repeating schedule, empty for a one-time schedule, and its
+// next time.
+type Schedule struct {
+       Owner string    `json:"owner"`
+       ID    int       `json:"id"`
+       Text  string    `json:"text"`
+       Cron  string    `json:"cron"`
+       Due   time.Time `json:"due"`
+}
+
 // openSchedules reads the schedules of the file at path. A missing file holds
 // none. The owner names its agent. Schedules without an owner take this name. A
 // different stored owner is an error. The clock gives the time and the time
 // openSchedules reads the schedules of the file at path. A missing file holds
 // none. The owner names its agent. Schedules without an owner take this name. A
 // different stored owner is an error. The clock gives the time and the time
@@ -107,22 +141,6 @@ func openSchedules(path, owner string, clock func() time.Time, logger *slog.Logg
        return s, nil
 }
 
        return s, nil
 }
 
-// Schedules holds the schedules of the agent: tasks that start a turn at one
-// time or at the times of a cron expression. Each change writes the file whole
-// before it returns, so a schedule survives a restart. The mutex protects the
-// schedules, because the tools change them from a turn while Run reads them.
-// changed tells Run that a set or a remove changed the times.
-type Schedules struct {
-       path    string
-       owner   string
-       clock   func() time.Time
-       logger  *slog.Logger
-       changed chan struct{}
-
-       mutex sync.Mutex
-       file  ScheduleFile
-}
-
 // Run starts the runs of the due schedules in l until ctx ends. It waits until
 // the first due time, at most scheduleClockWaitTimeout at a time, and also
 // wakes at a set or a remove. So a schedule that came due while the daemon was
 // Run starts the runs of the due schedules in l until ctx ends. It waits until
 // the first due time, at most scheduleClockWaitTimeout at a time, and also
 // wakes at a set or a remove. So a schedule that came due while the daemon was
@@ -420,24 +438,6 @@ func (s *Schedules) save() error {
        return nil
 }
 
        return nil
 }
 
-// ScheduleFile is the content of the file of the schedules: the ID of the last
-// schedule, so that an ID is never used again, and the schedules.
-type ScheduleFile struct {
-       Next      int        `json:"next"`
-       Schedules []Schedule `json:"schedules"`
-}
-
-// Schedule is one schedule: its owner, its ID, the task for the agent, the cron
-// expression of a repeating schedule, empty for a one-time schedule, and its
-// next time.
-type Schedule struct {
-       Owner string    `json:"owner"`
-       ID    int       `json:"id"`
-       Text  string    `json:"text"`
-       Cron  string    `json:"cron"`
-       Due   time.Time `json:"due"`
-}
-
 // Message returns the text of a run at now for the log, with the times in the
 // location of now. The first line starts with [Schedule ID] and gives the due
 // time and once or the cron expression. Then come the text, the line about a
 // Message returns the text of a run at now for the log, with the times in the
 // location of now. The first line starts with [Schedule ID] and gives the due
 // time and once or the cron expression. Then come the text, the line about a
index 91fe8ce8b58e2be552af7138136b945f3b6e114c..3255f99571fdbd968f08352c0c7cbd451012f8c0 100644 (file)
@@ -53,61 +53,6 @@ var scheduleCronFields = []struct {
        },
 }
 
        },
 }
 
-// scheduleParseCron parses a cron expression of five fields: minute, hour, day
-// of month, month, and day of week, where 0 and 7 are Sunday. A field is a
-// comma list of items, and an item is *, N, or N-M, where * and N-M can have a
-// step /S. Names, macros, the characters ? L W #, and N/S are errors.
-//
-// It refuses an expression whose runs can be less than scheduleCronGap minutes
-// apart. The check takes the wall times of one day from the hour and the minute
-// fields, and the gap across midnight as if the next day matches too. It also
-// refuses an expression that matches no day in scheduleCronYears years, such as
-// one on February 30.
-func scheduleParseCron(expression string) (ScheduleCron, error) {
-       fields := strings.Fields(expression)
-       if len(fields) != len(scheduleCronFields) {
-               return ScheduleCron{}, fmt.Errorf("the cron expression %q has %d fields, not 5: minute, hour, day of month, month, day of week", expression, len(fields))
-       }
-
-       var sets [5]uint64
-
-       for index, field := range fields {
-               set, err := scheduleCronField(field, scheduleCronFields[index].low, scheduleCronFields[index].high)
-               if err != nil {
-                       return ScheduleCron{}, fmt.Errorf("the %s field of %q: %w", scheduleCronFields[index].name, expression, err)
-               }
-
-               sets[index] = set
-       }
-
-       c := ScheduleCron{
-               minutes:     sets[0],
-               hours:       sets[1],
-               days:        sets[2],
-               months:      sets[3],
-               weekdays:    sets[4],
-               anyDay:      strings.HasPrefix(fields[2], "*"),
-               anyWeekday:  strings.HasPrefix(fields[4], "*"),
-               description: strings.Join(fields, " "),
-       }
-
-       if c.weekdays&(1<<7) != 0 {
-               c.weekdays |= 1
-       }
-
-       if c.gap() < scheduleCronGap {
-               return ScheduleCron{}, fmt.Errorf("the runs of %q can be less than %d minutes apart: the runs of a schedule must be at least %d minutes apart", expression, scheduleCronGap, scheduleCronGap)
-       }
-
-       start := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
-       _, found := c.Next(start)
-       if !found {
-               return ScheduleCron{}, fmt.Errorf("the cron expression %q matches no day in %d years", expression, scheduleCronYears)
-       }
-
-       return c, nil
-}
-
 // ScheduleCron is a parsed cron expression. Each set holds a bit for each value
 // of its field. anyDay and anyWeekday tell that the day field or the weekday
 // field starts with *. When neither does, a day matches when either field
 // ScheduleCron is a parsed cron expression. Each set holds a bit for each value
 // of its field. anyDay and anyWeekday tell that the day field or the weekday
 // field starts with *. When neither does, a day matches when either field
@@ -209,6 +154,61 @@ func (c ScheduleCron) gap() int {
        return gap
 }
 
        return gap
 }
 
+// scheduleParseCron parses a cron expression of five fields: minute, hour, day
+// of month, month, and day of week, where 0 and 7 are Sunday. A field is a
+// comma list of items, and an item is *, N, or N-M, where * and N-M can have a
+// step /S. Names, macros, the characters ? L W #, and N/S are errors.
+//
+// It refuses an expression whose runs can be less than scheduleCronGap minutes
+// apart. The check takes the wall times of one day from the hour and the minute
+// fields, and the gap across midnight as if the next day matches too. It also
+// refuses an expression that matches no day in scheduleCronYears years, such as
+// one on February 30.
+func scheduleParseCron(expression string) (ScheduleCron, error) {
+       fields := strings.Fields(expression)
+       if len(fields) != len(scheduleCronFields) {
+               return ScheduleCron{}, fmt.Errorf("the cron expression %q has %d fields, not 5: minute, hour, day of month, month, day of week", expression, len(fields))
+       }
+
+       var sets [5]uint64
+
+       for index, field := range fields {
+               set, err := scheduleCronField(field, scheduleCronFields[index].low, scheduleCronFields[index].high)
+               if err != nil {
+                       return ScheduleCron{}, fmt.Errorf("the %s field of %q: %w", scheduleCronFields[index].name, expression, err)
+               }
+
+               sets[index] = set
+       }
+
+       c := ScheduleCron{
+               minutes:     sets[0],
+               hours:       sets[1],
+               days:        sets[2],
+               months:      sets[3],
+               weekdays:    sets[4],
+               anyDay:      strings.HasPrefix(fields[2], "*"),
+               anyWeekday:  strings.HasPrefix(fields[4], "*"),
+               description: strings.Join(fields, " "),
+       }
+
+       if c.weekdays&(1<<7) != 0 {
+               c.weekdays |= 1
+       }
+
+       if c.gap() < scheduleCronGap {
+               return ScheduleCron{}, fmt.Errorf("the runs of %q can be less than %d minutes apart: the runs of a schedule must be at least %d minutes apart", expression, scheduleCronGap, scheduleCronGap)
+       }
+
+       start := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
+       _, found := c.Next(start)
+       if !found {
+               return ScheduleCron{}, fmt.Errorf("the cron expression %q matches no day in %d years", expression, scheduleCronYears)
+       }
+
+       return c, nil
+}
+
 // scheduleWallTime returns the time of a wall time in location. A wall time
 // that the clock skips, at the start of daylight saving time, is later by the
 // length of the skip: 02:30 becomes 03:30 when the clock goes from 02:00 to
 // scheduleWallTime returns the time of a wall time in location. A wall time
 // that the clock skips, at the start of daylight saving time, is later by the
 // length of the skip: 02:30 becomes 03:30 when the clock goes from 02:00 to
index 0dffeb088c4c61013cf690c8b2188a0446ad6a5b..f87bec70ad5fca67fe1bce47e21cfc9d5bcb7c6f 100644 (file)
--- a/socket.go
+++ b/socket.go
@@ -20,15 +20,6 @@ const (
        terminalSource = "terminal"
 )
 
        terminalSource = "terminal"
 )
 
-// checkSocketPath makes sure that path fits in the address of a Unix socket.
-func checkSocketPath(path string) error {
-       if len(path) > socketPathBytes {
-               return fmt.Errorf("socket path %s is longer than %d bytes: set ONECHAT_DIR to a shorter path", path, socketPathBytes)
-       }
-
-       return nil
-}
-
 // SocketRequest is one line from a client: "send" with the text of a user
 // message, "interrupt", "stop" with the name of a subagent in Text, or
 // "whatsapp" with the text after /whatsapp of a WhatsApp command, or "backup"
 // SocketRequest is one line from a client: "send" with the text of a user
 // message, "interrupt", "stop" with the name of a subagent in Text, or
 // "whatsapp" with the text after /whatsapp of a WhatsApp command, or "backup"
@@ -40,3 +31,12 @@ type SocketRequest struct {
        Text string `json:"text,omitempty"`
        Path string `json:"path,omitempty"`
 }
        Text string `json:"text,omitempty"`
        Path string `json:"path,omitempty"`
 }
+
+// checkSocketPath makes sure that path fits in the address of a Unix socket.
+func checkSocketPath(path string) error {
+       if len(path) > socketPathBytes {
+               return fmt.Errorf("socket path %s is longer than %d bytes: set ONECHAT_DIR to a shorter path", path, socketPathBytes)
+       }
+
+       return nil
+}