sketch: add session history tools using markdown table API
Add session history tools to allow the LLM to access previous sketch sessions:
- New list_recent_sketch_sessions and read_sketch_session tools
- Integration with skaband client for session data retrieval
- Session history tools automatically added when skaband client available
- Updated agent configuration to include skaband client
- Client handles plain text markdown table response from API
- Display server-generated markdown table directly to LLM
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s693acdfdaaa392c8k
diff --git a/claudetool/session_history.go b/claudetool/session_history.go
new file mode 100644
index 0000000..5351538
--- /dev/null
+++ b/claudetool/session_history.go
@@ -0,0 +1,105 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "sketch.dev/llm"
+ "sketch.dev/skabandclient"
+)
+
+// CreateSessionHistoryTools creates the session history tools when skaband is available
+func CreateSessionHistoryTools(skabandClient *skabandclient.SkabandClient, sessionID, currentRepo string) []*llm.Tool {
+ if skabandClient == nil {
+ return nil
+ }
+
+ return []*llm.Tool{
+ listRecentSketchSessionsTool(skabandClient, sessionID, currentRepo),
+ readSketchSessionTool(skabandClient, sessionID),
+ }
+}
+
+func listRecentSketchSessionsTool(client *skabandclient.SkabandClient, sessionID, currentRepo string) *llm.Tool {
+ return &llm.Tool{
+ Name: "list_recent_sketch_sessions",
+ Description: fmt.Sprintf(`Lists recent Sketch sessions%s. Use this tool when the user refers to previous sketch sessions, asks about recent work, or wants to see their session history. This helps you understand what work has been done previously and can provide context for continuing or reviewing past sessions.`, func() string {
+ if currentRepo != "" {
+ return " for the current repository (" + currentRepo + ")"
+ }
+ return ""
+ }()),
+ InputSchema: json.RawMessage(`{
+ "type": "object",
+ "properties": {},
+ "required": []
+ }`),
+ Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
+ // Use 60 second timeout for skaband requests
+ ctxWithTimeout, cancel := context.WithTimeout(ctx, 60*time.Second)
+ defer cancel()
+
+ markdownTable, err := client.ListRecentSketchSessionsMarkdown(ctxWithTimeout, currentRepo, sessionID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list recent sessions: %w", err)
+ }
+
+ // Check if no sessions found (markdown will contain "No sessions found")
+ if strings.Contains(markdownTable, "No sessions found") {
+ return llm.TextContent("No recent sketch sessions found."), nil
+ }
+
+ // Use the markdown table from the server
+ var result strings.Builder
+ result.WriteString("Recent sketch sessions:\n\n")
+ result.WriteString(markdownTable)
+ result.WriteString("\n\nUse the `read_sketch_session` tool with a session ID to see the full conversation history.")
+
+ return llm.TextContent(result.String()), nil
+ },
+ }
+}
+
+func readSketchSessionTool(client *skabandclient.SkabandClient, sessionID string) *llm.Tool {
+ return &llm.Tool{
+ Name: "read_sketch_session",
+ Description: `Reads the full conversation history of a specific Sketch session. Use this tool when the user mentions a specific sketch session ID, wants to review what was done in a previous session, or needs to understand the context from a past conversation to continue work.`,
+ InputSchema: json.RawMessage(`{
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string",
+ "description": "The ID of the sketch session to read"
+ }
+ },
+ "required": ["session_id"]
+ }`),
+ Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
+ // Use 60 second timeout for skaband requests
+ ctxWithTimeout, cancel := context.WithTimeout(ctx, 60*time.Second)
+ defer cancel()
+
+ var params struct {
+ SessionID string `json:"session_id"`
+ }
+ if err := json.Unmarshal(input, ¶ms); err != nil {
+ return nil, fmt.Errorf("failed to parse input: %w", err)
+ }
+
+ if params.SessionID == "" {
+ return nil, fmt.Errorf("session_id is required")
+ }
+
+ formattedResponse, err := client.ReadSketchSession(ctxWithTimeout, params.SessionID, sessionID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read session: %w", err)
+ }
+
+ // Server now returns formatted text directly
+ return llm.TextContent(*formattedResponse), nil
+ },
+ }
+}
diff --git a/cmd/sketch/main.go b/cmd/sketch/main.go
index 6e3cfbe..2ec9699 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -516,6 +516,11 @@
Commit: flags.commit,
BranchPrefix: flags.branchPrefix,
}
+
+ // Create SkabandClient if skaband address is provided
+ if flags.skabandAddr != "" && pubKey != "" {
+ agentConfig.SkabandClient = skabandclient.NewSkabandClient(flags.skabandAddr, pubKey)
+ }
agent := loop.NewAgent(agentConfig)
// Create the server
@@ -598,7 +603,9 @@
}
}
}
- go skabandclient.DialAndServeLoop(ctx, flags.skabandAddr, flags.sessionID, pubKey, srv, connectFn)
+ if agentConfig.SkabandClient != nil {
+ go agentConfig.SkabandClient.DialAndServeLoop(ctx, flags.sessionID, srv, connectFn)
+ }
}
// Handle one-shot mode or mode without terminal UI
diff --git a/loop/agent.go b/loop/agent.go
index 0d32420..1d21a48 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -28,6 +28,7 @@
"sketch.dev/llm"
"sketch.dev/llm/ant"
"sketch.dev/llm/conversation"
+ "sketch.dev/skabandclient"
)
const (
@@ -989,6 +990,8 @@
Commit string
// Prefix for git branches created by sketch
BranchPrefix string
+ // Skaband client for session history (optional)
+ SkabandClient *skabandclient.SkabandClient
}
// NewAgent creates a new Agent.
@@ -1211,6 +1214,13 @@
}
convo.Tools = append(convo.Tools, browserTools...)
+
+ // Add session history tools if skaband client is available
+ if a.config.SkabandClient != nil {
+ sessionHistoryTools := claudetool.CreateSessionHistoryTools(a.config.SkabandClient, a.config.SessionID, a.gitOrigin)
+ convo.Tools = append(convo.Tools, sessionHistoryTools...)
+ }
+
convo.Listener = a
return convo
}
diff --git a/skabandclient/skabandclient.go b/skabandclient/skabandclient.go
index d13fb77..5abff35 100644
--- a/skabandclient/skabandclient.go
+++ b/skabandclient/skabandclient.go
@@ -29,56 +29,21 @@
"golang.org/x/net/http2"
)
-// DialAndServeLoop is a redial loop around DialAndServe.
-func DialAndServeLoop(ctx context.Context, skabandAddr, sessionID, clientPubKey string, srv http.Handler, connectFn func(connected bool)) {
- if _, err := os.Stat("/.dockerenv"); err == nil { // inDocker
- if addr, err := LocalhostToDockerInternal(skabandAddr); err == nil {
- skabandAddr = addr
- }
- }
+// SketchSession represents a sketch session with metadata
+type SketchSession struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ FirstMessage string `json:"first_message"`
+ LastMessage string `json:"last_message"`
+ FirstMessageDate time.Time `json:"first_message_date"`
+ LastMessageDate time.Time `json:"last_message_date"`
+}
- var skabandConnected atomic.Bool
- skabandHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == "/skabandinit" {
- b, err := io.ReadAll(r.Body)
- if err != nil {
- fmt.Printf("skabandinit failed: %v\n", err)
- return
- }
- m := map[string]string{}
- if err := json.Unmarshal(b, &m); err != nil {
- fmt.Printf("skabandinit failed: %v\n", err)
- return
- }
- skabandConnected.Store(true)
- if connectFn != nil {
- connectFn(true)
- }
- return
- }
- srv.ServeHTTP(w, r)
- })
-
- var lastErrLog time.Time
- for {
- if err := DialAndServe(ctx, skabandAddr, sessionID, clientPubKey, skabandHandler); err != nil {
- // NOTE: *just* backoff the logging. Backing off dialing
- // is bad UX. Doing so saves negligible CPU and doing so
- // without hurting UX requires interrupting the backoff with
- // wake-from-sleep and network-up events from the OS,
- // which are a pain to plumb.
- if time.Since(lastErrLog) > 1*time.Minute {
- slog.DebugContext(ctx, "skaband connection failed", "err", err)
- lastErrLog = time.Now()
- }
- }
- if skabandConnected.CompareAndSwap(true, false) {
- if connectFn != nil {
- connectFn(false)
- }
- }
- time.Sleep(200 * time.Millisecond)
- }
+// SkabandClient provides HTTP client functionality for skaband server
+type SkabandClient struct {
+ addr string
+ publicKey string
+ client *http.Client
}
func DialAndServe(ctx context.Context, hostURL, sessionID, clientPubKey string, h http.Handler) (err error) {
@@ -302,3 +267,149 @@
}
return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16]
}
+
+// NewSkabandClient creates a new skaband client
+func NewSkabandClient(addr, publicKey string) *SkabandClient {
+ // Apply localhost-to-docker-internal transformation if needed
+ if _, err := os.Stat("/.dockerenv"); err == nil { // inDocker
+ if newAddr, err := LocalhostToDockerInternal(addr); err == nil {
+ addr = newAddr
+ }
+ }
+
+ return &SkabandClient{
+ addr: addr,
+ publicKey: publicKey,
+ client: &http.Client{Timeout: 30 * time.Second},
+ }
+}
+
+// ListRecentSketchSessionsMarkdown returns recent sessions as a markdown table
+func (c *SkabandClient) ListRecentSketchSessionsMarkdown(ctx context.Context, currentRepo, sessionID string) (string, error) {
+ if c == nil {
+ return "", fmt.Errorf("SkabandClient is nil")
+ }
+
+ // Build URL with query parameters
+ baseURL := c.addr + "/api/sessions/recent"
+ if currentRepo != "" {
+ baseURL += "?repo=" + url.QueryEscape(currentRepo)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "GET", baseURL, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Add headers
+ req.Header.Set("Public-Key", c.publicKey)
+ req.Header.Set("Session-ID", sessionID)
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("failed to make request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w", err)
+ }
+
+ return string(body), nil
+}
+
+// ReadSketchSession reads the full details of a specific session and returns formatted text
+func (c *SkabandClient) ReadSketchSession(ctx context.Context, targetSessionID, originSessionID string) (*string, error) {
+ if c == nil {
+ return nil, fmt.Errorf("SkabandClient is nil")
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "GET", c.addr+"/api/sessions/"+targetSessionID, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Add headers
+ req.Header.Set("Public-Key", c.publicKey)
+ req.Header.Set("Session-ID", originSessionID)
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to make request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ response := string(body)
+ return &response, nil
+}
+
+// DialAndServeLoop is a redial loop around DialAndServe.
+func (c *SkabandClient) DialAndServeLoop(ctx context.Context, sessionID string, srv http.Handler, connectFn func(connected bool)) {
+ skabandAddr := c.addr
+ clientPubKey := c.publicKey
+
+ if _, err := os.Stat("/.dockerenv"); err == nil { // inDocker
+ if addr, err := LocalhostToDockerInternal(skabandAddr); err == nil {
+ skabandAddr = addr
+ }
+ }
+
+ var skabandConnected atomic.Bool
+ skabandHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/skabandinit" {
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ fmt.Printf("skabandinit failed: %v\n", err)
+ return
+ }
+ m := map[string]string{}
+ if err := json.Unmarshal(b, &m); err != nil {
+ fmt.Printf("skabandinit failed: %v\n", err)
+ return
+ }
+ skabandConnected.Store(true)
+ if connectFn != nil {
+ connectFn(true)
+ }
+ return
+ }
+ srv.ServeHTTP(w, r)
+ })
+
+ var lastErrLog time.Time
+ for {
+ if err := DialAndServe(ctx, skabandAddr, sessionID, clientPubKey, skabandHandler); err != nil {
+ // NOTE: *just* backoff the logging. Backing off dialing
+ // is bad UX. Doing so saves negligible CPU and doing so
+ // without hurting UX requires interrupting the backoff with
+ // wake-from-sleep and network-up events from the OS,
+ // which are a pain to plumb.
+ if time.Since(lastErrLog) > 1*time.Minute {
+ slog.DebugContext(ctx, "skaband connection failed", "err", err)
+ lastErrLog = time.Now()
+ }
+ }
+ if skabandConnected.CompareAndSwap(true, false) {
+ if connectFn != nil {
+ connectFn(false)
+ }
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+}
diff --git a/termui/termui.go b/termui/termui.go
index bbe04b4..512fa65 100644
--- a/termui/termui.go
+++ b/termui/termui.go
@@ -81,6 +81,10 @@
๐ Console logs
{{else if eq .msg.ToolName "browser_clear_console_logs" -}}
๐งน Clear console logs
+{{else if eq .msg.ToolName "list_recent_sketch_sessions" -}}
+ ๐ List recent sketch sessions
+{{else if eq .msg.ToolName "read_sketch_session" -}}
+ ๐ Read session {{.input.session_id}}
{{else -}}
๐ ๏ธ {{ .msg.ToolName}}: {{.msg.ToolInput -}}
{{end -}}