From: Anderson A. Giacomolli Date: Tue, 10 Nov 2026 15:03:38 +0000 (-0300) Subject: Send terminal images by local path X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/d28e3b2858db322899a81b59a30400c1acad2b42 Send terminal images by local path 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. --- diff --git a/README.rst b/README.rst index cb47c98..1c344f4 100644 --- a/README.rst +++ b/README.rst @@ -154,6 +154,9 @@ replies show with Markdown styles and tables. These lines are commands: ``/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. @@ -191,8 +194,9 @@ WhatsApp 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 diff --git a/daemon.go b/daemon.go index 2a93f1d..137cc6f 100644 --- 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 + 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") diff --git a/main.go b/main.go index 843bf90..10ac995 100644 --- 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. +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. diff --git a/socket.go b/socket.go index 7b2da2b..0dffeb0 100644 --- 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 -// 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"` + Path string `json:"path,omitempty"` } diff --git a/terminal.go b/terminal.go index 9945620..da0d78a 100644 --- a/terminal.go +++ b/terminal.go @@ -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 -// 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 { @@ -99,6 +100,7 @@ func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error { name, stop := stopSubagentName(line) args, whatsapp := whatsappCommandArgs(line) + image, isImage := terminalImageRequest(line) 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, } + 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", diff --git a/terminal_image.go b/terminal_image.go new file mode 100644 index 0000000..f6f4101 --- /dev/null +++ b/terminal_image.go @@ -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 index 0000000..46a7cfd --- /dev/null +++ b/terminal_image_test.go @@ -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()) +}