694 lines
19 KiB
Go
694 lines
19 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package helpers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
)
|
|
|
|
const (
|
|
opencodeServerUsername = "opencode"
|
|
opencodeServerHealthPath = "/global/health"
|
|
opencodeNoTextReply = "(本地 agent 无文本输出)"
|
|
opencodeServerPollInterval = 150 * time.Millisecond
|
|
opencodeConfigContentEnv = "OPENCODE_CONFIG_CONTENT"
|
|
opencodePermissionEnv = "OPENCODE_PERMISSION"
|
|
// opencodeHealthProbeTimeout bounds a single /global/health probe so startup
|
|
// detection stays snappy even though the shared http.Client has no overall
|
|
// deadline (message turns rely on the per-turn ctx instead).
|
|
opencodeHealthProbeTimeout = 10 * time.Second
|
|
)
|
|
|
|
var errOpencodeSessionMissing = errors.New("opencode session missing")
|
|
|
|
var (
|
|
opencodeServerStartupWait = 20 * time.Second
|
|
opencodeAbsPath = filepath.Abs
|
|
opencodeFreeLocalPort = freeLocalPort
|
|
opencodeExecCommand = exec.Command
|
|
opencodeRandRead = rand.Read
|
|
opencodeWaitHealthy = func(s *opencodeServer, ctx context.Context, client *opencodeHTTPClient) error {
|
|
return s.waitHealthy(ctx, client)
|
|
}
|
|
freeLocalPortListen = net.Listen
|
|
)
|
|
|
|
// opencodeForwarder forwards DingTalk messages to a local `opencode serve`
|
|
// instance. DWS owns the local server process and keeps a per-conversation
|
|
// mapping to opencode's native session id; opencode owns the actual message
|
|
// history in its own storage.
|
|
type opencodeForwarder struct {
|
|
bin string
|
|
env []string
|
|
timeout time.Duration
|
|
workDir string
|
|
model string
|
|
yolo bool
|
|
sessions *opencodeSessions // convID→opencode sessionID, persisted; nil = stateless
|
|
server *opencodeServer
|
|
}
|
|
|
|
func newOpencodeForwarder(bin string, env []string, timeout time.Duration, opts connectAgentOptions, clientID string) forwarder {
|
|
var sessions *opencodeSessions
|
|
if opts.Memory {
|
|
sessions = newOpencodeSessions(opencodeSessionStorePath(clientID))
|
|
}
|
|
f := &opencodeForwarder{
|
|
bin: bin,
|
|
env: env,
|
|
timeout: timeout,
|
|
workDir: opts.WorkDir,
|
|
model: opts.Model,
|
|
yolo: opts.Yolo,
|
|
sessions: sessions,
|
|
}
|
|
f.server = newOpencodeServer(bin, env, f.cwd(), opts.Yolo)
|
|
return f
|
|
}
|
|
|
|
// Group-chat bots answer once per message. opencode server still supports
|
|
// events, but DWS deliberately uses the synchronous message API here.
|
|
func (f *opencodeForwarder) canStream() bool { return false }
|
|
|
|
func (f *opencodeForwarder) label() string {
|
|
memo := "stateless"
|
|
if f.sessions != nil {
|
|
memo = "server-session-memory"
|
|
}
|
|
return fmt.Sprintf("opencode-server:%s (%s)", f.bin, memo)
|
|
}
|
|
|
|
func (f *opencodeForwarder) forward(ctx context.Context, convID, text string) (string, error) {
|
|
return f.forwardStream(ctx, convID, text, nil)
|
|
}
|
|
|
|
func (f *opencodeForwarder) forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
|
return f.forwardStreamWithAttachments(ctx, convID, text, attachments, nil)
|
|
}
|
|
|
|
func (f *opencodeForwarder) forwardStream(ctx context.Context, convID, text string, _ func(string)) (string, error) {
|
|
return f.forwardStreamWithAttachments(ctx, convID, text, nil, nil)
|
|
}
|
|
|
|
func (f *opencodeForwarder) forwardStreamWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment, _ func(string)) (string, error) {
|
|
ctx, cancel := applyTimeout(ctx, f.timeout)
|
|
defer cancel()
|
|
|
|
client, err := f.server.ensure(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
reply, err := f.forwardWithClient(ctx, client, convID, text, attachments)
|
|
if errors.Is(err, errOpencodeSessionMissing) && f.sessions != nil {
|
|
f.sessions.reset(convID)
|
|
reply, err = f.forwardWithClient(ctx, client, convID, text, attachments)
|
|
}
|
|
return reply, err
|
|
}
|
|
|
|
func (f *opencodeForwarder) forwardWithClient(ctx context.Context, client *opencodeHTTPClient, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
|
sessionID := ""
|
|
if f.sessions != nil {
|
|
sessionID = f.sessions.id(convID)
|
|
}
|
|
if sessionID == "" {
|
|
created, err := client.createSession(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sessionID = created
|
|
if f.sessions != nil {
|
|
f.sessions.set(convID, sessionID)
|
|
}
|
|
}
|
|
reply, err := client.sendMessageWithAttachments(ctx, sessionID, text, f.model, attachments)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if agentReplyIsError(reply) {
|
|
return agentBackendErrorReply(reply), nil
|
|
}
|
|
if strings.TrimSpace(reply) == "" {
|
|
return opencodeNoTextReply, nil
|
|
}
|
|
return brandReply(f.name(), reply), nil
|
|
}
|
|
|
|
func (f *opencodeForwarder) name() string { return "opencode" }
|
|
|
|
func (f *opencodeForwarder) cwd() string {
|
|
if f.workDir == "" {
|
|
return connectWorkDir()
|
|
}
|
|
if abs, err := opencodeAbsPath(f.workDir); err == nil {
|
|
return abs
|
|
}
|
|
return f.workDir
|
|
}
|
|
|
|
func (f *opencodeForwarder) close() error {
|
|
if f.server != nil {
|
|
return f.server.close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resetSession drops the conversation's opencode session so the next message
|
|
// starts a fresh one, leaving the old session on the opencode server (it stays
|
|
// resumable by id). Backs the /new command. A no-op when memory is disabled.
|
|
func (f *opencodeForwarder) resetSession(convID string) {
|
|
if f.sessions != nil {
|
|
f.sessions.reset(convID)
|
|
}
|
|
}
|
|
|
|
// clearSession backs the /clear command: it actively deletes the conversation's
|
|
// session on the opencode server (DELETE /session/:id) and then drops the local
|
|
// mapping, so the context is truly wiped rather than just forgotten. A missing
|
|
// server session is treated as already cleared. Implements sessionClearer.
|
|
func (f *opencodeForwarder) clearSession(ctx context.Context, convID string) error {
|
|
if f.sessions == nil {
|
|
return nil
|
|
}
|
|
sessionID := f.sessions.id(convID)
|
|
f.sessions.reset(convID)
|
|
if strings.TrimSpace(sessionID) == "" {
|
|
return nil
|
|
}
|
|
client, err := f.server.ensure(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return client.deleteSession(ctx, sessionID)
|
|
}
|
|
|
|
type opencodeServer struct {
|
|
bin string
|
|
env []string
|
|
workDir string
|
|
yolo bool
|
|
mu sync.Mutex
|
|
baseURL string
|
|
password string
|
|
cmd *exec.Cmd
|
|
done chan error
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func newOpencodeServer(bin string, env []string, workDir string, yolo bool) *opencodeServer {
|
|
return &opencodeServer{
|
|
bin: bin,
|
|
env: env,
|
|
workDir: workDir,
|
|
yolo: yolo,
|
|
// No client-level Timeout: a turn can legitimately run for minutes.
|
|
// f.timeout (from --agent-timeout / DWS_AGENT_TIMEOUT_MS, default 0 =
|
|
// no limit) governs via the per-request ctx when set.
|
|
httpClient: &http.Client{},
|
|
}
|
|
}
|
|
|
|
func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.httpClient == nil {
|
|
s.httpClient = &http.Client{}
|
|
}
|
|
client := &opencodeHTTPClient{baseURL: s.baseURL, username: opencodeServerUsername, password: s.password, httpClient: s.httpClient}
|
|
if s.baseURL != "" && client.health(ctx) == nil {
|
|
return client, nil
|
|
}
|
|
if s.cmd != nil {
|
|
_ = s.closeLocked()
|
|
}
|
|
|
|
port, err := opencodeFreeLocalPort()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
password := randomHex(24)
|
|
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
|
|
cmd := opencodeExecCommand(s.bin, "serve", "--pure", "--hostname", "127.0.0.1", "--port", fmt.Sprint(port))
|
|
if s.workDir != "" {
|
|
cmd.Dir = s.workDir
|
|
}
|
|
cmd.Env = s.commandEnv(password)
|
|
cmd.Stdout = os.Stderr
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("启动 opencode serve 失败:%w", err)
|
|
}
|
|
|
|
s.baseURL = baseURL
|
|
s.password = password
|
|
s.cmd = cmd
|
|
s.done = make(chan error, 1)
|
|
go func() {
|
|
s.done <- cmd.Wait()
|
|
}()
|
|
client = &opencodeHTTPClient{baseURL: s.baseURL, username: opencodeServerUsername, password: s.password, httpClient: s.httpClient}
|
|
if err := opencodeWaitHealthy(s, ctx, client); err != nil {
|
|
_ = s.closeLocked()
|
|
return nil, err
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
func (s *opencodeServer) commandEnv(password string) []string {
|
|
env := append([]string{}, os.Environ()...)
|
|
env = append(env, s.env...)
|
|
env = upsertEnv(env, "OPENCODE_SERVER_USERNAME", opencodeServerUsername)
|
|
env = upsertEnv(env, "OPENCODE_SERVER_PASSWORD", password)
|
|
config := opencodeNonInteractiveConfig(envValue(env, opencodeConfigContentEnv))
|
|
env = upsertEnv(env, opencodeConfigContentEnv, config)
|
|
env = upsertEnv(env, opencodePermissionEnv, opencodeNonInteractivePermission())
|
|
return env
|
|
}
|
|
|
|
func opencodeNonInteractiveConfig(existing string) string {
|
|
cfg := map[string]any{}
|
|
if strings.TrimSpace(existing) != "" {
|
|
_ = json.Unmarshal([]byte(existing), &cfg)
|
|
}
|
|
tools, _ := cfg["tools"].(map[string]any)
|
|
if tools == nil {
|
|
tools = map[string]any{}
|
|
}
|
|
tools["question"] = false
|
|
cfg["tools"] = tools
|
|
|
|
permission, _ := cfg["permission"].(map[string]any)
|
|
if permission == nil {
|
|
permission = map[string]any{}
|
|
}
|
|
for k, v := range opencodeNonInteractivePermissionMap() {
|
|
permission[k] = v
|
|
}
|
|
cfg["permission"] = permission
|
|
|
|
data, _ := json.Marshal(cfg)
|
|
return string(data)
|
|
}
|
|
|
|
func opencodeNonInteractivePermission() string {
|
|
data, _ := json.Marshal(opencodeNonInteractivePermissionMap())
|
|
return string(data)
|
|
}
|
|
|
|
func opencodeNonInteractivePermissionMap() map[string]string {
|
|
return map[string]string{
|
|
"*": "allow",
|
|
"read": "allow",
|
|
"edit": "allow",
|
|
"write": "allow",
|
|
"patch": "allow",
|
|
"apply_patch": "allow",
|
|
"glob": "allow",
|
|
"grep": "allow",
|
|
"bash": "allow",
|
|
"task": "allow",
|
|
"skill": "allow",
|
|
"lsp": "allow",
|
|
"webfetch": "allow",
|
|
"websearch": "allow",
|
|
"question": "deny",
|
|
"external_directory": "allow",
|
|
"doom_loop": "allow",
|
|
}
|
|
}
|
|
|
|
func envValue(env []string, key string) string {
|
|
prefix := key + "="
|
|
for i := len(env) - 1; i >= 0; i-- {
|
|
if strings.HasPrefix(env[i], prefix) {
|
|
return strings.TrimPrefix(env[i], prefix)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func upsertEnv(env []string, key, value string) []string {
|
|
prefix := key + "="
|
|
out := make([]string, 0, len(env)+1)
|
|
for _, item := range env {
|
|
if strings.HasPrefix(item, prefix) {
|
|
continue
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return append(out, prefix+value)
|
|
}
|
|
|
|
func (s *opencodeServer) waitHealthy(ctx context.Context, client *opencodeHTTPClient) error {
|
|
deadline := time.Now().Add(opencodeServerStartupWait)
|
|
var lastErr error
|
|
for time.Now().Before(deadline) {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if s.done != nil {
|
|
select {
|
|
case err := <-s.done:
|
|
s.done = nil
|
|
if err != nil {
|
|
return fmt.Errorf("opencode serve 已退出:%w", err)
|
|
}
|
|
return fmt.Errorf("opencode serve 已退出")
|
|
default:
|
|
}
|
|
}
|
|
if err := client.health(ctx); err == nil {
|
|
return nil
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
helperSleep(opencodeServerPollInterval)
|
|
}
|
|
if lastErr != nil {
|
|
return fmt.Errorf("等待 opencode serve 就绪超时:%w", lastErr)
|
|
}
|
|
return fmt.Errorf("等待 opencode serve 就绪超时")
|
|
}
|
|
|
|
func (s *opencodeServer) close() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.closeLocked()
|
|
}
|
|
|
|
func (s *opencodeServer) closeLocked() error {
|
|
if s.cmd == nil || s.cmd.Process == nil {
|
|
s.cmd = nil
|
|
s.done = nil
|
|
return nil
|
|
}
|
|
err := s.cmd.Process.Kill()
|
|
if s.done != nil {
|
|
<-s.done
|
|
}
|
|
s.cmd = nil
|
|
s.done = nil
|
|
s.baseURL = ""
|
|
s.password = ""
|
|
return err
|
|
}
|
|
|
|
type opencodeHTTPClient struct {
|
|
baseURL string
|
|
username string
|
|
password string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func (c *opencodeHTTPClient) health(ctx context.Context) error {
|
|
ctx, cancel := context.WithTimeout(ctx, opencodeHealthProbeTimeout)
|
|
defer cancel()
|
|
var out struct {
|
|
Healthy bool `json:"healthy"`
|
|
}
|
|
if err := c.doJSON(ctx, http.MethodGet, opencodeServerHealthPath, nil, &out); err != nil {
|
|
return err
|
|
}
|
|
if !out.Healthy {
|
|
return fmt.Errorf("opencode serve health=false")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *opencodeHTTPClient) createSession(ctx context.Context) (string, error) {
|
|
var out struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := c.doJSON(ctx, http.MethodPost, "/session", map[string]any{}, &out); err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(out.ID) == "" {
|
|
return "", fmt.Errorf("opencode server response missing session id")
|
|
}
|
|
return out.ID, nil
|
|
}
|
|
|
|
// deleteSession disposes a session on the opencode server (DELETE /session/:id).
|
|
// A 404 (session already gone) is treated as success so /clear stays idempotent.
|
|
func (c *opencodeHTTPClient) deleteSession(ctx context.Context, sessionID string) error {
|
|
if strings.TrimSpace(sessionID) == "" {
|
|
return nil
|
|
}
|
|
if err := c.doJSON(ctx, http.MethodDelete, "/session/"+sessionID, nil, nil); err != nil {
|
|
if errors.Is(err, errOpencodeSessionMissing) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *opencodeHTTPClient) sendMessageWithAttachments(ctx context.Context, sessionID, text, model string, attachments []connectMediaAttachment) (string, error) {
|
|
parts := []map[string]any{{"type": "text", "text": text}}
|
|
for _, attachment := range attachments {
|
|
path := strings.TrimSpace(attachment.LocalPath)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
fileURL := (&url.URL{Scheme: "file", Path: path}).String()
|
|
name := strings.TrimSpace(attachment.FileName)
|
|
if name == "" {
|
|
name = filepath.Base(path)
|
|
}
|
|
parts = append(parts, map[string]any{
|
|
"type": "file",
|
|
"url": fileURL,
|
|
"filename": name,
|
|
"mime": connectAttachmentMIME(path),
|
|
})
|
|
}
|
|
body := map[string]any{
|
|
"parts": parts,
|
|
}
|
|
if m := opencodeModelRef(model); m != nil {
|
|
body["model"] = m
|
|
}
|
|
var out struct {
|
|
Parts []opencodePart `json:"parts"`
|
|
}
|
|
if err := c.doJSON(ctx, http.MethodPost, "/session/"+sessionID+"/message", body, &out); err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(opencodePartsText(out.Parts)), nil
|
|
}
|
|
|
|
func (c *opencodeHTTPClient) doJSON(ctx context.Context, method, path string, in any, out any) error {
|
|
if strings.TrimSpace(c.baseURL) == "" {
|
|
return fmt.Errorf("opencode server URL is empty")
|
|
}
|
|
var body io.Reader
|
|
if in != nil {
|
|
data, err := json.Marshal(in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body = bytes.NewReader(data)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.baseURL, "/")+path, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if in != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
if c.password != "" {
|
|
req.SetBasicAuth(c.username, c.password)
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNotFound && strings.HasPrefix(path, "/session/") {
|
|
return errOpencodeSessionMissing
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
msg := strings.TrimSpace(string(raw))
|
|
if msg == "" {
|
|
msg = resp.Status
|
|
}
|
|
return fmt.Errorf("opencode server %s %s 失败:%s", method, path, truncateRunes(msg, 300))
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type opencodePart struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
Delta string `json:"delta"`
|
|
Parts []opencodePart `json:"parts"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
func opencodePartsText(parts []opencodePart) string {
|
|
var b strings.Builder
|
|
var walk func([]opencodePart)
|
|
walk = func(items []opencodePart) {
|
|
for _, p := range items {
|
|
if p.Type == "text" {
|
|
if p.Text != "" {
|
|
b.WriteString(p.Text)
|
|
}
|
|
if p.Delta != "" {
|
|
b.WriteString(p.Delta)
|
|
}
|
|
if len(p.Data) > 0 {
|
|
var nested struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if json.Unmarshal(p.Data, &nested) == nil && nested.Text != "" {
|
|
b.WriteString(nested.Text)
|
|
}
|
|
}
|
|
}
|
|
if len(p.Parts) > 0 {
|
|
walk(p.Parts)
|
|
}
|
|
}
|
|
}
|
|
walk(parts)
|
|
return b.String()
|
|
}
|
|
|
|
func opencodeModelRef(model string) map[string]string {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
return nil
|
|
}
|
|
provider, modelID, ok := strings.Cut(model, "/")
|
|
if !ok || strings.TrimSpace(provider) == "" || strings.TrimSpace(modelID) == "" {
|
|
return map[string]string{"modelID": model}
|
|
}
|
|
return map[string]string{"providerID": provider, "modelID": modelID}
|
|
}
|
|
|
|
func freeLocalPort() (int, error) {
|
|
ln, err := freeLocalPortListen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer ln.Close()
|
|
return ln.Addr().(*net.TCPAddr).Port, nil
|
|
}
|
|
|
|
func randomHex(bytesLen int) string {
|
|
buf := make([]byte, bytesLen)
|
|
if _, err := opencodeRandRead(buf); err != nil {
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
return hex.EncodeToString(buf)
|
|
}
|
|
|
|
// opencodeSessionStorePath returns the on-disk location for a robot's opencode
|
|
// conversation→session map, scoped by clientId so multiple bots on one machine
|
|
// stay isolated: <config dir>/connect/<clientId>/opencode-sessions.json. An
|
|
// empty clientId means "do not persist" (in-memory only).
|
|
func opencodeSessionStorePath(clientID string) string {
|
|
if clientID == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(config.DefaultConfigDir(), "connect", sanitizeLockID(clientID), "opencode-sessions.json")
|
|
}
|
|
|
|
// opencodeSessions maps a DingTalk conversation to the opencode session id
|
|
// returned by the server API. The map is the authoritative store (guarded by mu)
|
|
// and is persisted to disk so context can survive a connector restart when
|
|
// --agent-memory is enabled.
|
|
type opencodeSessions struct {
|
|
mu sync.Mutex
|
|
m map[string]string
|
|
path string
|
|
}
|
|
|
|
func newOpencodeSessions(path string) *opencodeSessions {
|
|
return &opencodeSessions{m: loadConvSessionMap(path), path: path}
|
|
}
|
|
|
|
func opencodeConvKey(convID string) string {
|
|
key := strings.TrimSpace(convID)
|
|
if key == "" {
|
|
key = "_default"
|
|
}
|
|
return key
|
|
}
|
|
|
|
// id returns the opencode session bound to a conversation, or "" if none.
|
|
func (s *opencodeSessions) id(convID string) string {
|
|
key := opencodeConvKey(convID)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.m[key]
|
|
}
|
|
|
|
// set binds a conversation to an opencode session and persists the snapshot.
|
|
// Best-effort: a failed save only logs a warning and never blocks message
|
|
// handling.
|
|
func (s *opencodeSessions) set(convID, sessionID string) {
|
|
key := opencodeConvKey(convID)
|
|
s.mu.Lock()
|
|
if sessionID == "" {
|
|
delete(s.m, key)
|
|
} else {
|
|
s.m[key] = sessionID
|
|
}
|
|
snapshot := make(map[string]string, len(s.m))
|
|
for k, v := range s.m {
|
|
snapshot[k] = v
|
|
}
|
|
path := s.path
|
|
s.mu.Unlock()
|
|
saveConvSessionMap(path, snapshot)
|
|
}
|
|
|
|
// reset forgets a conversation's session so the next message starts a fresh one.
|
|
// The removal is persisted so a restart does not resurrect the dropped session.
|
|
func (s *opencodeSessions) reset(convID string) {
|
|
s.set(convID, "")
|
|
}
|