Changeset 0.11.0 (#62)

This commit is contained in:
2026-02-25 13:29:15 +00:00
parent d2ec55b16d
commit c9d8e9457e
56 changed files with 5664 additions and 91 deletions

View File

@@ -2,7 +2,7 @@
# Chat Switchboard - Backend Dockerfile
# ==========================================
# Standalone Go API server for cluster deployment.
# Build context is ./server (not repo root).
# Build context is the repo root (not ./server).
# ==========================================
# Build stage
@@ -13,11 +13,11 @@ ARG APP_VERSION=dev
WORKDIR /app
# Copy module files and download deps
COPY go.mod go.sum* ./
COPY server/go.mod server/go.sum* ./
RUN go mod download
# Copy source and tidy (resolves any go.sum gaps)
COPY . .
COPY server/ .
RUN go mod tidy
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=${APP_VERSION}" -o /bin/switchboard .
@@ -31,6 +31,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY --from=builder /bin/switchboard /usr/local/bin/switchboard
COPY --from=builder /app/database/migrations /app/database/migrations
# Builtin extensions (seeded into DB on startup)
COPY extensions/builtin/ /app/extensions/builtin/
WORKDIR /app
EXPOSE 8080

View File

@@ -0,0 +1,41 @@
-- Migration 006: Extension system tables
--
-- Extensions are installable plugins that add renderers, tools, surfaces,
-- and event handlers. Three tiers: browser (JS), starlark (sandbox),
-- sidecar (container). Browser tier ships first (v0.11.0).
--
-- See EXTENSIONS.md for full spec.
-- ── Extension registry ──────────────────────────
CREATE TABLE IF NOT EXISTS extensions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ext_id VARCHAR(100) NOT NULL UNIQUE, -- manifest id (e.g. "collapsible-code")
name VARCHAR(200) NOT NULL, -- human label
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
tier VARCHAR(20) NOT NULL DEFAULT 'browser', -- browser | starlark | sidecar
description TEXT NOT NULL DEFAULT '',
author VARCHAR(200) NOT NULL DEFAULT '',
manifest JSONB NOT NULL DEFAULT '{}', -- full manifest JSON
is_system BOOLEAN NOT NULL DEFAULT false, -- admin-pushed, users can't disable
is_enabled BOOLEAN NOT NULL DEFAULT true, -- global enable/disable
scope VARCHAR(20) NOT NULL DEFAULT 'global', -- global | team | personal
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
installed_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- For bundled extensions that ship with core
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled) WHERE is_enabled = true;
-- ── Per-user extension settings ─────────────────
CREATE TABLE IF NOT EXISTS extension_user_settings (
extension_id UUID NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
settings JSONB NOT NULL DEFAULT '{}', -- user's config values
is_enabled BOOLEAN NOT NULL DEFAULT true, -- per-user toggle
PRIMARY KEY (extension_id, user_id)
);

View File

@@ -3,6 +3,7 @@ package events
import (
"strings"
"sync"
"time"
)
// Bus is a labeled publish/subscribe event bus.
@@ -114,3 +115,28 @@ func match(label, pattern string) bool {
prefix := pattern[:len(pattern)-1] // "chat.message." from "chat.message.*"
return strings.HasPrefix(label, prefix)
}
// WaitFor subscribes to an exact label and blocks until an event arrives
// or the timeout elapses. Returns the event and true, or a zero Event and
// false on timeout. The subscription is automatically removed after.
//
// Used by the browser tool bridge: the server publishes tool.call.{id}
// to the client and then calls WaitFor("tool.result.{id}", 30s) to block
// until the browser returns the result.
func (b *Bus) WaitFor(label string, timeout time.Duration) (Event, bool) {
ch := make(chan Event, 1)
unsub := b.Subscribe(label, func(e Event) {
select {
case ch <- e:
default:
}
})
defer unsub()
select {
case e := <-ch:
return e, true
case <-time.After(timeout):
return Event{}, false
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"sync/atomic"
"testing"
"time"
)
func TestMatch(t *testing.T) {
@@ -112,6 +113,12 @@ func TestRouteFor(t *testing.T) {
{"unknown.event", DirLocal}, // default
{"ping", DirFromClient},
{"pong", DirToClient},
// Tool bridge routes
{"tool.call.abc123", DirToClient},
{"tool.result.abc123", DirFromClient},
// Extension lifecycle
{"extension.loaded", DirLocal},
{"extension.error", DirLocal},
}
for _, tt := range tests {
@@ -121,3 +128,84 @@ func TestRouteFor(t *testing.T) {
}
}
}
func TestWaitFor_Success(t *testing.T) {
bus := NewBus()
// Publish after a short delay
go func() {
time.Sleep(10 * time.Millisecond)
bus.Publish(Event{
Label: "tool.result.abc123",
Payload: json.RawMessage(`{"result":"hello"}`),
})
}()
event, ok := bus.WaitFor("tool.result.abc123", 1*time.Second)
if !ok {
t.Fatal("WaitFor timed out, expected success")
}
if event.Label != "tool.result.abc123" {
t.Errorf("expected label tool.result.abc123, got %s", event.Label)
}
if string(event.Payload) != `{"result":"hello"}` {
t.Errorf("unexpected payload: %s", string(event.Payload))
}
}
func TestWaitFor_Timeout(t *testing.T) {
bus := NewBus()
_, ok := bus.WaitFor("tool.result.never", 50*time.Millisecond)
if ok {
t.Error("WaitFor should have timed out")
}
}
func TestWaitFor_IgnoresOtherLabels(t *testing.T) {
bus := NewBus()
go func() {
time.Sleep(5 * time.Millisecond)
bus.Publish(Event{Label: "tool.result.other", Payload: json.RawMessage(`{}`)})
time.Sleep(5 * time.Millisecond)
bus.Publish(Event{Label: "tool.result.target", Payload: json.RawMessage(`{"ok":true}`)})
}()
event, ok := bus.WaitFor("tool.result.target", 1*time.Second)
if !ok {
t.Fatal("WaitFor timed out")
}
if event.Label != "tool.result.target" {
t.Errorf("got wrong label: %s", event.Label)
}
}
func TestWaitFor_CleansUpSubscription(t *testing.T) {
bus := NewBus()
// WaitFor with immediate timeout
bus.WaitFor("tool.result.cleanup", 1*time.Millisecond)
time.Sleep(5 * time.Millisecond)
// Verify no panic when publishing to the label after cleanup
bus.Publish(Event{Label: "tool.result.cleanup", Payload: json.RawMessage(`{}`)})
}
func TestToolCallRouteToClient(t *testing.T) {
if !ShouldSendToClient("tool.call.abc123") {
t.Error("tool.call.* should be sent to client")
}
if ShouldAcceptFromClient("tool.call.abc123") {
t.Error("tool.call.* should NOT be accepted from client")
}
}
func TestToolResultRouteFromClient(t *testing.T) {
if !ShouldAcceptFromClient("tool.result.abc123") {
t.Error("tool.result.* should be accepted from client")
}
if ShouldSendToClient("tool.result.abc123") {
t.Error("tool.result.* should NOT be sent to client")
}
}

View File

@@ -58,6 +58,14 @@ var routeTable = map[string]Direction{
"internal.": DirLocal,
"db.": DirLocal,
// Tool execution (browser tools bridge)
"tool.call.": DirToClient, // Server → specific client (browser tool invocation)
"tool.result.": DirFromClient, // Client → server (browser tool result)
// Extension lifecycle
"extension.loaded": DirLocal, // Client-only
"extension.error": DirLocal,
// Heartbeat
"ping": DirFromClient,
"pong": DirToClient,

View File

@@ -118,6 +118,42 @@ func (h *Hub) ConnsByUser(userID string) []*Conn {
return result
}
// IsConnected returns true if the user has at least one active WebSocket.
func (h *Hub) IsConnected(userID string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
for _, c := range h.conns {
if c.userID == userID {
return true
}
}
return false
}
// SendToUser sends an event directly to all WebSocket connections for a user,
// bypassing room filtering. Used for targeted tool.call delivery.
func (h *Hub) SendToUser(userID string, event Event) {
data, err := json.Marshal(event)
if err != nil {
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for _, c := range h.conns {
if c.userID == userID {
select {
case c.send <- data:
default:
}
}
}
}
// GetBus returns the underlying event bus.
func (h *Hub) GetBus() *Bus {
return h.bus
}
// removeConn cleans up a connection.
func (h *Hub) removeConn(conn *Conn) {
h.mu.Lock()

View File

@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
@@ -39,11 +40,12 @@ type completionRequest struct {
type CompletionHandler struct {
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub // WebSocket hub for browser tool bridge
}
// NewCompletionHandler creates a new handler.
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores) *CompletionHandler {
return &CompletionHandler{vault: vault, stores: stores}
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *CompletionHandler {
return &CompletionHandler{vault: vault, stores: stores, hub: hub}
}
// ── Chat Completion ─────────────────────────
@@ -171,8 +173,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// Attach tool definitions if model supports tool calling and tools are available
if caps.ToolCalling && tools.HasTools() {
provReq.Tools = h.buildToolDefs()
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools)
}
// Determine streaming
@@ -189,19 +192,56 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// buildToolDefs converts registered tools to provider-format tool definitions.
func (h *CompletionHandler) buildToolDefs() []providers.ToolDef {
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool) []providers.ToolDef {
allTools := tools.AllDefinitions()
defs := make([]providers.ToolDef, len(allTools))
for i, t := range allTools {
defs[i] = providers.ToolDef{
defs := make([]providers.ToolDef, 0, len(allTools))
for _, t := range allTools {
defs = append(defs, providers.ToolDef{
Type: "function",
Function: providers.FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
// Append browser-defined tool schemas from extensions
if includeBrowser {
exts, err := h.stores.Extensions.ListForUser(ctx, userID)
if err != nil {
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
return defs
}
for _, ext := range exts {
if ext.Tier != "browser" {
continue
}
var manifest struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
Tier string `json:"tier"`
} `json:"tools"`
}
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
continue
}
for _, t := range manifest.Tools {
defs = append(defs, providers.ToolDef{
Type: "function",
Function: providers.FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
}
}
return defs
}
@@ -216,7 +256,7 @@ func (h *CompletionHandler) streamCompletion(
req providers.CompletionRequest,
channelID, userID, model, configID, providerScope string,
) {
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID)
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, h.hub)
// Persist assistant response
if result.Content != "" {

View File

@@ -0,0 +1,346 @@
package handlers
import (
"database/sql"
"encoding/json"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ExtensionHandler serves extension management endpoints.
type ExtensionHandler struct {
stores store.Stores
}
func NewExtensionHandler(stores store.Stores) *ExtensionHandler {
return &ExtensionHandler{stores: stores}
}
// ── User endpoints ──────────────────────────────
// ListUserExtensions returns all enabled extensions for the current user,
// with user-specific settings and enabled state merged in.
// GET /api/v1/extensions
func (h *ExtensionHandler) ListUserExtensions(c *gin.Context) {
userID := c.GetString("user_id")
tier := c.Query("tier") // optional filter
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
if tier != "" {
filtered := make([]models.UserExtension, 0)
for _, e := range exts {
if e.Tier == tier {
filtered = append(filtered, e)
}
}
exts = filtered
}
c.JSON(200, gin.H{"data": exts})
}
// UpdateUserExtensionSettings saves per-user settings for an extension.
// POST /api/v1/extensions/:id/settings
func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
userID := c.GetString("user_id")
extID := c.Param("id")
// Verify extension exists
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), extID)
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
// System extensions can't be disabled by users
var body struct {
Settings json.RawMessage `json:"settings"`
IsEnabled *bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid request body"})
return
}
enabled := true
if body.IsEnabled != nil {
if ext.IsSystem && !*body.IsEnabled {
c.JSON(403, gin.H{"error": "system extensions cannot be disabled"})
return
}
enabled = *body.IsEnabled
}
settings := json.RawMessage("{}")
if body.Settings != nil {
settings = body.Settings
}
eus := &models.ExtensionUserSettings{
ExtensionID: extID,
UserID: userID,
Settings: settings,
IsEnabled: enabled,
}
if err := h.stores.Extensions.SetUserSettings(c.Request.Context(), eus); err != nil {
c.JSON(500, gin.H{"error": "failed to save settings"})
return
}
c.JSON(200, gin.H{"ok": true})
}
// ── Admin endpoints ─────────────────────────────
// AdminListExtensions returns all extensions (enabled and disabled).
// GET /api/v1/admin/extensions
func (h *ExtensionHandler) AdminListExtensions(c *gin.Context) {
exts, err := h.stores.Extensions.ListAll(c.Request.Context())
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
c.JSON(200, gin.H{"data": exts})
}
// AdminInstallExtension installs an extension from a manifest.
// POST /api/v1/admin/extensions
func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
userID := c.GetString("user_id")
var body struct {
ExtID string `json:"ext_id" binding:"required"`
Name string `json:"name" binding:"required"`
Version string `json:"version"`
Tier string `json:"tier"`
Description string `json:"description"`
Author string `json:"author"`
Manifest json.RawMessage `json:"manifest"`
IsSystem bool `json:"is_system"`
IsEnabled bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid request: " + err.Error()})
return
}
// Defaults
if body.Version == "" {
body.Version = "0.0.0"
}
if body.Tier == "" {
body.Tier = models.ExtTierBrowser
}
if body.Manifest == nil {
body.Manifest = json.RawMessage("{}")
}
// Check for duplicate ext_id
existing, err := h.stores.Extensions.GetByExtID(c.Request.Context(), body.ExtID)
if err != nil && err != sql.ErrNoRows {
c.JSON(500, gin.H{"error": "failed to check existing extension"})
return
}
if existing != nil {
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
return
}
ext := &models.Extension{
ExtID: body.ExtID,
Name: body.Name,
Version: body.Version,
Tier: body.Tier,
Description: body.Description,
Author: body.Author,
Manifest: body.Manifest,
IsSystem: body.IsSystem,
IsEnabled: body.IsEnabled,
Scope: models.ScopeGlobal,
InstalledBy: &userID,
}
if err := h.stores.Extensions.Create(c.Request.Context(), ext); err != nil {
c.JSON(500, gin.H{"error": "failed to install extension"})
return
}
c.JSON(201, gin.H{"data": ext})
}
// AdminUpdateExtension updates an extension's config.
// PUT /api/v1/admin/extensions/:id
func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
id := c.Param("id")
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
var body struct {
Name *string `json:"name"`
Description *string `json:"description"`
IsSystem *bool `json:"is_system"`
IsEnabled *bool `json:"is_enabled"`
Manifest *json.RawMessage `json:"manifest"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid request body"})
return
}
if body.Name != nil {
ext.Name = *body.Name
}
if body.Description != nil {
ext.Description = *body.Description
}
if body.IsSystem != nil {
ext.IsSystem = *body.IsSystem
}
if body.IsEnabled != nil {
ext.IsEnabled = *body.IsEnabled
}
if body.Manifest != nil {
ext.Manifest = *body.Manifest
}
if err := h.stores.Extensions.Update(c.Request.Context(), id, ext); err != nil {
c.JSON(500, gin.H{"error": "failed to update extension"})
return
}
c.JSON(200, gin.H{"data": ext})
}
// ServeExtensionAsset serves browser extension JS files.
// GET /api/v1/extensions/:id/assets/*path
//
// For the MVP, scripts are stored inline in manifest._script.
// The :id param is the ext_id (e.g. "mermaid-renderer"), not the UUID.
func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
extID := c.Param("id")
// path := c.Param("path") // reserved for future multi-file support
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
if err != nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if !ext.IsEnabled {
c.JSON(404, gin.H{"error": "extension not enabled"})
return
}
// Extract inline script from manifest
var manifest map[string]json.RawMessage
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
c.JSON(500, gin.H{"error": "invalid manifest"})
return
}
scriptRaw, ok := manifest["_script"]
if !ok {
c.JSON(404, gin.H{"error": "no script in manifest"})
return
}
// _script is a JSON string — unquote it
var script string
if err := json.Unmarshal(scriptRaw, &script); err != nil {
c.JSON(500, gin.H{"error": "invalid script in manifest"})
return
}
c.Header("Content-Type", "application/javascript; charset=utf-8")
c.Header("Cache-Control", "public, max-age=3600")
c.String(200, script)
}
// GetExtensionManifest returns the manifest for a specific extension.
// GET /api/v1/extensions/:id/manifest
func (h *ExtensionHandler) GetExtensionManifest(c *gin.Context) {
extID := c.Param("id")
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
if err != nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
c.JSON(200, gin.H{"data": ext.Manifest})
}
// ListBrowserToolSchemas returns tool schemas from all enabled browser extensions.
// Used by the completion handler to include browser tools in LLM requests.
// GET /api/v1/extensions/tools
func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
userID := c.GetString("user_id")
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
var toolSchemas []json.RawMessage
for _, ext := range exts {
if ext.Tier != "browser" {
continue
}
var manifest struct {
Tools []json.RawMessage `json:"tools"`
}
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
continue
}
toolSchemas = append(toolSchemas, manifest.Tools...)
}
if toolSchemas == nil {
toolSchemas = make([]json.RawMessage, 0)
}
c.JSON(200, gin.H{"data": toolSchemas})
}
// AdminUninstallExtension removes an extension.
// DELETE /api/v1/admin/extensions/:id
func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) {
id := c.Param("id")
_, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
if err := h.stores.Extensions.Delete(c.Request.Context(), id); err != nil {
c.JSON(500, gin.H{"error": "failed to uninstall extension"})
return
}
c.JSON(200, gin.H{"ok": true})
}

View File

@@ -134,7 +134,7 @@ func setupHarness(t *testing.T) *testHarness {
protected.POST("/channels", channels.CreateChannel)
// Completions
completions := NewCompletionHandler(nil, stores)
completions := NewCompletionHandler(nil, stores, nil)
protected.POST("/chat/completions", completions.Complete)
// Admin routes

View File

@@ -13,6 +13,7 @@ import (
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
@@ -59,11 +60,12 @@ type cursorRequest struct {
type MessageHandler struct {
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub
}
// NewMessageHandler creates a new message handler.
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores) *MessageHandler {
return &MessageHandler{vault: vault, stores: stores}
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *MessageHandler {
return &MessageHandler{vault: vault, stores: stores, hub: hub}
}
// ── List Messages (flat, all branches) ──────
@@ -358,7 +360,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// ── Resolve model + provider ──
comp := NewCompletionHandler(h.vault, h.stores)
comp := NewCompletionHandler(h.vault, h.stores, h.hub)
var presetSystemPrompt string
model := req.Model
@@ -449,13 +451,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
}
// Attach tool definitions (same as normal completion)
if caps.ToolCalling && tools.HasTools() {
provReq.Tools = comp.buildToolDefs()
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools)
}
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID)
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, h.hub)
// Persist as sibling (regen) with tool activity
if result.Content != "" {

View File

@@ -0,0 +1,178 @@
package handlers
import (
"context"
"encoding/json"
"log"
"os"
"path/filepath"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// builtinManifest is the on-disk manifest.json shape.
type builtinManifest struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Tier string `json:"tier"`
Author string `json:"author"`
Description string `json:"description"`
Permissions json.RawMessage `json:"permissions"`
Tools json.RawMessage `json:"tools"`
Surfaces json.RawMessage `json:"surfaces"`
Settings json.RawMessage `json:"settings"`
}
// SeedBuiltinExtensions scans a directory for builtin extension bundles
// (each subdirectory contains manifest.json + script.js) and upserts them
// into the extensions table as system extensions.
//
// Layout:
//
// extensions/builtin/
// mermaid-renderer/
// manifest.json
// script.js
//
// Idempotent: skips extensions whose version matches what's already installed,
// updates those with a newer version, creates new ones.
func SeedBuiltinExtensions(stores store.Stores, extensionsDir string) {
if stores.Extensions == nil {
return
}
entries, err := os.ReadDir(extensionsDir)
if err != nil {
if os.IsNotExist(err) {
log.Printf(" 📦 No builtin extensions directory at %s (skipping)", extensionsDir)
return
}
log.Printf("⚠ Failed to read extensions directory %s: %v", extensionsDir, err)
return
}
ctx := context.Background()
seeded, updated, skipped := 0, 0, 0
for _, entry := range entries {
if !entry.IsDir() {
continue
}
extDir := filepath.Join(extensionsDir, entry.Name())
manifestPath := filepath.Join(extDir, "manifest.json")
scriptPath := filepath.Join(extDir, "script.js")
// Read manifest
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
log.Printf("⚠ Skipping %s: no manifest.json: %v", entry.Name(), err)
continue
}
var manifest builtinManifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
log.Printf("⚠ Skipping %s: invalid manifest.json: %v", entry.Name(), err)
continue
}
if manifest.ID == "" {
log.Printf("⚠ Skipping %s: manifest missing 'id'", entry.Name())
continue
}
// Read script (optional — some extensions might be manifest-only)
var script string
if scriptData, err := os.ReadFile(scriptPath); err == nil {
script = string(scriptData)
}
// Build the full manifest JSONB with _script inlined
fullManifest := buildFullManifest(manifestData, script)
// Check if already installed
existing, err := stores.Extensions.GetByExtID(ctx, manifest.ID)
if err == nil {
// Already exists — check version
if existing.Version == manifest.Version {
skipped++
continue
}
// Version changed — update
existing.Name = manifest.Name
existing.Version = manifest.Version
existing.Description = manifest.Description
existing.Author = manifest.Author
existing.Manifest = fullManifest
existing.IsSystem = true
existing.IsEnabled = true
if err := stores.Extensions.Update(ctx, existing.ID, existing); err != nil {
log.Printf("⚠ Failed to update builtin extension %s: %v", manifest.ID, err)
continue
}
updated++
continue
}
// New extension — create
tier := manifest.Tier
if tier == "" {
tier = models.ExtTierBrowser
}
ext := &models.Extension{
ExtID: manifest.ID,
Name: manifest.Name,
Version: manifest.Version,
Tier: tier,
Description: manifest.Description,
Author: manifest.Author,
Manifest: fullManifest,
IsSystem: true,
IsEnabled: true,
Scope: "global",
}
if err := stores.Extensions.Create(ctx, ext); err != nil {
log.Printf("⚠ Failed to seed builtin extension %s: %v", manifest.ID, err)
continue
}
seeded++
}
if seeded+updated > 0 {
log.Printf(" 📦 Builtin extensions: %d seeded, %d updated, %d unchanged", seeded, updated, skipped)
} else if skipped > 0 {
log.Printf(" 📦 Builtin extensions: %d up to date", skipped)
}
}
// buildFullManifest merges the raw manifest JSON with an inline _script field.
func buildFullManifest(rawManifest []byte, script string) json.RawMessage {
if script == "" {
return json.RawMessage(rawManifest)
}
// Parse manifest as generic map, add _script, re-serialize
var m map[string]json.RawMessage
if err := json.Unmarshal(rawManifest, &m); err != nil {
// Fallback: just return raw manifest
return json.RawMessage(rawManifest)
}
scriptJSON, err := json.Marshal(script)
if err != nil {
return json.RawMessage(rawManifest)
}
m["_script"] = json.RawMessage(scriptJSON)
result, err := json.Marshal(m)
if err != nil {
return json.RawMessage(rawManifest)
}
return json.RawMessage(result)
}

View File

@@ -1,13 +1,17 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
@@ -40,6 +44,7 @@ func streamWithToolLoop(
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, userID, channelID string,
hub *events.Hub,
) streamResult {
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
@@ -171,8 +176,25 @@ func streamWithToolLoop(
Arguments: tc.Function.Arguments,
}
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
toolResult := tools.ExecuteCall(c.Request.Context(), execCtx, call)
var toolResult tools.ToolResult
// Check if tool is registered locally (server-side)
if tools.Get(call.Name) != nil {
log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID)
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call)
} else if hub != nil && hub.IsConnected(userID) {
// Route to browser via WebSocket bridge
log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID)
toolResult = executeBrowserTool(hub, userID, call)
} else {
log.Printf("⚠️ Unknown tool: %s (call %s)", call.Name, call.ID)
toolResult = tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"unknown tool or browser not connected"}`,
IsError: true,
}
}
// Notify client about tool result
resultJSON, _ := json.Marshal(map[string]interface{}{
@@ -214,3 +236,68 @@ func streamWithToolLoop(
return result
}
const browserToolTimeout = 30 * time.Second
// executeBrowserTool sends a tool call to the user's browser via WebSocket
// and blocks until a result is returned or the timeout elapses.
func executeBrowserTool(hub *events.Hub, userID string, call tools.ToolCall) tools.ToolResult {
b := make([]byte, 16)
rand.Read(b)
callID := hex.EncodeToString(b)
// Publish tool.call event to the user's browser
payload, _ := json.Marshal(map[string]interface{}{
"call_id": callID,
"tool": call.Name,
"arguments": json.RawMessage(call.Arguments),
})
hub.SendToUser(userID, events.Event{
Label: "tool.call." + callID,
Payload: payload,
Ts: time.Now().UnixMilli(),
})
// Block until browser sends tool.result.{callID} or timeout
event, ok := hub.GetBus().WaitFor("tool.result."+callID, browserToolTimeout)
if !ok {
log.Printf("⚠️ Browser tool %s timed out after %v (call %s)", call.Name, browserToolTimeout, callID)
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"browser tool timed out (30s) — browser may be disconnected"}`,
IsError: true,
}
}
// Parse result from the browser event payload
var result struct {
CallID string `json:"call_id"`
Result string `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(event.Payload, &result); err != nil {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"invalid result from browser tool"}`,
IsError: true,
}
}
if result.Error != "" {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: result.Error,
IsError: true,
}
}
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: result.Result,
}
}

View File

@@ -67,6 +67,9 @@ func main() {
// Seed additional users from env (dev/test only, skipped in production)
handlers.SeedUsers(cfg, stores)
// Seed builtin extensions from disk (idempotent, version-aware)
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
}
defer database.Close()
@@ -126,6 +129,12 @@ func main() {
authGroup.POST("/logout", auth.Logout)
}
// ── Public extension assets ────────────────
// Script tags can't send Authorization headers, so asset serving must be public.
// Extension JS is the same for all users — no user-specific data.
extH := handlers.NewExtensionHandler(stores)
api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset)
// ── Protected routes ────────────────────
protected := api.Group("")
protected.Use(middleware.Auth(cfg))
@@ -139,7 +148,7 @@ func main() {
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages
msgs := handlers.NewMessageHandler(keyResolver, stores)
msgs := handlers.NewMessageHandler(keyResolver, stores, hub)
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
@@ -151,7 +160,7 @@ func main() {
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
// Chat Completions
comp := handlers.NewCompletionHandler(keyResolver, stores)
comp := handlers.NewCompletionHandler(keyResolver, stores, hub)
protected.POST("/chat/completions", comp.Complete)
// Summarize & Continue
@@ -258,6 +267,12 @@ func main() {
// Public global settings (non-admin users can read safe subset)
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
protected.GET("/settings/public", adm.PublicSettings)
// Extensions (user-facing)
protected.GET("/extensions", extH.ListUserExtensions)
protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings)
protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest)
protected.GET("/extensions/tools", extH.ListBrowserToolSchemas)
}
// ── Admin routes ────────────────────────
@@ -336,6 +351,13 @@ func main() {
admin.GET("/pricing", usageH.ListPricing)
admin.PUT("/pricing", usageH.UpsertPricing)
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
// Extensions (admin)
extAdm := handlers.NewExtensionHandler(stores)
admin.GET("/extensions", extAdm.AdminListExtensions)
admin.POST("/extensions", extAdm.AdminInstallExtension)
admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension)
admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension)
}
}

View File

@@ -536,6 +536,49 @@ func (m *JSONMap) Scan(src interface{}) error {
return nil
}
// ── Extensions ──────────────────────────────
// Extension tier constants
const (
ExtTierBrowser = "browser"
ExtTierStarlark = "starlark"
ExtTierSidecar = "sidecar"
)
// Extension represents an installed extension in the registry.
type Extension struct {
ID string `json:"id" db:"id"`
ExtID string `json:"ext_id" db:"ext_id"` // manifest id
Name string `json:"name" db:"name"`
Version string `json:"version" db:"version"`
Tier string `json:"tier" db:"tier"`
Description string `json:"description" db:"description"`
Author string `json:"author" db:"author"`
Manifest json.RawMessage `json:"manifest" db:"manifest"`
IsSystem bool `json:"is_system" db:"is_system"`
IsEnabled bool `json:"is_enabled" db:"is_enabled"`
Scope string `json:"scope" db:"scope"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
InstalledBy *string `json:"installed_by,omitempty" db:"installed_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ExtensionUserSettings stores per-user overrides for an extension.
type ExtensionUserSettings struct {
ExtensionID string `json:"extension_id" db:"extension_id"`
UserID string `json:"user_id" db:"user_id"`
Settings json.RawMessage `json:"settings" db:"settings"`
IsEnabled bool `json:"is_enabled" db:"is_enabled"`
}
// UserExtension combines extension info with user-specific settings for API responses.
type UserExtension struct {
Extension
UserEnabled *bool `json:"user_enabled,omitempty"`
UserSettings *json.RawMessage `json:"user_settings,omitempty"`
}
func NullString(s *string) sql.NullString {
if s == nil {
return sql.NullString{}

View File

@@ -32,6 +32,7 @@ type Stores struct {
GlobalConfig GlobalConfigStore
Usage UsageStore
Pricing PricingStore
Extensions ExtensionStore
}
// =========================================
@@ -316,6 +317,29 @@ type PricingStore interface {
Delete(ctx context.Context, providerConfigID, modelID string) error
}
// =========================================
// EXTENSION STORE
// =========================================
type ExtensionStore interface {
// Admin CRUD
Create(ctx context.Context, ext *models.Extension) error
GetByID(ctx context.Context, id string) (*models.Extension, error)
GetByExtID(ctx context.Context, extID string) (*models.Extension, error)
Update(ctx context.Context, id string, ext *models.Extension) error
Delete(ctx context.Context, id string) error
// Listing
ListAll(ctx context.Context) ([]models.Extension, error)
ListEnabled(ctx context.Context) ([]models.Extension, error)
ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error)
// User settings
GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error)
SetUserSettings(ctx context.Context, s *models.ExtensionUserSettings) error
DeleteUserSettings(ctx context.Context, extID, userID string) error
}
// =========================================
// SHARED TYPES
// =========================================

View File

@@ -0,0 +1,194 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type ExtensionStore struct{}
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
return DB.QueryRowContext(ctx, `
INSERT INTO extensions (ext_id, name, version, tier, description, author,
manifest, is_system, is_enabled, scope, team_id, installed_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING id, created_at, updated_at`,
ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
).Scan(&ext.ID, &ext.CreatedAt, &ext.UpdatedAt)
}
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = $1`, id)
}
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = $1`, extID)
}
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
_, err := DB.ExecContext(ctx, `
UPDATE extensions SET
name = $2, version = $3, description = $4, author = $5,
manifest = $6, is_system = $7, is_enabled = $8,
updated_at = now()
WHERE id = $1`,
id, ext.Name, ext.Version, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled,
)
return err
}
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = $1`, id)
return err
}
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
}
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = true ORDER BY name`)
}
// ListForUser returns all enabled extensions with user-specific overrides merged in.
// System extensions are always included (users can't disable them).
// Non-system extensions respect per-user enabled toggle.
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
rows, err := DB.QueryContext(ctx, `
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
e.created_at, e.updated_at,
eus.is_enabled AS user_enabled,
eus.settings AS user_settings
FROM extensions e
LEFT JOIN extension_user_settings eus
ON eus.extension_id = e.id AND eus.user_id = $1
WHERE e.is_enabled = true
AND (e.is_system = true OR COALESCE(eus.is_enabled, true) = true)
ORDER BY e.name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.UserExtension
for rows.Next() {
var ue models.UserExtension
var teamID, installedBy sql.NullString
var userEnabled sql.NullBool
var userSettings []byte
if err := rows.Scan(
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
&ue.CreatedAt, &ue.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
ue.TeamID = NullableStringPtr(teamID)
ue.InstalledBy = NullableStringPtr(installedBy)
if userEnabled.Valid {
ue.UserEnabled = &userEnabled.Bool
}
if userSettings != nil {
raw := json.RawMessage(userSettings)
ue.UserSettings = &raw
}
result = append(result, ue)
}
if result == nil {
result = make([]models.UserExtension, 0)
}
return result, rows.Err()
}
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
var eus models.ExtensionUserSettings
err := DB.QueryRowContext(ctx, `
SELECT extension_id, user_id, settings, is_enabled
FROM extension_user_settings
WHERE extension_id = $1 AND user_id = $2`,
extID, userID,
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
if err != nil {
return nil, err
}
return &eus, nil
}
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
VALUES ($1, $2, $3, $4)
ON CONFLICT (extension_id, user_id)
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
)
return err
}
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM extension_user_settings WHERE extension_id = $1 AND user_id = $2`,
extID, userID,
)
return err
}
// ── Internal helpers ────────────────────────────
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
var ext models.Extension
var teamID, installedBy sql.NullString
err := DB.QueryRowContext(ctx, query, args...).Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
&ext.CreatedAt, &ext.UpdatedAt,
)
if err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
return &ext, nil
}
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Extension
for rows.Next() {
var ext models.Extension
var teamID, installedBy sql.NullString
if err := rows.Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
&ext.CreatedAt, &ext.UpdatedAt,
); err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
result = append(result, ext)
}
if result == nil {
result = make([]models.Extension, 0)
}
return result, rows.Err()
}

View File

@@ -25,5 +25,6 @@ func NewStores(db *sql.DB) store.Stores {
GlobalConfig: NewGlobalConfigStore(),
Usage: NewUsageStore(),
Pricing: NewPricingStore(),
Extensions: NewExtensionStore(),
}
}

384
server/tools/calculator.go Normal file
View File

@@ -0,0 +1,384 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
)
func init() {
Register(&CalculatorTool{})
}
// ═══════════════════════════════════════════
// calculator
// ═══════════════════════════════════════════
type CalculatorTool struct{}
func (t *CalculatorTool) Definition() ToolDef {
return ToolDef{
Name: "calculator",
Description: "Evaluate mathematical expressions with precision. Supports +, -, *, /, ^ (power), % (modulo), parentheses, and functions: sqrt, abs, ceil, floor, round, log, log2, log10, sin, cos, tan, pi, e. Use this for any arithmetic, unit conversions, percentages, or calculations instead of doing mental math.",
Parameters: JSONSchema(map[string]interface{}{
"expression": Prop("string", "Mathematical expression to evaluate, e.g. '(100 * 1.08) ^ 5' or 'sqrt(144) + log10(1000)'"),
}, []string{"expression"}),
}
}
func (t *CalculatorTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Expression string `json:"expression"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Expression == "" {
return "", fmt.Errorf("expression is required")
}
// Normalize: replace ^ with ** for power, then rewrite for Go parser
expr := normalizeExpr(args.Expression)
result, err := evalExpr(expr)
if err != nil {
return "", fmt.Errorf("evaluation error: %w", err)
}
// Format result nicely
var display string
if result == math.Trunc(result) && !math.IsInf(result, 0) && !math.IsNaN(result) {
display = strconv.FormatFloat(result, 'f', 0, 64)
} else {
display = strconv.FormatFloat(result, 'g', 15, 64)
}
resp := map[string]interface{}{
"expression": args.Expression,
"result": result,
"display": display,
}
b, _ := json.Marshal(resp)
return string(b), nil
}
// ── Expression Normalization ────────────────
func normalizeExpr(s string) string {
// Replace common aliases
s = strings.ReplaceAll(s, "×", "*")
s = strings.ReplaceAll(s, "÷", "/")
s = strings.ReplaceAll(s, "**", "^")
return s
}
// ── Recursive Descent Evaluator ─────────────
// Supports: +, -, *, /, ^, %, unary -, parentheses, function calls, constants
type calcParser struct {
input string
pos int
}
func evalExpr(input string) (float64, error) {
p := &calcParser{input: strings.TrimSpace(input)}
result, err := p.parseExpr()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.pos < len(p.input) {
return 0, fmt.Errorf("unexpected character at position %d: %q", p.pos, string(p.input[p.pos]))
}
return result, nil
}
func (p *calcParser) parseExpr() (float64, error) {
return p.parseAddSub()
}
func (p *calcParser) parseAddSub() (float64, error) {
left, err := p.parseMulDiv()
if err != nil {
return 0, err
}
for {
p.skipSpaces()
if p.pos >= len(p.input) {
break
}
op := p.input[p.pos]
if op != '+' && op != '-' {
break
}
p.pos++
right, err := p.parseMulDiv()
if err != nil {
return 0, err
}
if op == '+' {
left += right
} else {
left -= right
}
}
return left, nil
}
func (p *calcParser) parseMulDiv() (float64, error) {
left, err := p.parsePower()
if err != nil {
return 0, err
}
for {
p.skipSpaces()
if p.pos >= len(p.input) {
break
}
op := p.input[p.pos]
if op != '*' && op != '/' && op != '%' {
break
}
p.pos++
right, err := p.parsePower()
if err != nil {
return 0, err
}
switch op {
case '*':
left *= right
case '/':
if right == 0 {
return 0, fmt.Errorf("division by zero")
}
left /= right
case '%':
if right == 0 {
return 0, fmt.Errorf("modulo by zero")
}
left = math.Mod(left, right)
}
}
return left, nil
}
func (p *calcParser) parsePower() (float64, error) {
base, err := p.parseUnary()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.pos < len(p.input) && p.input[p.pos] == '^' {
p.pos++
// Right-associative: 2^3^2 = 2^(3^2)
exp, err := p.parsePower()
if err != nil {
return 0, err
}
return math.Pow(base, exp), nil
}
return base, nil
}
func (p *calcParser) parseUnary() (float64, error) {
p.skipSpaces()
if p.pos < len(p.input) && p.input[p.pos] == '-' {
p.pos++
val, err := p.parseUnary()
if err != nil {
return 0, err
}
return -val, nil
}
if p.pos < len(p.input) && p.input[p.pos] == '+' {
p.pos++
return p.parseUnary()
}
return p.parsePrimary()
}
func (p *calcParser) parsePrimary() (float64, error) {
p.skipSpaces()
if p.pos >= len(p.input) {
return 0, fmt.Errorf("unexpected end of expression")
}
// Parenthesized expression
if p.input[p.pos] == '(' {
p.pos++
val, err := p.parseExpr()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.pos >= len(p.input) || p.input[p.pos] != ')' {
return 0, fmt.Errorf("missing closing parenthesis")
}
p.pos++
return val, nil
}
// Identifier (function or constant)
if isAlpha(p.input[p.pos]) {
name := p.parseIdent()
return p.evalIdentifier(name)
}
// Number
return p.parseNumber()
}
func (p *calcParser) parseIdent() string {
start := p.pos
for p.pos < len(p.input) && (isAlpha(p.input[p.pos]) || isDigit(p.input[p.pos])) {
p.pos++
}
return strings.ToLower(p.input[start:p.pos])
}
func (p *calcParser) evalIdentifier(name string) (float64, error) {
// Constants
switch name {
case "pi":
return math.Pi, nil
case "e":
return math.E, nil
case "inf":
return math.Inf(1), nil
}
// Functions (require parenthesized argument)
p.skipSpaces()
if p.pos >= len(p.input) || p.input[p.pos] != '(' {
return 0, fmt.Errorf("unknown constant %q (did you mean %s(x)?)", name, name)
}
p.pos++ // consume '('
arg, err := p.parseExpr()
if err != nil {
return 0, err
}
// Check for second argument (e.g. pow(2, 3))
p.skipSpaces()
var arg2 float64
hasArg2 := false
if p.pos < len(p.input) && p.input[p.pos] == ',' {
p.pos++
arg2, err = p.parseExpr()
if err != nil {
return 0, err
}
hasArg2 = true
}
p.skipSpaces()
if p.pos >= len(p.input) || p.input[p.pos] != ')' {
return 0, fmt.Errorf("missing closing parenthesis for %s()", name)
}
p.pos++
switch name {
case "sqrt":
return math.Sqrt(arg), nil
case "abs":
return math.Abs(arg), nil
case "ceil":
return math.Ceil(arg), nil
case "floor":
return math.Floor(arg), nil
case "round":
return math.Round(arg), nil
case "log", "ln":
return math.Log(arg), nil
case "log2":
return math.Log2(arg), nil
case "log10":
return math.Log10(arg), nil
case "sin":
return math.Sin(arg), nil
case "cos":
return math.Cos(arg), nil
case "tan":
return math.Tan(arg), nil
case "asin":
return math.Asin(arg), nil
case "acos":
return math.Acos(arg), nil
case "atan":
return math.Atan(arg), nil
case "exp":
return math.Exp(arg), nil
case "pow":
if !hasArg2 {
return 0, fmt.Errorf("pow() requires two arguments: pow(base, exponent)")
}
return math.Pow(arg, arg2), nil
case "max":
if !hasArg2 {
return 0, fmt.Errorf("max() requires two arguments")
}
return math.Max(arg, arg2), nil
case "min":
if !hasArg2 {
return 0, fmt.Errorf("min() requires two arguments")
}
return math.Min(arg, arg2), nil
default:
return 0, fmt.Errorf("unknown function %q", name)
}
}
func (p *calcParser) parseNumber() (float64, error) {
start := p.pos
if p.pos < len(p.input) && p.input[p.pos] == '.' {
p.pos++
}
if p.pos >= len(p.input) || !isDigit(p.input[p.pos]) {
if p.pos > start {
// lone dot
return 0, fmt.Errorf("invalid number at position %d", start)
}
return 0, fmt.Errorf("expected number at position %d, got %q", p.pos, string(p.input[p.pos]))
}
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
p.pos++
}
if p.pos < len(p.input) && p.input[p.pos] == '.' {
p.pos++
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
p.pos++
}
}
// Scientific notation
if p.pos < len(p.input) && (p.input[p.pos] == 'e' || p.input[p.pos] == 'E') {
p.pos++
if p.pos < len(p.input) && (p.input[p.pos] == '+' || p.input[p.pos] == '-') {
p.pos++
}
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
p.pos++
}
}
val, err := strconv.ParseFloat(p.input[start:p.pos], 64)
if err != nil {
return 0, fmt.Errorf("invalid number %q", p.input[start:p.pos])
}
return val, nil
}
func (p *calcParser) skipSpaces() {
for p.pos < len(p.input) && p.input[p.pos] == ' ' {
p.pos++
}
}
func isAlpha(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' }
func isDigit(b byte) bool { return b >= '0' && b <= '9' }

64
server/tools/datetime.go Normal file
View File

@@ -0,0 +1,64 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"time"
)
func init() {
Register(&DateTimeTool{})
}
// ═══════════════════════════════════════════
// datetime
// ═══════════════════════════════════════════
type DateTimeTool struct{}
func (t *DateTimeTool) Definition() ToolDef {
return ToolDef{
Name: "datetime",
Description: "Get the current date, time, timezone, and day of week. Use this whenever you need to know the current date or time, calculate days between dates, or answer questions about what day it is. Do NOT guess dates — always call this tool.",
Parameters: JSONSchema(map[string]interface{}{
"timezone": Prop("string", "IANA timezone name, e.g. 'America/New_York', 'UTC', 'Europe/London'. Defaults to UTC."),
}, nil),
}
}
func (t *DateTimeTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Timezone string `json:"timezone"`
}
if argsJSON != "" {
_ = json.Unmarshal([]byte(argsJSON), &args)
}
loc := time.UTC
if args.Timezone != "" {
parsed, err := time.LoadLocation(args.Timezone)
if err != nil {
return "", fmt.Errorf("invalid timezone %q: %w", args.Timezone, err)
}
loc = parsed
}
now := time.Now().In(loc)
year, week := now.ISOWeek()
result := map[string]interface{}{
"datetime": now.Format(time.RFC3339),
"date": now.Format("2006-01-02"),
"time": now.Format("15:04:05"),
"day": now.Weekday().String(),
"timezone": loc.String(),
"unix": now.Unix(),
"iso_week": fmt.Sprintf("%d-W%02d", year, week),
"day_of_year": now.YearDay(),
}
b, _ := json.Marshal(result)
return string(b), nil
}