]> git.giacomolli.uk - onechat/commitdiff
Send terminal images by local path master
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Tue, 10 Nov 2026 15:03:38 +0000 (12:03 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Tue, 10 Nov 2026 15:30:08 +0000 (12:30 -0300)
Accept /image PATH with an optional caption and quoted paths with
spaces. Resolve relative paths in the client and let the daemon store
the image before it submits the marker and caption. The socket carries
only the path, so image bytes never enter its events or the chat log.

README.rst
daemon.go
main.go
socket.go
terminal.go
terminal_image.go [new file with mode: 0644]
terminal_image_test.go [new file with mode: 0644]

index cb47c987a9ebea60e4829c5ef3a6c21638706e80..1c344f4bd11c967c6c5f51d5776395b2795ac4de 100644 (file)
@@ -154,6 +154,9 @@ replies show with Markdown styles and tables. These lines are commands:
 ``/stop NAME``
    Stops the subagent NAME.
 
 ``/stop NAME``
    Stops the subagent NAME.
 
+``/image PATH [caption]``
+   Sends a JPEG, PNG, or WebP image. Quote a path with spaces.
+
 ``/whatsapp ...``
    The WhatsApp commands above.
 
 ``/whatsapp ...``
    The WhatsApp commands above.
 
@@ -191,8 +194,9 @@ WhatsApp
    child of that area, and sets the route after you agree.
 
 Images
    child of that area, and sets the route after you agree.
 
 Images
-   Send a photo, and the agent sees it. Its memory keeps what the image
-   shows, and it can look at an older image again.
+   Send a photo in Telegram or with ``/image``, and the agent sees it.
+   Its memory keeps what the image shows, and it can look at an older
+   image again.
 
 Schedules
    Ask for reminders and timed tasks, such as "every weekday at 8, tell
 
 Schedules
    Ask for reminders and timed tasks, such as "every weekday at 8, tell
index 2a93f1dfbdd699ec48eac3a300e7b4cc802a1109..137cc6f42a8e833ec5da6a6481847356b20ca565 100644 (file)
--- a/daemon.go
+++ b/daemon.go
@@ -493,6 +493,27 @@ func daemonRunRequest(l *TurnLoop, whatsapp *WhatsApp, request SocketRequest, an
                _, err := l.Submit(terminalSource, request.Text)
 
                return err
                _, err := l.Submit(terminalSource, request.Text)
 
                return err
+       case "image":
+               if !filepath.IsAbs(request.Path) {
+                       return errors.New("use /image PATH [caption]")
+               }
+
+               data, err := imageReadFile(request.Path)
+               if err != nil {
+                       return fmt.Errorf("read terminal image: %w", err)
+               }
+
+               name, err := l.agent.Images().Save(data)
+               if err != nil {
+                       return fmt.Errorf("store terminal image: %w", err)
+               }
+
+               _, err = l.Submit(terminalSource, imageText([]string{name}, request.Text))
+               if err != nil {
+                       return fmt.Errorf("submit terminal image: %w", err)
+               }
+
+               return nil
        case "interrupt":
                if !l.Interrupt() {
                        return errors.New("no turn runs")
        case "interrupt":
                if !l.Interrupt() {
                        return errors.New("no turn runs")
diff --git a/main.go b/main.go
index 843bf9016905ab9d80fc6958f812cb5bf6a0b5d9..10ac995a9555b2885070e2d8baf77a1eed30fa4f 100644 (file)
--- a/main.go
+++ b/main.go
@@ -28,6 +28,8 @@ Commands:
   help                     show this help
 
 Chat needs a running daemon. ONECHAT_DIR sets the data directory.
   help                     show this help
 
 Chat needs a running daemon. ONECHAT_DIR sets the data directory.
+In chat, /image PATH [caption] sends a JPEG, PNG, or WebP image up to 20 MB.
+Quote PATH with single or double quotes when it contains spaces.
 `
 
 // version is set by the package build and stays dev for direct Go builds.
 `
 
 // version is set by the package build and stays dev for direct Go builds.
index 7b2da2b7a2077e11529c051efe4816de80827bf9..0dffeb088c4c61013cf690c8b2188a0446ad6a5b 100644 (file)
--- a/socket.go
+++ b/socket.go
@@ -33,8 +33,10 @@ func checkSocketPath(path string) error {
 // message, "interrupt", "stop" with the name of a subagent in Text, or
 // "whatsapp" with the text after /whatsapp of a WhatsApp command, or "backup"
 // with the absolute output path in Text. The backup answer is a "backup" event
 // message, "interrupt", "stop" with the name of a subagent in Text, or
 // "whatsapp" with the text after /whatsapp of a WhatsApp command, or "backup"
 // with the absolute output path in Text. The backup answer is a "backup" event
-// with the path and size, or an "error" event.
+// with the path and size, or an "error" event. An "image" request has an
+// absolute Path and the caption in Text.
 type SocketRequest struct {
        Type string `json:"type"`
        Text string `json:"text,omitempty"`
 type SocketRequest struct {
        Type string `json:"type"`
        Text string `json:"text,omitempty"`
+       Path string `json:"path,omitempty"`
 }
 }
index 9945620bdb5fd5329ef6e9e1bc32b922f2cef31a..da0d78aa4fb6c95160ed1f42fb77631820a426d2 100644 (file)
@@ -23,9 +23,10 @@ var errTerminalDaemonClosed = errors.New("the daemon closed the connection")
 // terminal runs "onechat" and "onechat chat": the terminal client of the
 // daemon. Each line of input is a user message, the line /stop interrupts the
 // running turn, the line /stop NAME stops the subagent NAME, and a line that
 // terminal runs "onechat" and "onechat chat": the terminal client of the
 // daemon. Each line of input is a user message, the line /stop interrupts the
 // running turn, the line /stop NAME stops the subagent NAME, and a line that
-// starts with /whatsapp is a WhatsApp command. The client shows the replies,
-// the tool calls, and their results as they happen. It ends at the end of the
-// input or when the daemon closes the connection.
+// starts with /whatsapp is a WhatsApp command. /image PATH [caption] sends an
+// image from the client working directory. The client shows the replies, the
+// tool calls, and their results as they happen. It ends at the end of the input
+// or when the daemon closes the connection.
 func terminal(ctx context.Context, in io.Reader, out io.Writer) error {
        dir, err := dataDir()
        if err != nil {
 func terminal(ctx context.Context, in io.Reader, out io.Writer) error {
        dir, err := dataDir()
        if err != nil {
@@ -99,6 +100,7 @@ func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error {
 
                name, stop := stopSubagentName(line)
                args, whatsapp := whatsappCommandArgs(line)
 
                name, stop := stopSubagentName(line)
                args, whatsapp := whatsappCommandArgs(line)
+               image, isImage := terminalImageRequest(line)
                switch {
                case strings.TrimSpace(line) == "":
                        continue
                switch {
                case strings.TrimSpace(line) == "":
                        continue
@@ -111,6 +113,16 @@ func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error {
                                Type: "stop",
                                Text: name,
                        }
                                Type: "stop",
                                Text: name,
                        }
+               case isImage:
+                       request = image
+                       if request.Path != "" {
+                               var err error
+
+                               request.Path, err = filepath.Abs(request.Path)
+                               if err != nil {
+                                       return fmt.Errorf("find terminal image path: %w", err)
+                               }
+                       }
                case whatsapp:
                        request = SocketRequest{
                                Type: "whatsapp",
                case whatsapp:
                        request = SocketRequest{
                                Type: "whatsapp",
diff --git a/terminal_image.go b/terminal_image.go
new file mode 100644 (file)
index 0000000..f6f4101
--- /dev/null
@@ -0,0 +1,46 @@
+package main
+
+import "strings"
+
+// terminalImageRequest reads /image with one path and an optional caption.
+// Single and double quotes permit spaces in a path, with no shell expansion. An
+// invalid path argument becomes an empty path for the daemon to refuse.
+func terminalImageRequest(line string) (SocketRequest, bool) {
+       rest, found := strings.CutPrefix(strings.TrimLeft(line, " \t"), "/image")
+       if !found || (rest != "" && rest[0] != ' ' && rest[0] != '\t') {
+               return SocketRequest{}, false
+       }
+
+       request := SocketRequest{Type: "image"}
+       rest = strings.TrimLeft(rest, " \t")
+       if rest == "" {
+               return request, true
+       }
+
+       if rest[0] == '\'' || rest[0] == '"' {
+               end := strings.IndexByte(rest[1:], rest[0])
+               if end < 0 {
+                       return request, true
+               }
+
+               request.Path = rest[1 : 1+end]
+               rest = rest[end+2:]
+               if rest != "" && rest[0] != ' ' && rest[0] != '\t' {
+                       request.Path = ""
+                       return request, true
+               }
+       } else {
+               end := strings.IndexAny(rest, " \t")
+               if end < 0 {
+                       request.Path = rest
+                       return request, true
+               }
+
+               request.Path = rest[:end]
+               rest = rest[end:]
+       }
+
+       request.Text = strings.TrimLeft(rest, " \t")
+
+       return request, true
+}
diff --git a/terminal_image_test.go b/terminal_image_test.go
new file mode 100644 (file)
index 0000000..46a7cfd
--- /dev/null
@@ -0,0 +1,157 @@
+package main
+
+import (
+       "bytes"
+       "encoding/json"
+       "net"
+       "os"
+       "path/filepath"
+       "strings"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestTerminalImageLineSendsAnAbsolutePathAndCaption(t *testing.T) {
+       client, daemon := net.Pipe()
+       defer func() {
+               require.NoError(t, client.Close())
+               require.NoError(t, daemon.Close())
+       }()
+
+       done := make(chan error, 1)
+       go func() {
+               done <- terminalSendLines(t.Context(), client, strings.NewReader("/image 'image sample.png' Read the label.\n"))
+       }()
+
+       var request SocketRequest
+
+       require.NoError(t, json.NewDecoder(daemon).Decode(&request))
+       require.NoError(t, <-done)
+
+       expected, err := filepath.Abs("image sample.png")
+       require.NoError(t, err)
+       require.Equal(t, SocketRequest{
+               Type: "image",
+               Path: expected,
+               Text: "Read the label.",
+       }, request)
+}
+
+func TestTerminalImagePathsAndCaptionsHaveNoShellExpansion(t *testing.T) {
+       testCases := []struct {
+               name    string
+               line    string
+               path    string
+               caption string
+               command bool
+       }{
+               {
+                       name:    "accepts paths without captions",
+                       line:    "/image sample.png",
+                       path:    "sample.png",
+                       command: true,
+               },
+               {
+                       name:    "keeps spaces in double quoted paths",
+                       line:    `/image "a b.png" caption`,
+                       path:    "a b.png",
+                       caption: "caption",
+                       command: true,
+               },
+               {
+                       name:    "caption stays literal",
+                       line:    "/image sample.png $(not-a-command)  ",
+                       path:    "sample.png",
+                       caption: "$(not-a-command)  ",
+                       command: true,
+               },
+               {
+                       name:    "missing path refused",
+                       line:    "/image",
+                       command: true,
+               },
+               {
+                       name:    "unclosed quote refused",
+                       line:    `/image "sample.png`,
+                       command: true,
+               },
+               {
+                       name:    "joined caption refused",
+                       line:    `/image "sample.png"caption`,
+                       command: true,
+               },
+               {
+                       name:    "other command stays text",
+                       line:    "/images sample.png",
+                       command: false,
+               },
+       }
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       request, command := terminalImageRequest(tc.line)
+                       require.Equal(t, tc.command, command)
+                       require.Equal(t, tc.path, request.Path)
+                       require.Equal(t, tc.caption, request.Text)
+               })
+       }
+}
+
+func TestTerminalImageIsStoredBeforeItReachesTheModel(t *testing.T) {
+       server := newModelFake(t)
+       socket := daemonStartTest(t, server)
+       terminal := terminalStartTestClient(t, socket)
+       terminal.WaitFor(t, "[connected to OneChat]")
+
+       path := filepath.Join(t.TempDir(), "sample.png")
+       data := imageTestPNG(t, 2, 2)
+       require.NoError(t, os.WriteFile(path, data, 0o600))
+       terminal.Type("/image " + path + " What is this?")
+       terminal.WaitFor(t, "[done]")
+       require.Equal(t, 1, server.Calls())
+       require.Contains(t, string(server.Call(0).raw), "input_image")
+       require.Contains(t, string(server.Call(0).raw), "What is this?")
+       require.NotContains(t, string(server.Call(0).raw), path)
+
+       files, err := os.ReadDir(filepath.Join(filepath.Dir(socket), imageDirName))
+       require.NoError(t, err)
+       require.Len(t, files, 1)
+
+       stored, err := os.ReadFile(filepath.Join(filepath.Dir(socket), imageDirName, files[0].Name()))
+       require.NoError(t, err)
+       require.Equal(t, data, stored)
+       require.NotContains(t, terminal.Output(), "base64")
+}
+
+func TestTerminalRefusesLargeAndUnsupportedImagesWithoutAModelCall(t *testing.T) {
+       server := newModelFake(t)
+       test := newTestTurnLoop(t, server)
+       path := filepath.Join(t.TempDir(), "sample.png")
+       require.NoError(t, os.WriteFile(path, bytes.Repeat([]byte{0}, imageMaxBytes+1), 0o600))
+
+       request := SocketRequest{
+               Type: "image",
+               Path: path,
+       }
+       require.ErrorContains(t, daemonRunRequest(test.loop, nil, request, func(string) {
+       }), "exceeds 20 MB")
+       require.NoError(t, os.WriteFile(path, []byte("not an image"), 0o600))
+       require.ErrorContains(t, daemonRunRequest(test.loop, nil, request, func(string) {
+       }), "JPEG, PNG, or WebP")
+       require.Equal(t, 0, test.memory.Len())
+       require.Equal(t, 0, server.Calls())
+
+       _, err := os.Stat(test.agent.Images().dir)
+       require.ErrorIs(t, err, os.ErrNotExist)
+}
+
+func TestImageCommandErrorDoesNotEndTheTerminalClient(t *testing.T) {
+       socket := daemonStartTest(t, newModelFake(t))
+       terminal := terminalStartTestClient(t, socket)
+       terminal.WaitFor(t, "[connected to OneChat]")
+       terminal.Type("/image")
+       terminal.WaitFor(t, "use /image PATH [caption]")
+       terminal.Type("Continue.")
+       terminal.WaitFor(t, "[done]")
+       require.NoError(t, terminal.Close())
+}