diff --git a/.gitignore b/.gitignore
index f8ed7ff3..beb8b117 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,19 @@ ui/web/dist/
ui/web/.vite/
*.tsbuildinfo
+# UI Desktop (Wails + React)
+ui/desktop/frontend/node_modules/
+ui/desktop/frontend/dist/*.js
+ui/desktop/frontend/dist/*.css
+ui/desktop/frontend/dist/assets/
+ui/desktop/frontend/.vite/
+ui/desktop/frontend/wailsjs/
+ui/desktop/frontend/package.json.md5
+ui/desktop/build/bin/
+ui/desktop/desktop
+goclaw-lite
+/desktop
+
.mcp.json
tmp
.claude/
diff --git a/cmd/gateway.go b/cmd/gateway.go
index 423c1bfc..59ca2fb5 100644
--- a/cmd/gateway.go
+++ b/cmd/gateway.go
@@ -25,6 +25,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo"
zalopersonal "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal"
"github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/heartbeat"
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
@@ -77,6 +78,21 @@ func runGateway() {
os.Exit(1)
}
+ // Edition override: explicit GOCLAW_EDITION takes precedence over auto-detection.
+ // Auto-detection happens later in setupStoresAndTracing (sqlite → lite).
+ if edName := os.Getenv("GOCLAW_EDITION"); edName != "" {
+ switch edName {
+ case "lite":
+ edition.SetCurrent(edition.Lite)
+ slog.Info("edition: lite (explicit)")
+ case "standard":
+ edition.SetCurrent(edition.Standard)
+ slog.Info("edition: standard (explicit)")
+ default:
+ slog.Warn("unknown GOCLAW_EDITION, using standard", "value", edName)
+ }
+ }
+
// Create core components
msgBus := bus.New()
@@ -433,6 +449,9 @@ func runGateway() {
// API documentation (OpenAPI spec + Swagger UI at /docs)
server.SetDocsHandler(httpapi.NewDocsHandler())
+ // Edition info (public, no auth — used by desktop UI comparison modal)
+ server.SetEditionHandler(httpapi.NewEditionHandler())
+
if pgStores != nil && pgStores.APIKeys != nil {
server.SetAPIKeysHandler(httpapi.NewAPIKeysHandler(pgStores.APIKeys, msgBus))
server.SetAPIKeyStore(pgStores.APIKeys)
diff --git a/cmd/gateway_builtin_tools.go b/cmd/gateway_builtin_tools.go
index 71f6f209..2dca1635 100644
--- a/cmd/gateway_builtin_tools.go
+++ b/cmd/gateway_builtin_tools.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"log/slog"
+ "github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
@@ -12,7 +13,7 @@ import (
// builtinToolSeedData returns the canonical list of built-in tools to seed into the database.
// Seed preserves user-customized enabled/settings values across upgrades.
func builtinToolSeedData() []store.BuiltinToolDef {
- return []store.BuiltinToolDef{
+ defs := []store.BuiltinToolDef{
// filesystem
{Name: "read_file", DisplayName: "Read File", Description: "Read the contents of a file from the agent's workspace by path", Category: "filesystem", Enabled: true},
{Name: "write_file", DisplayName: "Write File", Description: "Write content to a file in the workspace, creating directories as needed", Category: "filesystem", Enabled: true},
@@ -107,6 +108,19 @@ func builtinToolSeedData() []store.BuiltinToolDef {
Requires: []string{"managed_mode", "teams"},
},
}
+
+ // Lite edition: remove skill management tools — not available on desktop.
+ if !edition.Current().TeamFullMode {
+ liteHidden := map[string]bool{"skill_manage": true, "publish_skill": true}
+ filtered := defs[:0]
+ for _, d := range defs {
+ if !liteHidden[d.Name] {
+ filtered = append(filtered, d)
+ }
+ }
+ return filtered
+ }
+ return defs
}
// seedBuiltinTools seeds built-in tool definitions into the database.
@@ -220,14 +234,17 @@ func applyBuiltinToolDisables(ctx context.Context, bts store.BuiltinToolStore, t
return
}
- var disabled int
+ var disabledCount, enabledCount int
for _, t := range all {
if !t.Enabled {
- toolsReg.Unregister(t.Name)
- disabled++
+ toolsReg.Disable(t.Name)
+ disabledCount++
+ } else {
+ toolsReg.Enable(t.Name)
+ enabledCount++
}
}
- if disabled > 0 {
- slog.Info("builtin tools disabled", "count", disabled)
+ if disabledCount > 0 {
+ slog.Info("builtin tools updated", "disabled", disabledCount, "enabled", enabledCount)
}
}
diff --git a/cmd/gateway_export.go b/cmd/gateway_export.go
new file mode 100644
index 00000000..a2e92d97
--- /dev/null
+++ b/cmd/gateway_export.go
@@ -0,0 +1,7 @@
+package cmd
+
+// RunGateway starts the gateway server.
+// Exported for embedding in desktop app.
+func RunGateway() {
+ runGateway()
+}
diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go
index 163cee4d..0890057a 100644
--- a/cmd/gateway_managed.go
+++ b/cmd/gateway_managed.go
@@ -21,6 +21,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
@@ -177,7 +178,7 @@ func wireExtras(
if c, ok := m["content"].(string); ok && strings.Contains(c, "/v1/") {
m["content"] = httpapi.SignFileURLs(c, secret)
}
- // Convert media local paths → signed /v1/files/basename?ft=hash
+ // Convert media local paths → signed /v1/files/{full_path}?ft=hash
if rawMedia, ok := m["media"].([]agent.MediaResult); ok {
// Clone slice — the original is shared with RunResult.Media;
// mutating in-place corrupts paths for downstream consumers
@@ -185,8 +186,10 @@ func wireExtras(
signed := make([]agent.MediaResult, len(rawMedia))
for i, mr := range rawMedia {
signed[i] = mr
- basename := filepath.Base(mr.Path)
- url := "/v1/files/" + basename
+ // Use full path so backend resolves directly via os.Stat,
+ // no findInWorkspace fallback needed.
+ urlPath := strings.TrimPrefix(filepath.Clean(mr.Path), "/")
+ url := "/v1/files/" + urlPath
ft := httpapi.SignFileToken(url, secret, httpapi.FileTokenTTL)
signed[i].Path = url + "?ft=" + ft
}
@@ -278,7 +281,7 @@ func wireExtras(
ms.SetMemoryStore(stores.Memory)
}
}
- slog.Info("memory layering enabled (Postgres)")
+ slog.Info("memory layering enabled")
}
// Wire knowledge graph store on KG tool + hint in memory_search results
@@ -436,7 +439,11 @@ func wireExtras(
if stores.Teams != nil && stores.Agents != nil {
teamMgr := tools.NewTeamToolManager(stores.Teams, stores.Agents, msgBus, workspace)
postTurn = teamMgr
- toolsReg.Register(tools.NewTeamTasksTool(teamMgr))
+ var teamPolicy tools.TeamActionPolicy = tools.FullTeamPolicy{}
+ if !edition.Current().TeamFullMode {
+ teamPolicy = tools.LiteTeamPolicy{}
+ }
+ toolsReg.Register(tools.NewTeamTasksTool(teamMgr, teamPolicy))
// Wire workspace interceptor into write_file so team workspace validation
// and event broadcasting happen transparently via existing file tools.
wsInterceptor := tools.NewWorkspaceInterceptor(teamMgr)
diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go
index a9edfbb2..49b3e446 100644
--- a/cmd/gateway_setup.go
+++ b/cmd/gateway_setup.go
@@ -17,6 +17,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
+ "github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
@@ -222,6 +223,9 @@ func setupToolRegistry(
filepath.Join(workspace, "memory.db-shm"),
filepath.Join(workspace, "config.json"),
filepath.Join(workspace, "delegate"),
+ filepath.Join(dataDir, "goclaw.db"),
+ filepath.Join(dataDir, "goclaw.db-wal"),
+ filepath.Join(dataDir, "goclaw.db-shm"),
)
if cfgPath := os.Getenv("GOCLAW_CONFIG"); cfgPath != "" {
et.DenyPaths(cfgPath)
@@ -236,12 +240,14 @@ func setupToolRegistry(
// deny paths add defense-in-depth.
internalDenyPaths := []string{
"config.json", "memory.db", "memory.db-wal", "memory.db-shm",
+ "goclaw.db", "goclaw.db-wal", "goclaw.db-shm",
"memory/", ".media/", ".uploads/", "delegate/",
}
// read_file: allow .media/ access (uploaded documents accessed via AllowPaths
// for backward compat; new uploads go to per-user .uploads/ within workspace).
readFileDenyPaths := []string{
"config.json", "memory.db", "memory.db-wal", "memory.db-shm",
+ "goclaw.db", "goclaw.db-wal", "goclaw.db-shm",
"memory/", "delegate/",
}
if rf, ok := toolsReg.Get("read_file"); ok {
@@ -268,39 +274,17 @@ func setupToolRegistry(
return
}
-// setupStoresAndTracing creates PG stores, tracing collector, snapshot worker, and wires cron config.
-// Exits the process on unrecoverable errors (missing DSN, schema mismatch, store creation failure).
-func setupStoresAndTracing(
+// wireTracingAndCron sets up tracing collector, snapshot worker, and cron config
+// on an already-created store set. Shared between PG and SQLite build variants.
+func wireTracingAndCron(
cfg *config.Config,
- dataDir string,
+ stores *store.Stores,
msgBus *bus.MessageBus,
-) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) {
- // --- Store creation (Postgres) ---
- if cfg.Database.PostgresDSN == "" {
- slog.Error("GOCLAW_POSTGRES_DSN is required. Set it in your environment or .env.local file.")
- os.Exit(1)
- }
-
+ dataDir string,
+) (*tracing.Collector, *tracing.SnapshotWorker) {
var traceCollector *tracing.Collector
-
- // Schema compatibility check: ensure DB schema matches this binary.
- if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil {
- slog.Error("schema compatibility check failed", "error", err)
- os.Exit(1)
- }
-
- storeCfg := store.StoreConfig{
- PostgresDSN: cfg.Database.PostgresDSN,
- EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
- SkillsStorageDir: filepath.Join(dataDir, "skills-store"),
- }
- pgStores, pgErr := pg.NewPGStores(storeCfg)
- if pgErr != nil {
- slog.Error("failed to create PG stores", "error", pgErr)
- os.Exit(1)
- }
- if pgStores.Tracing != nil {
- traceCollector = tracing.NewCollector(pgStores.Tracing)
+ if stores.Tracing != nil {
+ traceCollector = tracing.NewCollector(stores.Tracing)
traceCollector.OnFlush = func(traceIDs []uuid.UUID) {
ids := make([]string, len(traceIDs))
for i, id := range traceIDs {
@@ -317,8 +301,8 @@ func setupStoresAndTracing(
// Start snapshot worker for hourly usage aggregation
var snapshotWorker *tracing.SnapshotWorker
- if pgStores.Snapshots != nil {
- snapshotWorker = tracing.NewSnapshotWorker(pgStores.DB, pgStores.Snapshots)
+ if stores.Snapshots != nil {
+ snapshotWorker = tracing.NewSnapshotWorker(stores.DB, stores.Snapshots)
snapshotWorker.Start()
// Backfill historical data in background
@@ -334,24 +318,25 @@ func setupStoresAndTracing(
// Wire cron config from config.json
cronRetryCfg := cfg.Cron.ToRetryConfig()
- // Apply retry config via type assertion on the concrete cron store.
- pgStores.Cron.SetOnJob(nil) // ensure initialized; actual handler set below
- _ = cronRetryCfg // config available; pg cron store reads it internally
- if cfg.Cron.DefaultTimezone != "" {
- pgStores.Cron.SetDefaultTimezone(cfg.Cron.DefaultTimezone)
+ if stores.Cron != nil {
+ stores.Cron.SetOnJob(nil) // ensure initialized; actual handler set below
+ _ = cronRetryCfg // config available; cron store reads it internally
+ if cfg.Cron.DefaultTimezone != "" {
+ stores.Cron.SetDefaultTimezone(cfg.Cron.DefaultTimezone)
+ }
}
// Load secrets from config_secrets table before env overrides.
// Precedence: config.json → DB secrets → env vars (highest).
- if pgStores.ConfigSecrets != nil {
- if secrets, err := pgStores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 {
+ if stores.ConfigSecrets != nil {
+ if secrets, err := stores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 {
cfg.ApplyDBSecrets(secrets)
cfg.ApplyEnvOverrides()
slog.Info("config secrets loaded from DB", "count", len(secrets))
}
}
- return pgStores, traceCollector, snapshotWorker
+ return traceCollector, snapshotWorker
}
// setupMemoryEmbeddings wires embedding provider to PGMemoryStore and triggers backfill.
@@ -547,8 +532,9 @@ func setupSkillsSystem(
}
}
- // Publish skill tool — lets agents register created skills in the database
- if pgStores.Skills != nil {
+ // Publish skill tool — lets agents register created skills in the database.
+ // Disabled in lite edition: agents should not self-manage skills on desktop.
+ if pgStores.Skills != nil && edition.Current().TeamFullMode {
if manageStore, ok := pgStores.Skills.(store.SkillManageStore); ok {
storeDirs := pgStores.Skills.Dirs()
if len(storeDirs) > 0 {
diff --git a/cmd/gateway_stores_pg.go b/cmd/gateway_stores_pg.go
new file mode 100644
index 00000000..90220c05
--- /dev/null
+++ b/cmd/gateway_stores_pg.go
@@ -0,0 +1,47 @@
+//go:build !sqlite && !sqliteonly
+
+package cmd
+
+import (
+ "log/slog"
+ "os"
+ "path/filepath"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/internal/store/pg"
+ "github.com/nextlevelbuilder/goclaw/internal/tracing"
+)
+
+// setupStoresAndTracing creates PG stores, tracing collector, snapshot worker, and wires cron config.
+// This is the default (PG-only) build — no SQLite support compiled in.
+func setupStoresAndTracing(
+ cfg *config.Config,
+ dataDir string,
+ msgBus *bus.MessageBus,
+) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) {
+ if cfg.Database.PostgresDSN == "" {
+ slog.Error("GOCLAW_POSTGRES_DSN is required. Set it in your environment or .env.local file.")
+ os.Exit(1)
+ }
+
+ if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil {
+ slog.Error("schema compatibility check failed", "error", err)
+ os.Exit(1)
+ }
+
+ storeCfg := store.StoreConfig{
+ PostgresDSN: cfg.Database.PostgresDSN,
+ EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
+ SkillsStorageDir: filepath.Join(dataDir, "skills-store"),
+ }
+ pgStores, pgErr := pg.NewPGStores(storeCfg)
+ if pgErr != nil {
+ slog.Error("failed to create PG stores", "error", pgErr)
+ os.Exit(1)
+ }
+
+ traceCollector, snapshotWorker := wireTracingAndCron(cfg, pgStores, msgBus, dataDir)
+ return pgStores, traceCollector, snapshotWorker
+}
diff --git a/cmd/gateway_stores_sqlite.go b/cmd/gateway_stores_sqlite.go
new file mode 100644
index 00000000..68286722
--- /dev/null
+++ b/cmd/gateway_stores_sqlite.go
@@ -0,0 +1,88 @@
+//go:build sqlite && !sqliteonly
+
+package cmd
+
+import (
+ "log/slog"
+ "os"
+ "path/filepath"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/edition"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/internal/store/pg"
+ "github.com/nextlevelbuilder/goclaw/internal/store/sqlitestore"
+ "github.com/nextlevelbuilder/goclaw/internal/tracing"
+)
+
+// setupStoresAndTracing creates stores (PG or SQLite based on config), tracing collector,
+// snapshot worker, and wires cron config.
+// Built with -tags sqlite: supports both backends, selected via GOCLAW_STORAGE_BACKEND env.
+func setupStoresAndTracing(
+ cfg *config.Config,
+ dataDir string,
+ msgBus *bus.MessageBus,
+) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) {
+ backend := cfg.Database.StorageBackend
+ if backend == "" {
+ backend = "postgres"
+ }
+
+ var stores *store.Stores
+
+ switch backend {
+ case "sqlite":
+ sqlitePath := cfg.Database.SQLitePath
+ if sqlitePath == "" {
+ sqlitePath = filepath.Join(dataDir, "goclaw.db")
+ }
+ storeCfg := store.StoreConfig{
+ SQLitePath: sqlitePath,
+ StorageBackend: "sqlite",
+ EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
+ SkillsStorageDir: filepath.Join(dataDir, "skills-store"),
+ }
+ s, err := sqlitestore.NewSQLiteStores(storeCfg)
+ if err != nil {
+ slog.Error("failed to create SQLite stores", "error", err, "path", sqlitePath)
+ os.Exit(1)
+ }
+ stores = s
+ // SQLite backend auto-defaults to Lite edition unless explicitly overridden.
+ if os.Getenv("GOCLAW_EDITION") == "" {
+ edition.SetCurrent(edition.Lite)
+ slog.Info("edition: lite (auto, sqlite backend)")
+ }
+ slog.Info("storage backend: sqlite", "path", sqlitePath)
+
+ case "postgres":
+ if cfg.Database.PostgresDSN == "" {
+ slog.Error("GOCLAW_POSTGRES_DSN is required. Set it in your environment or .env.local file.")
+ os.Exit(1)
+ }
+ if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil {
+ slog.Error("schema compatibility check failed", "error", err)
+ os.Exit(1)
+ }
+ storeCfg := store.StoreConfig{
+ PostgresDSN: cfg.Database.PostgresDSN,
+ EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
+ SkillsStorageDir: filepath.Join(dataDir, "skills-store"),
+ }
+ s, err := pg.NewPGStores(storeCfg)
+ if err != nil {
+ slog.Error("failed to create PG stores", "error", err)
+ os.Exit(1)
+ }
+ stores = s
+ slog.Info("storage backend: postgres")
+
+ default:
+ slog.Error("unknown GOCLAW_STORAGE_BACKEND; expected 'postgres' or 'sqlite'", "value", backend)
+ os.Exit(1)
+ }
+
+ traceCollector, snapshotWorker := wireTracingAndCron(cfg, stores, msgBus, dataDir)
+ return stores, traceCollector, snapshotWorker
+}
diff --git a/cmd/gateway_stores_sqliteonly.go b/cmd/gateway_stores_sqliteonly.go
new file mode 100644
index 00000000..318b89ae
--- /dev/null
+++ b/cmd/gateway_stores_sqliteonly.go
@@ -0,0 +1,50 @@
+//go:build sqliteonly
+
+package cmd
+
+import (
+ "log/slog"
+ "os"
+ "path/filepath"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/edition"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/internal/store/sqlitestore"
+ "github.com/nextlevelbuilder/goclaw/internal/tracing"
+)
+
+// setupStoresAndTracing creates SQLite-only stores, tracing collector, and snapshot worker.
+// Built with -tags sqliteonly: no PostgreSQL dependency compiled in.
+func setupStoresAndTracing(
+ cfg *config.Config,
+ dataDir string,
+ msgBus *bus.MessageBus,
+) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) {
+ sqlitePath := cfg.Database.SQLitePath
+ if sqlitePath == "" {
+ sqlitePath = filepath.Join(dataDir, "goclaw.db")
+ }
+ storeCfg := store.StoreConfig{
+ SQLitePath: sqlitePath,
+ StorageBackend: "sqlite",
+ EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
+ SkillsStorageDir: filepath.Join(dataDir, "skills-store"),
+ }
+ stores, err := sqlitestore.NewSQLiteStores(storeCfg)
+ if err != nil {
+ slog.Error("failed to create SQLite stores", "error", err, "path", sqlitePath)
+ os.Exit(1)
+ }
+
+ // SQLite-only always defaults to Lite edition unless explicitly overridden.
+ if os.Getenv("GOCLAW_EDITION") == "" {
+ edition.SetCurrent(edition.Lite)
+ slog.Info("edition: lite (auto, sqliteonly build)")
+ }
+ slog.Info("storage backend: sqlite (sqliteonly build)", "path", sqlitePath)
+
+ traceCollector, snapshotWorker := wireTracingAndCron(cfg, stores, msgBus, dataDir)
+ return stores, traceCollector, snapshotWorker
+}
diff --git a/go.mod b/go.mod
index 47b1782f..ea4022e6 100644
--- a/go.mod
+++ b/go.mod
@@ -48,6 +48,7 @@ require (
github.com/aws/smithy-go v1.24.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
+ github.com/bep/debounce v1.2.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
@@ -60,22 +61,32 @@ require (
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/creachadair/msync v0.7.1 // indirect
+ github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gaissmai/bart v0.18.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
- github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/huin/goupnp v1.3.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
+ github.com/labstack/echo/v4 v4.13.3 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
+ github.com/leaanthony/gosod v1.0.4 // indirect
+ github.com/leaanthony/slicer v1.6.0 // indirect
+ github.com/leaanthony/u v1.1.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
@@ -85,9 +96,14 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus-community/pro-bing v0.4.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/safchain/ethtool v0.3.0 // indirect
+ github.com/samber/lo v1.49.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
@@ -95,10 +111,16 @@ require (
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
+ github.com/tkrajina/go-reflector v0.5.8 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ github.com/wailsapp/go-webview2 v1.0.22 // indirect
+ github.com/wailsapp/mimetype v1.4.1 // indirect
+ github.com/wailsapp/wails/v2 v2.11.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+ github.com/zalando/go-keyring v0.2.8 // indirect
go.uber.org/atomic v1.11.0 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
@@ -109,6 +131,10 @@ require (
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect
+ modernc.org/libc v1.70.0 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.47.0 // indirect
)
require (
@@ -153,7 +179,7 @@ require (
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.49.0
golang.org/x/sync v0.19.0
- golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
diff --git a/go.sum b/go.sum
index e0d02565..314f5941 100644
--- a/go.sum
+++ b/go.sum
@@ -60,6 +60,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -127,6 +129,8 @@ github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoi
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
+github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -186,6 +190,8 @@ github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
+github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
+github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
@@ -231,6 +237,8 @@ github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
@@ -252,6 +260,18 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
+github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
+github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
+github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
+github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -260,6 +280,10 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
@@ -298,6 +322,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -308,6 +334,8 @@ github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
@@ -323,6 +351,8 @@ github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -333,6 +363,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/slack-go/slack v0.19.0 h1:J8lL/nGTsIUX53HU8YxZeI3PDkA+sxZsFrI2Dew7h44=
github.com/slack-go/slack v0.19.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
@@ -375,8 +407,11 @@ github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
+github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4=
github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s=
github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c=
+github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
+github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
@@ -389,8 +424,16 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
+github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
+github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
+github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
+github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
+github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -415,6 +458,8 @@ github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
+github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
+github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -465,6 +510,7 @@ golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
@@ -472,17 +518,24 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
@@ -518,6 +571,14 @@ honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLa
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
+modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
+modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
+modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.94.2 h1:H+0NYSG81K1RBXnh6FfWee9G1KEeX9pvYspPrVdIfII=
diff --git a/internal/agent/loop_finalize.go b/internal/agent/loop_finalize.go
index bf2c53da..ddaf0a4f 100644
--- a/internal/agent/loop_finalize.go
+++ b/internal/agent/loop_finalize.go
@@ -8,11 +8,29 @@ import (
"log/slog"
+ "github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
+// isUserFilePopulated checks if USER.md has been filled with actual user data
+// beyond the blank template. The template has "- **Name:**\n" with no value.
+func isUserFilePopulated(content string) bool {
+ trimmed := strings.TrimSpace(content)
+ if trimmed == "" {
+ return false
+ }
+ // Template markers: "**Name:**" followed by newline (no value) or just whitespace
+ for _, line := range strings.Split(content, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "- **Name:**" || line == "**Name:**" {
+ return false // name field still empty
+ }
+ }
+ return true
+}
+
// finalizeRun performs post-loop processing: sanitization, media dedup, session flush,
// bootstrap cleanup, and builds the final RunResult.
func (l *Loop) finalizeRun(
@@ -90,6 +108,7 @@ func (l *Loop) finalizeRun(
ID: filepath.Base(mr.Path),
MimeType: mr.ContentType,
Kind: kind,
+ Path: mr.Path,
})
}
rs.pendingMsgs = append(rs.pendingMsgs, assistantMsg)
@@ -134,6 +153,7 @@ func (l *Loop) finalizeRun(
l.sessions.Save(ctx, req.SessionKey)
// Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md.
+ // If USER.md is still the blank template, inject a reminder so the agent fills it.
if hadBootstrap && l.bootstrapCleanup != nil {
userTurns := 1 // current user message
for _, m := range history {
@@ -146,6 +166,19 @@ func (l *Loop) finalizeRun(
slog.Warn("bootstrap auto-cleanup failed", "error", cleanErr, "agent", l.id, "user", req.UserID)
} else {
slog.Info("bootstrap auto-cleanup completed", "agent", l.id, "user", req.UserID, "turns", userTurns)
+ // Check if USER.md is still the blank template — nudge agent to fill it
+ if l.contextFileLoader != nil {
+ files := l.contextFileLoader(ctx, l.agentUUID, req.UserID, l.agentType)
+ for _, f := range files {
+ if f.Path == bootstrap.UserFile && !isUserFilePopulated(f.Content) {
+ rs.pendingMsgs = append(rs.pendingMsgs, providers.Message{
+ Role: "user",
+ Content: "[System] You completed onboarding but USER.md is still empty. Please update USER.md with the user's name and details from this conversation using write_file.",
+ })
+ break
+ }
+ }
+ }
}
}
}
diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go
index b6a84b3e..3d03a9df 100644
--- a/internal/agent/loop_history.go
+++ b/internal/agent/loop_history.go
@@ -12,12 +12,21 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/safego"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
+// teamGuidance returns edition-specific system prompt guidance for team members.
+func teamGuidance(fullMode bool) string {
+ if fullMode {
+ return tools.FullTeamPolicy{}.MemberGuidance()
+ }
+ return tools.LiteTeamPolicy{}.MemberGuidance()
+}
+
// filteredToolNames returns tool names after applying policy filters.
// Used for system prompt so denied tools don't appear in ## Tooling section.
func (l *Loop) filteredToolNames() []string {
@@ -187,6 +196,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
HasTeam: hasTeamTools,
TeamWorkspace: tools.ToolTeamWorkspaceFromCtx(ctx),
TeamMembers: teamMembers,
+ TeamGuidance: teamGuidance(edition.Current().TeamFullMode),
HasSkillSearch: hasSkillSearch,
HasSkillManage: l.skillEvolve && hasSkillManage,
HasMCPToolSearch: hasMCPToolSearch,
diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go
index caabdbf3..92396ea5 100644
--- a/internal/agent/loop_tracing.go
+++ b/internal/agent/loop_tracing.go
@@ -70,8 +70,9 @@ func (l *Loop) emitLLMSpanStart(ctx context.Context, start time.Time, iteration
span.TenantID = store.MasterTenantID
}
- // Verbose mode: include input messages (same stripping as emitLLMSpan).
- if collector.Verbose() && len(messages) > 0 {
+ // Include input messages preview as truncated JSON.
+ if len(messages) > 0 {
+ previewLimit := previewLimitForVerbose(collector.Verbose())
stripped := make([]providers.Message, len(messages))
copy(stripped, messages)
for i := range stripped {
@@ -84,7 +85,7 @@ func (l *Loop) emitLLMSpanStart(ctx context.Context, start time.Time, iteration
}
}
if b, err := json.Marshal(stripped); err == nil {
- span.InputPreview = truncateStr(string(b), 100000)
+ span.InputPreview = tracing.TruncateJSON(string(b), previewLimit)
}
}
@@ -144,16 +145,12 @@ func (l *Loop) emitLLMSpanEnd(ctx context.Context, spanID uuid.UUID, start time.
}
}
updates["finish_reason"] = resp.FinishReason
- verbose := collector.Verbose()
- if verbose {
- preview := resp.Content
- if resp.Thinking != "" {
- preview = "\n" + resp.Thinking + "\n\n" + resp.Content
- }
- updates["output_preview"] = truncateStr(preview, 100000)
- } else {
- updates["output_preview"] = truncateStr(resp.Content, 500)
+ limit := previewLimitForVerbose(collector.Verbose())
+ preview := resp.Content
+ if resp.Thinking != "" {
+ preview = "\n" + resp.Thinking + "\n\n" + resp.Content
}
+ updates["output_preview"] = tracing.TruncateMid(preview, limit)
}
collector.EmitSpanUpdate(spanID, traceID, updates)
@@ -173,10 +170,7 @@ func (l *Loop) emitToolSpanStart(ctx context.Context, start time.Time, toolName,
return uuid.Nil
}
- previewLimit := 500
- if collector.Verbose() {
- previewLimit = 100000
- }
+ previewLimit := previewLimitForVerbose(collector.Verbose())
spanID := store.GenNewID()
span := store.SpanData{
@@ -187,7 +181,7 @@ func (l *Loop) emitToolSpanStart(ctx context.Context, start time.Time, toolName,
StartTime: start,
ToolName: toolName,
ToolCallID: toolCallID,
- InputPreview: truncateStr(input, previewLimit),
+ InputPreview: tracing.TruncateJSON(input, previewLimit),
Status: store.SpanStatusRunning,
Level: store.SpanLevelDefault,
CreatedAt: start,
@@ -222,16 +216,13 @@ func (l *Loop) emitToolSpanEnd(ctx context.Context, spanID uuid.UUID, start time
}
now := time.Now().UTC()
- previewLimit := 500
- if collector.Verbose() {
- previewLimit = 100000
- }
+ previewLimit := previewLimitForVerbose(collector.Verbose())
updates := map[string]any{
"end_time": now,
"duration_ms": int(now.Sub(start).Milliseconds()),
"status": store.SpanStatusCompleted,
- "output_preview": truncateStr(result.ForLLM, previewLimit),
+ "output_preview": tracing.TruncateMid(result.ForLLM, previewLimit),
}
if result.IsError {
@@ -282,10 +273,7 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st
return
}
- previewLimit := 500
- if collector.Verbose() {
- previewLimit = 100000
- }
+ previewLimit := previewLimitForVerbose(collector.Verbose())
spanName := l.id
span := store.SpanData{
@@ -298,7 +286,7 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st
Level: store.SpanLevelDefault,
Model: l.model,
Provider: l.provider.Name(),
- InputPreview: truncateStr(inputPreview, previewLimit),
+ InputPreview: tracing.TruncateMid(inputPreview, previewLimit),
CreatedAt: start,
}
// Nest under parent root span if this is an announce run.
@@ -341,11 +329,8 @@ func (l *Loop) emitAgentSpanEnd(ctx context.Context, agentSpanID uuid.UUID, star
updates["status"] = store.SpanStatusError
updates["error"] = runErr.Error()
} else if result != nil {
- limit := 500
- if collector.Verbose() {
- limit = 100000
- }
- updates["output_preview"] = truncateStr(result.Content, limit)
+ limit := previewLimitForVerbose(collector.Verbose())
+ updates["output_preview"] = tracing.TruncateMid(result.Content, limit)
// Note: token counts are NOT set on agent spans to avoid double-counting
// with child llm_call spans. Trace aggregation sums only llm_call spans.
}
@@ -353,16 +338,26 @@ func (l *Loop) emitAgentSpanEnd(ctx context.Context, agentSpanID uuid.UUID, star
collector.EmitSpanUpdate(agentSpanID, traceID, updates)
}
+// previewLimitForVerbose returns the preview character limit based on verbose mode.
+func previewLimitForVerbose(verbose bool) int {
+ if verbose {
+ return 200_000
+ }
+ return 40_000
+}
+
func truncateStr(s string, maxLen int) string {
s = strings.ToValidUTF8(s, "")
if len(s) <= maxLen {
return s
}
+ // Keep the tail — recent context is more useful for debugging.
+ start := len(s) - maxLen
// Don't cut in the middle of a multi-byte rune
- for maxLen > 0 && !utf8.RuneStart(s[maxLen]) {
- maxLen--
+ for start < len(s) && !utf8.RuneStart(s[start]) {
+ start++
}
- return s[:maxLen] + "..."
+ return "..." + s[start:]
}
// EstimateTokens returns a rough token estimate for a slice of messages.
diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go
index 598289b3..5ce1a871 100644
--- a/internal/agent/systemprompt.go
+++ b/internal/agent/systemprompt.go
@@ -37,6 +37,7 @@ type SystemPromptConfig struct {
HasTeam bool // agent belongs to a team? (skips generic spawn section)
TeamWorkspace string // absolute path to team shared workspace (empty if not in team)
TeamMembers []store.TeamMemberData // team member roster for task assignment
+ TeamGuidance string // edition-specific guidance from TeamActionPolicy.MemberGuidance()
ContextFiles []bootstrap.ContextFile // bootstrap files for # Project Context
ExtraPrompt string // extra system prompt (subagent context, etc.)
AgentType string // "open" or "predefined" — affects context file framing
@@ -228,7 +229,7 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string {
// 6.4. ## Team Members — inject roster so agent knows who to assign tasks to
if !cfg.IsBootstrap && len(cfg.TeamMembers) > 0 {
- lines = append(lines, buildTeamMembersSection(cfg.TeamMembers)...)
+ lines = append(lines, buildTeamMembersSection(cfg.TeamMembers, cfg.TeamGuidance)...)
}
// 6.5 ## Sandbox (matching TS sandboxInfo section) — skip during bootstrap
diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go
index c9af9681..b3f02205 100644
--- a/internal/agent/systemprompt_sections.go
+++ b/internal/agent/systemprompt_sections.go
@@ -392,7 +392,8 @@ func buildTeamWorkspaceSection(teamWsPath string) []string {
}
// buildTeamMembersSection lists team members so the agent knows who to assign tasks to.
-func buildTeamMembersSection(members []store.TeamMemberData) []string {
+// teamGuidance is injected from TeamActionPolicy.MemberGuidance() — varies by edition.
+func buildTeamMembersSection(members []store.TeamMemberData, teamGuidance string) []string {
lines := []string{
"## Team Members",
"",
@@ -413,7 +414,10 @@ func buildTeamMembersSection(members []store.TeamMemberData) []string {
"",
"When creating tasks with team_tasks, set assignee to the agent_key of the best-suited member.",
"Do NOT invent agent keys — only use the keys listed above.",
- "",
)
+ if teamGuidance != "" {
+ lines = append(lines, teamGuidance)
+ }
+ lines = append(lines, "")
return lines
}
diff --git a/internal/config/config.go b/internal/config/config.go
index 47811969..989c73c5 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -66,11 +66,13 @@ type TailscaleConfig struct {
EnableTLS bool `json:"enable_tls,omitempty"` // use ListenTLS for auto HTTPS certs
}
-// DatabaseConfig configures the PostgreSQL connection and optional Redis cache.
+// DatabaseConfig configures the database connection and optional Redis cache.
// DSN fields are NEVER read from config.json (secrets) — only from env vars.
type DatabaseConfig struct {
- PostgresDSN string `json:"-"` // from env GOCLAW_POSTGRES_DSN only
- RedisDSN string `json:"-"` // from env GOCLAW_REDIS_DSN only (optional, requires -tags redis)
+ PostgresDSN string `json:"-"` // from env GOCLAW_POSTGRES_DSN only
+ RedisDSN string `json:"-"` // from env GOCLAW_REDIS_DSN only (optional, requires -tags redis)
+ StorageBackend string `json:"-"` // from env GOCLAW_STORAGE_BACKEND only ("postgres" or "sqlite", default "postgres")
+ SQLitePath string `json:"-"` // from env GOCLAW_SQLITE_PATH only (default: {dataDir}/goclaw.db)
}
// SkillsConfig configures the skills storage system.
diff --git a/internal/config/config_load.go b/internal/config/config_load.go
index 15a706c1..5d414551 100644
--- a/internal/config/config_load.go
+++ b/internal/config/config_load.go
@@ -186,6 +186,8 @@ func (c *Config) applyEnvOverrides() {
// Database
envStr("GOCLAW_POSTGRES_DSN", &c.Database.PostgresDSN)
envStr("GOCLAW_REDIS_DSN", &c.Database.RedisDSN)
+ envStr("GOCLAW_STORAGE_BACKEND", &c.Database.StorageBackend)
+ envStr("GOCLAW_SQLITE_PATH", &c.Database.SQLitePath)
// Deprecation warning for GOCLAW_MODE (removed — PostgreSQL is always active)
if v := os.Getenv("GOCLAW_MODE"); v != "" {
diff --git a/internal/edition/edition.go b/internal/edition/edition.go
new file mode 100644
index 00000000..73918b91
--- /dev/null
+++ b/internal/edition/edition.go
@@ -0,0 +1,79 @@
+// Package edition defines feature tiers for GoClaw.
+// Set once at startup via SetCurrent(), read everywhere via Current().
+// Adding a new edition = add one preset struct. No code changes elsewhere.
+package edition
+
+import "sync/atomic"
+
+// Edition defines the feature limits for a GoClaw instance.
+type Edition struct {
+ Name string `json:"name"` // "standard" or "lite"
+ MaxAgents int `json:"max_agents"` // 0 = unlimited
+ MaxTeams int `json:"max_teams"` // 0 = unlimited
+ MaxTeamMembers int `json:"max_team_members"` // 0 = unlimited
+ MaxChannels map[string]int `json:"max_channels"` // per channel type, nil = unlimited
+ KGEnabled bool `json:"kg_enabled"`
+ RBACEnabled bool `json:"rbac_enabled"`
+ TeamFullMode bool `json:"team_full_mode"` // false = lite task actions only
+ VectorSearch bool `json:"vector_search"` // false = FTS5 only
+}
+
+// --- Presets ---
+
+// Standard is the default edition: all features enabled, no limits.
+var Standard = Edition{
+ Name: "standard",
+ KGEnabled: true,
+ RBACEnabled: true,
+ TeamFullMode: true,
+ VectorSearch: true,
+}
+
+// Lite is the desktop/self-hosted edition with sensible limits.
+var Lite = Edition{
+ Name: "lite",
+ MaxAgents: 5,
+ MaxTeams: 1,
+ MaxTeamMembers: 5,
+ MaxChannels: map[string]int{"telegram": 1, "discord": 1},
+ KGEnabled: false,
+ RBACEnabled: false,
+ TeamFullMode: false,
+ VectorSearch: false,
+}
+
+// --- Global state ---
+
+// current holds the active edition. Atomic pointer for safe concurrent reads.
+var current atomic.Pointer[Edition]
+
+func init() {
+ std := Standard
+ current.Store(&std)
+}
+
+// Current returns the active edition. Safe for concurrent use.
+func Current() Edition {
+ return *current.Load()
+}
+
+// SetCurrent sets the active edition. Call once at startup.
+func SetCurrent(e Edition) {
+ current.Store(&e)
+}
+
+// --- Helpers ---
+
+// IsLimited returns true if the edition enforces resource limits.
+func (e Edition) IsLimited() bool {
+ return e.MaxAgents > 0 || e.MaxTeams > 0
+}
+
+// ChannelLimit returns the max instances for a channel type.
+// Returns 0 (unlimited) if the channel type is not in MaxChannels.
+func (e Edition) ChannelLimit(channelType string) int {
+ if e.MaxChannels == nil {
+ return 0
+ }
+ return e.MaxChannels[channelType]
+}
diff --git a/internal/gateway/methods/teams.go b/internal/gateway/methods/teams.go
index c8d91ca1..29039759 100644
--- a/internal/gateway/methods/teams.go
+++ b/internal/gateway/methods/teams.go
@@ -125,6 +125,10 @@ func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client,
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "lead")))
return
}
+ if len(params.Members) == 0 {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "at least 1 member is required"))
+ return
+ }
// Resolve lead agent
leadAgent, err := resolveAgentInfo(ctx, m.agentStore, params.Lead)
diff --git a/internal/gateway/server.go b/internal/gateway/server.go
index fe276124..c7e48f5f 100644
--- a/internal/gateway/server.go
+++ b/internal/gateway/server.go
@@ -8,6 +8,7 @@ import (
"log/slog"
"net"
"net/http"
+ "os"
"strings"
"sync"
"time"
@@ -279,10 +280,16 @@ func tokenAuthMiddleware(token string, next http.Handler) http.Handler {
func (s *Server) Start(ctx context.Context) error {
mux := s.BuildMux()
+ // Wrap with CORS for desktop dev mode (Wails serves frontend on different port).
+ var handler http.Handler = mux
+ if os.Getenv("GOCLAW_DESKTOP") == "1" {
+ handler = desktopCORS(mux)
+ }
+
addr := fmt.Sprintf("%s:%d", s.cfg.Gateway.Host, s.cfg.Gateway.Port)
s.httpServer = &http.Server{
Addr: addr,
- Handler: mux,
+ Handler: handler,
}
slog.Info("gateway starting", "addr", addr)
@@ -469,6 +476,9 @@ func (s *Server) SetUsageHandler(h *httpapi.UsageHandler) { s.handlers = append(
// SetDocsHandler sets the OpenAPI spec + Swagger UI handler.
func (s *Server) SetDocsHandler(h *httpapi.DocsHandler) { s.handlers = append(s.handlers, h) }
+// SetEditionHandler sets the edition info handler.
+func (s *Server) SetEditionHandler(h *httpapi.EditionHandler) { s.handlers = append(s.handlers, h) }
+
// SetAgentStore sets the agent store for context injection in tools_invoke.
func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as }
@@ -612,3 +622,18 @@ func StartTestServer(s *Server, ctx context.Context) (addr string, start func())
return addr, start
}
+
+// desktopCORS wraps a handler with permissive CORS headers for desktop dev mode.
+// Only active when GOCLAW_DESKTOP=1 (set by desktop app.go).
+func desktopCORS(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-GoClaw-Tenant-Id, X-GoClaw-User-Id")
+ if r.Method == "OPTIONS" {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/http/edition.go b/internal/http/edition.go
new file mode 100644
index 00000000..ed4ba40f
--- /dev/null
+++ b/internal/http/edition.go
@@ -0,0 +1,23 @@
+package http
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/nextlevelbuilder/goclaw/internal/edition"
+)
+
+// EditionHandler serves the current edition info for UI feature comparison.
+type EditionHandler struct{}
+
+func NewEditionHandler() *EditionHandler { return &EditionHandler{} }
+
+// RegisterRoutes adds the /v1/edition route. No auth required — edition info is public.
+func (h *EditionHandler) RegisterRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("GET /v1/edition", h.handleGet)
+}
+
+func (h *EditionHandler) handleGet(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(edition.Current())
+}
diff --git a/internal/http/files.go b/internal/http/files.go
index 951a2fbe..b16f6981 100644
--- a/internal/http/files.go
+++ b/internal/http/files.go
@@ -1,6 +1,7 @@
package http
import (
+ "encoding/json"
"fmt"
"log/slog"
"mime"
@@ -10,6 +11,7 @@ import (
"strings"
"github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -33,6 +35,29 @@ func NewFilesHandler(workspace, dataDir string) *FilesHandler {
// RegisterRoutes registers the file serving route.
func (h *FilesHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/files/{path...}", h.auth(h.handleServe))
+ mux.HandleFunc("POST /v1/files/sign", h.handleSign)
+}
+
+// handleSign accepts a JSON body with a "path" field (absolute file path),
+// returns a signed /v1/files/ URL with ?ft= token. Requires Bearer auth.
+func (h *FilesHandler) handleSign(w http.ResponseWriter, r *http.Request) {
+ provided := extractBearerToken(r)
+ authedReq, ok := requireAuthBearer("", provided, w, r)
+ if !ok {
+ return
+ }
+ var body struct {
+ Path string `json:"path"`
+ }
+ if err := json.NewDecoder(authedReq.Body).Decode(&body); err != nil || body.Path == "" {
+ http.Error(w, `{"error":"path required"}`, http.StatusBadRequest)
+ return
+ }
+ urlPath := "/v1/files/" + strings.TrimPrefix(filepath.Clean(body.Path), "/")
+ ft := SignFileToken(urlPath, FileSigningKey(), FileTokenTTL)
+ writeJSON(w, http.StatusOK, map[string]string{
+ "url": urlPath + "?ft=" + ft,
+ })
}
func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
@@ -93,21 +118,52 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
}
}
- // Tenant isolation: validate absolute path is within tenant's allowed directories.
- // Skip for HMAC file tokens — the path is cryptographically bound in the signature,
- // so it cannot be tampered with. File tokens are generated server-side with the correct path.
+ // Path isolation: validate file path is within allowed directories.
+ // Skip for HMAC file tokens — path is cryptographically bound in the signature.
if r.URL.Query().Get("ft") == "" {
- tenantData := config.TenantDataDir(h.dataDir, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context()))
- tenantWs := h.tenantWorkspace(r)
- if !strings.HasPrefix(absPath, tenantData+string(filepath.Separator)) &&
- !strings.HasPrefix(absPath, tenantWs+string(filepath.Separator)) &&
- absPath != tenantData && absPath != tenantWs {
+ allowed := false
+
+ // Always allow files within workspace root and data dir root.
+ // These are the two top-level directories that contain all user files.
+ sep := string(filepath.Separator)
+ if h.workspace != "" && (strings.HasPrefix(absPath, h.workspace+sep) || absPath == h.workspace) {
+ allowed = true
+ }
+ if !allowed && h.dataDir != "" && (strings.HasPrefix(absPath, h.dataDir+sep) || absPath == h.dataDir) {
+ allowed = true
+ }
+
+ // Multi-tenant (standard edition): additionally restrict to tenant-scoped subdirectories.
+ if allowed && edition.Current().RBACEnabled {
+ tenantData := config.TenantDataDir(h.dataDir, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context()))
+ tenantWs := h.tenantWorkspace(r)
+ if !strings.HasPrefix(absPath, tenantData+sep) &&
+ !strings.HasPrefix(absPath, tenantWs+sep) &&
+ absPath != tenantData && absPath != tenantWs {
+ allowed = false
+ }
+ }
+
+ if !allowed {
+ slog.Warn("security.files_path_denied", "path", absPath, "workspace", h.workspace, "data_dir", h.dataDir)
http.NotFound(w, r)
return
}
}
info, err := os.Stat(absPath)
+ if err != nil && !os.IsNotExist(err) {
+ http.NotFound(w, r)
+ return
+ }
+ // Fuzzy match: generated files have timestamp suffixes (e.g. "file_20260326-232559_269000.png")
+ // but LLM may reference them without timestamp (e.g. "file.png"). Try prefix match in same dir.
+ if err != nil {
+ if resolved := fuzzyMatchInDir(absPath); resolved != "" {
+ absPath = resolved
+ info, err = os.Stat(absPath)
+ }
+ }
if err != nil || info.IsDir() {
// Fallback: search workspace for file by basename (handles LLM-hallucinated paths).
// Generated media filenames include timestamps and are globally unique.
@@ -162,8 +218,16 @@ func (h *FilesHandler) findInWorkspace(workspace, basename string) string {
}
if d.IsDir() {
name := d.Name()
- // Allow workspace root + known directory structures
- if name == "teams" || name == "generated" || name == "system" || name == ".uploads" || path == workspace {
+ // Allow workspace root
+ if path == workspace {
+ return nil
+ }
+ // Allow direct children of workspace root (agent workspace dirs like "quill", "goclaw")
+ if filepath.Dir(path) == workspace {
+ return nil
+ }
+ // Allow known directory structures
+ if name == "teams" || name == "generated" || name == "system" || name == "ws" || name == ".uploads" || name == "tenants" {
return nil
}
// Allow date directories (e.g. 2026-03-20)
@@ -185,6 +249,32 @@ func (h *FilesHandler) findInWorkspace(workspace, basename string) string {
return found
}
+// fuzzyMatchInDir handles LLM-hallucinated filenames missing timestamp suffixes.
+// E.g. requested "file.png" matches "file_20260326-232559_269000.png" in the same directory.
+func fuzzyMatchInDir(absPath string) string {
+ dir := filepath.Dir(absPath)
+ base := filepath.Base(absPath)
+ ext := filepath.Ext(base)
+ stem := strings.TrimSuffix(base, ext) // "smart-home-cover"
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return ""
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ // Match: starts with stem, has same extension, has timestamp between
+ // e.g. "smart-home-cover_20260326-232444_269000.png"
+ if strings.HasPrefix(name, stem) && strings.HasSuffix(name, ext) && name != base {
+ return filepath.Join(dir, name)
+ }
+ }
+ return ""
+}
+
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
@@ -193,3 +283,4 @@ func isNumeric(s string) bool {
}
return len(s) > 0
}
+
diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go
index e7533790..fac4089a 100644
--- a/internal/store/agent_store.go
+++ b/internal/store/agent_store.go
@@ -156,16 +156,19 @@ func (a *AgentData) ParseMemoryConfig() *config.MemoryConfig {
}
// ParseThinkingLevel extracts thinking_level from other_config JSONB.
-// Returns "" if not configured (meaning "off").
+// Returns "low" if not configured (default thinking mode).
func (a *AgentData) ParseThinkingLevel() string {
if len(a.OtherConfig) == 0 {
- return ""
+ return "low"
}
var cfg struct {
ThinkingLevel string `json:"thinking_level"`
}
if json.Unmarshal(a.OtherConfig, &cfg) != nil {
- return ""
+ return "low"
+ }
+ if cfg.ThinkingLevel == "" {
+ return "low"
}
return cfg.ThinkingLevel
}
diff --git a/internal/store/pg/tracing.go b/internal/store/pg/tracing.go
index bb99c2e7..706a4925 100644
--- a/internal/store/pg/tracing.go
+++ b/internal/store/pg/tracing.go
@@ -547,3 +547,20 @@ func (s *PGTracingStore) GetCostSummary(ctx context.Context, opts store.CostSumm
}
return result, nil
}
+
+// DeleteTracesOlderThan deletes traces and their spans older than cutoff.
+// Spans are deleted first (FK), then traces. Returns total traces deleted.
+func (s *PGTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
+ // Delete spans belonging to old traces.
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM spans WHERE trace_id IN (SELECT id FROM traces WHERE created_at < $1)`, cutoff)
+ if err != nil {
+ return 0, fmt.Errorf("delete old spans: %w", err)
+ }
+
+ res, err := s.db.ExecContext(ctx, `DELETE FROM traces WHERE created_at < $1`, cutoff)
+ if err != nil {
+ return 0, fmt.Errorf("delete old traces: %w", err)
+ }
+ return res.RowsAffected()
+}
diff --git a/internal/store/sqlitestore/activity.go b/internal/store/sqlitestore/activity.go
new file mode 100644
index 00000000..cf4b0b9e
--- /dev/null
+++ b/internal/store/sqlitestore/activity.go
@@ -0,0 +1,120 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteActivityStore implements store.ActivityStore backed by SQLite.
+type SQLiteActivityStore struct {
+ db *sql.DB
+}
+
+// NewSQLiteActivityStore creates a new SQLiteActivityStore.
+func NewSQLiteActivityStore(db *sql.DB) *SQLiteActivityStore {
+ return &SQLiteActivityStore{db: db}
+}
+
+func (s *SQLiteActivityStore) Log(ctx context.Context, entry *store.ActivityLog) error {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+ id := uuid.New()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO activity_logs (id, actor_type, actor_id, action, entity_type, entity_id, details, ip_address, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ id, entry.ActorType, entry.ActorID, entry.Action,
+ entry.EntityType, entry.EntityID, entry.Details, entry.IPAddress, tenantID,
+ )
+ return err
+}
+
+func (s *SQLiteActivityStore) List(ctx context.Context, opts store.ActivityListOpts) ([]store.ActivityLog, error) {
+ where, args := buildActivityWhere(ctx, opts)
+ limit := opts.Limit
+ if limit <= 0 {
+ limit = 50
+ }
+ args = append(args, limit, opts.Offset)
+
+ query := fmt.Sprintf(
+ `SELECT id, actor_type, actor_id, action, COALESCE(entity_type,''), COALESCE(entity_id,''), COALESCE(details,'null'), COALESCE(ip_address,''), created_at
+ FROM activity_logs %s ORDER BY created_at DESC LIMIT ? OFFSET ?`,
+ where,
+ )
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.ActivityLog
+ for rows.Next() {
+ var a store.ActivityLog
+ var createdAt sqliteTime
+ if err := rows.Scan(&a.ID, &a.ActorType, &a.ActorID, &a.Action, &a.EntityType, &a.EntityID, &a.Details, &a.IPAddress, &createdAt); err != nil {
+ return nil, err
+ }
+ a.CreatedAt = createdAt.Time
+ result = append(result, a)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteActivityStore) Count(ctx context.Context, opts store.ActivityListOpts) (int, error) {
+ where, args := buildActivityWhere(ctx, opts)
+ query := fmt.Sprintf("SELECT COUNT(*) FROM activity_logs %s", where)
+
+ var count int
+ err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
+ return count, err
+}
+
+func buildActivityWhere(ctx context.Context, opts store.ActivityListOpts) (string, []any) {
+ var conditions []string
+ var args []any
+
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID != uuid.Nil {
+ conditions = append(conditions, "tenant_id = ?")
+ args = append(args, tenantID)
+ }
+ }
+
+ if opts.ActorType != "" {
+ conditions = append(conditions, "actor_type = ?")
+ args = append(args, opts.ActorType)
+ }
+ if opts.ActorID != "" {
+ conditions = append(conditions, "actor_id = ?")
+ args = append(args, opts.ActorID)
+ }
+ if opts.Action != "" {
+ conditions = append(conditions, "action = ?")
+ args = append(args, opts.Action)
+ }
+ if opts.EntityType != "" {
+ conditions = append(conditions, "entity_type = ?")
+ args = append(args, opts.EntityType)
+ }
+ if opts.EntityID != "" {
+ conditions = append(conditions, "entity_id = ?")
+ args = append(args, opts.EntityID)
+ }
+
+ if len(conditions) == 0 {
+ return "", nil
+ }
+ return "WHERE " + strings.Join(conditions, " AND "), args
+}
diff --git a/internal/store/sqlitestore/agents.go b/internal/store/sqlitestore/agents.go
new file mode 100644
index 00000000..d642e3fa
--- /dev/null
+++ b/internal/store/sqlitestore/agents.go
@@ -0,0 +1,274 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteAgentStore implements store.AgentStore backed by SQLite.
+type SQLiteAgentStore struct {
+ db *sql.DB
+ // embProvider is intentionally omitted — vector search not supported in SQLite lite edition.
+}
+
+func NewSQLiteAgentStore(db *sql.DB) *SQLiteAgentStore {
+ return &SQLiteAgentStore{db: db}
+}
+
+// SetEmbeddingProvider is a no-op for SQLite — vector not supported.
+func (s *SQLiteAgentStore) SetEmbeddingProvider(_ store.EmbeddingProvider) {}
+
+// agentSelectCols is the column list for all agent SELECT queries.
+const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, provider, model,
+ context_window, max_tool_iterations, workspace, restrict_to_workspace,
+ tools_config, sandbox_config, subagents_config, memory_config,
+ compaction_config, context_pruning, other_config,
+ agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id`
+
+func (s *SQLiteAgentStore) Create(ctx context.Context, agent *store.AgentData) error {
+ if agent.ID == uuid.Nil {
+ agent.ID = store.GenNewID()
+ }
+ now := time.Now()
+ agent.CreatedAt = now
+ agent.UpdatedAt = now
+ tenantID := agent.TenantID
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO agents (id, agent_key, display_name, frontmatter, owner_id, provider, model,
+ context_window, max_tool_iterations, workspace, restrict_to_workspace,
+ tools_config, sandbox_config, subagents_config, memory_config,
+ compaction_config, context_pruning, other_config,
+ agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ agent.ID, agent.AgentKey,
+ agent.DisplayName,
+ sql.NullString{String: agent.Frontmatter, Valid: agent.Frontmatter != ""},
+ agent.OwnerID, agent.Provider, agent.Model,
+ agent.ContextWindow, agent.MaxToolIterations, agent.Workspace, agent.RestrictToWorkspace,
+ jsonOrEmpty(agent.ToolsConfig), jsonOrNull(agent.SandboxConfig), jsonOrNull(agent.SubagentsConfig), jsonOrNull(agent.MemoryConfig),
+ jsonOrNull(agent.CompactionConfig), jsonOrNull(agent.ContextPruning), jsonOrEmpty(agent.OtherConfig),
+ agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents,
+ now, now, tenantID,
+ )
+ return err
+}
+
+func (s *SQLiteAgentStore) GetByKey(ctx context.Context, agentKey string) (*store.AgentData, error) {
+ var row *sql.Row
+ if store.IsCrossTenant(ctx) {
+ row = s.db.QueryRowContext(ctx,
+ `SELECT `+agentSelectCols+` FROM agents WHERE agent_key = ? AND deleted_at IS NULL`, agentKey)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("agent not found: %s", agentKey)
+ }
+ row = s.db.QueryRowContext(ctx,
+ `SELECT `+agentSelectCols+` FROM agents WHERE agent_key = ? AND deleted_at IS NULL AND tenant_id = ?`,
+ agentKey, tid)
+ }
+ d, err := scanAgentRow(row)
+ if err != nil {
+ return nil, fmt.Errorf("agent not found: %s", agentKey)
+ }
+ return d, nil
+}
+
+func (s *SQLiteAgentStore) GetByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) {
+ var row *sql.Row
+ if store.IsCrossTenant(ctx) {
+ row = s.db.QueryRowContext(ctx,
+ `SELECT `+agentSelectCols+` FROM agents WHERE id = ? AND deleted_at IS NULL`, id)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("agent not found: %s", id)
+ }
+ row = s.db.QueryRowContext(ctx,
+ `SELECT `+agentSelectCols+` FROM agents WHERE id = ? AND deleted_at IS NULL AND tenant_id = ?`, id, tid)
+ }
+ d, err := scanAgentRow(row)
+ if err != nil {
+ return nil, fmt.Errorf("agent not found: %s", id)
+ }
+ return d, nil
+}
+
+func (s *SQLiteAgentStore) GetByIDUnscoped(ctx context.Context, id uuid.UUID) (*store.AgentData, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+agentSelectCols+` FROM agents WHERE id = ? AND deleted_at IS NULL`, id)
+ d, err := scanAgentRow(row)
+ if err != nil {
+ return nil, fmt.Errorf("agent not found: %s", id)
+ }
+ return d, nil
+}
+
+func (s *SQLiteAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
+ if len(updates) == 0 {
+ return nil
+ }
+
+ // Unset existing default before setting a new one (scoped to same tenant).
+ if v, ok := updates["is_default"]; ok {
+ if isDefault, _ := v.(bool); isDefault {
+ if store.IsCrossTenant(ctx) {
+ if _, err := s.db.ExecContext(ctx,
+ "UPDATE agents SET is_default = 0 WHERE is_default = 1 AND id != ? AND deleted_at IS NULL", id); err != nil {
+ slog.Warn("agents.unset_default", "error", err)
+ }
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ if _, err := s.db.ExecContext(ctx,
+ "UPDATE agents SET is_default = 0 WHERE is_default = 1 AND id != ? AND deleted_at IS NULL AND tenant_id = ?",
+ id, tid); err != nil {
+ slog.Warn("agents.unset_default", "error", err)
+ }
+ }
+ }
+ }
+ }
+
+ updates["updated_at"] = time.Now()
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "agents", id, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("agent not found: %s", id)
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "agents", updates, id, tid)
+}
+
+func (s *SQLiteAgentStore) Delete(ctx context.Context, id uuid.UUID) error {
+ if store.IsCrossTenant(ctx) {
+ _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = ?", id)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("agent not found: %s", id)
+ }
+ _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = ? AND tenant_id = ?", id, tid)
+ return err
+}
+
+func (s *SQLiteAgentStore) List(ctx context.Context, ownerID string) ([]store.AgentData, error) {
+ q := `SELECT ` + agentSelectCols + ` FROM agents WHERE deleted_at IS NULL`
+ var args []any
+
+ if ownerID != "" {
+ q += " AND owner_id = ?"
+ args = append(args, ownerID)
+ }
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += " AND tenant_id = ?"
+ args = append(args, tid)
+ }
+ }
+
+ q += " ORDER BY created_at DESC"
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanAgentRows(rows)
+}
+
+func (s *SQLiteAgentStore) GetDefault(ctx context.Context) (*store.AgentData, error) {
+ if store.IsCrossTenant(ctx) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents WHERE deleted_at IS NULL
+ ORDER BY is_default DESC, created_at ASC LIMIT 1`)
+ return scanAgentRow(row)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("agent not found: default")
+ }
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents WHERE deleted_at IS NULL AND tenant_id = ?
+ ORDER BY is_default DESC, created_at ASC LIMIT 1`, tid)
+ return scanAgentRow(row)
+}
+
+// --- Scan helpers ---
+
+type agentRowScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanAgentRow(row agentRowScanner) (*store.AgentData, error) {
+ var d store.AgentData
+ var frontmatter sql.NullString
+ var toolsCfg, sandboxCfg, subagentsCfg, memoryCfg, compactionCfg, pruningCfg, otherCfg *[]byte
+ createdAt, updatedAt := scanTimePair()
+ err := row.Scan(
+ &d.ID, &d.AgentKey, &d.DisplayName, &frontmatter, &d.OwnerID, &d.Provider, &d.Model,
+ &d.ContextWindow, &d.MaxToolIterations, &d.Workspace, &d.RestrictToWorkspace,
+ &toolsCfg, &sandboxCfg, &subagentsCfg, &memoryCfg, &compactionCfg, &pruningCfg, &otherCfg,
+ &d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents,
+ createdAt, updatedAt, &d.TenantID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ d.CreatedAt = createdAt.Time
+ d.UpdatedAt = updatedAt.Time
+ if frontmatter.Valid {
+ d.Frontmatter = frontmatter.String
+ }
+ if toolsCfg != nil {
+ d.ToolsConfig = *toolsCfg
+ }
+ if sandboxCfg != nil {
+ d.SandboxConfig = *sandboxCfg
+ }
+ if subagentsCfg != nil {
+ d.SubagentsConfig = *subagentsCfg
+ }
+ if memoryCfg != nil {
+ d.MemoryConfig = *memoryCfg
+ }
+ if compactionCfg != nil {
+ d.CompactionConfig = *compactionCfg
+ }
+ if pruningCfg != nil {
+ d.ContextPruning = *pruningCfg
+ }
+ if otherCfg != nil {
+ d.OtherConfig = *otherCfg
+ }
+ return &d, nil
+}
+
+func scanAgentRows(rows *sql.Rows) ([]store.AgentData, error) {
+ var result []store.AgentData
+ for rows.Next() {
+ d, err := scanAgentRow(rows)
+ if err != nil {
+ continue
+ }
+ result = append(result, *d)
+ }
+ return result, rows.Err()
+}
diff --git a/internal/store/sqlitestore/agents_access.go b/internal/store/sqlitestore/agents_access.go
new file mode 100644
index 00000000..fd6a9e31
--- /dev/null
+++ b/internal/store/sqlitestore/agents_access.go
@@ -0,0 +1,180 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+func (s *SQLiteAgentStore) ShareAgent(ctx context.Context, agentID uuid.UUID, userID, role, grantedBy string) error {
+ if err := store.ValidateUserID(userID); err != nil {
+ return err
+ }
+ if err := store.ValidateUserID(grantedBy); err != nil {
+ return err
+ }
+ tid := tenantIDForInsert(ctx)
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO agent_shares (id, agent_id, user_id, role, granted_by, tenant_id, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (agent_id, user_id) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by`,
+ store.GenNewID(), agentID, userID, role, grantedBy, tid, time.Now(),
+ )
+ return err
+}
+
+func (s *SQLiteAgentStore) RevokeShare(ctx context.Context, agentID uuid.UUID, userID string) error {
+ if store.IsCrossTenant(ctx) {
+ _, err := s.db.ExecContext(ctx,
+ "DELETE FROM agent_shares WHERE agent_id = ? AND user_id = ?", agentID, userID)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ _, err := s.db.ExecContext(ctx,
+ "DELETE FROM agent_shares WHERE agent_id = ? AND user_id = ? AND tenant_id = ?", agentID, userID, tid)
+ return err
+}
+
+func (s *SQLiteAgentStore) ListShares(ctx context.Context, agentID uuid.UUID) ([]store.AgentShareData, error) {
+ q := "SELECT id, agent_id, user_id, role, granted_by, created_at FROM agent_shares WHERE agent_id = ?"
+ args := []any{agentID}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ q += " AND tenant_id = ?"
+ args = append(args, tid)
+ }
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.AgentShareData
+ for rows.Next() {
+ var d store.AgentShareData
+ var createdAt sqliteTime
+ if err := rows.Scan(&d.ID, &d.AgentID, &d.UserID, &d.Role, &d.GrantedBy, &createdAt); err != nil {
+ continue
+ }
+ d.CreatedAt = createdAt.Time
+ result = append(result, d)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteAgentStore) CanAccess(ctx context.Context, agentID uuid.UUID, userID string) (bool, string, error) {
+ var ownerID string
+ var isDefault bool
+ var err error
+ if store.IsCrossTenant(ctx) {
+ err = s.db.QueryRowContext(ctx,
+ "SELECT owner_id, is_default FROM agents WHERE id = ? AND deleted_at IS NULL", agentID,
+ ).Scan(&ownerID, &isDefault)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return false, "", fmt.Errorf("agent not found")
+ }
+ err = s.db.QueryRowContext(ctx,
+ "SELECT owner_id, is_default FROM agents WHERE id = ? AND deleted_at IS NULL AND tenant_id = ?",
+ agentID, tid,
+ ).Scan(&ownerID, &isDefault)
+ }
+ if err != nil {
+ return false, "", fmt.Errorf("agent not found")
+ }
+ if isDefault {
+ if ownerID == userID {
+ return true, "owner", nil
+ }
+ return true, "user", nil
+ }
+ if ownerID == userID {
+ return true, "owner", nil
+ }
+ // Check shares
+ var role string
+ if store.IsCrossTenant(ctx) {
+ err = s.db.QueryRowContext(ctx,
+ "SELECT role FROM agent_shares WHERE agent_id = ? AND user_id = ?", agentID, userID,
+ ).Scan(&role)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return false, "", nil
+ }
+ err = s.db.QueryRowContext(ctx,
+ "SELECT role FROM agent_shares WHERE agent_id = ? AND user_id = ? AND tenant_id = ?",
+ agentID, userID, tid,
+ ).Scan(&role)
+ }
+ if err != nil {
+ return false, "", nil
+ }
+ return true, role, nil
+}
+
+func (s *SQLiteAgentStore) ListAccessible(ctx context.Context, userID string) ([]store.AgentData, error) {
+ if store.IsCrossTenant(ctx) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents
+ WHERE deleted_at IS NULL AND (
+ owner_id = ?
+ OR is_default = 1
+ OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = ?)
+ OR (agent_type = 'predefined' AND id IN (
+ SELECT agent_id FROM channel_instances ci
+ WHERE ci.enabled = 1
+ AND EXISTS (
+ SELECT 1 FROM json_each(json_extract(ci.config, '$.allow_from'))
+ WHERE json_each.value = ?
+ )
+ ))
+ )
+ ORDER BY created_at DESC`, userID, userID, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanAgentRows(rows)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents
+ WHERE deleted_at IS NULL AND tenant_id = ? AND (
+ owner_id = ?
+ OR is_default = 1
+ OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = ? AND tenant_id = ?)
+ OR (agent_type = 'predefined' AND id IN (
+ SELECT agent_id FROM channel_instances ci
+ WHERE ci.enabled = 1 AND ci.tenant_id = ?
+ AND EXISTS (
+ SELECT 1 FROM json_each(json_extract(ci.config, '$.allow_from'))
+ WHERE json_each.value = ?
+ )
+ ))
+ )
+ ORDER BY created_at DESC`, tid, userID, userID, tid, tid, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanAgentRows(rows)
+}
diff --git a/internal/store/sqlitestore/agents_batch.go b/internal/store/sqlitestore/agents_batch.go
new file mode 100644
index 00000000..d667f380
--- /dev/null
+++ b/internal/store/sqlitestore/agents_batch.go
@@ -0,0 +1,87 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// GetByKeys returns agents matching the given keys in a single query.
+// Replaces PG's = ANY($1) with a dynamic IN (?, ?, ...) clause.
+func (s *SQLiteAgentStore) GetByKeys(ctx context.Context, keys []string) ([]store.AgentData, error) {
+ if len(keys) == 0 {
+ return nil, nil
+ }
+
+ placeholders := strings.Repeat("?,", len(keys))
+ placeholders = placeholders[:len(placeholders)-1] // trim trailing comma
+
+ args := make([]any, len(keys))
+ for i, k := range keys {
+ args[i] = k
+ }
+
+ var q string
+ if store.IsCrossTenant(ctx) {
+ q = `SELECT ` + agentSelectCols + `
+ FROM agents WHERE agent_key IN (` + placeholders + `) AND deleted_at IS NULL`
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("no tenant context for batch agent lookup")
+ }
+ q = `SELECT ` + agentSelectCols + `
+ FROM agents WHERE agent_key IN (` + placeholders + `) AND deleted_at IS NULL AND tenant_id = ?`
+ args = append(args, tid)
+ }
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, fmt.Errorf("batch agent key lookup: %w", err)
+ }
+ defer rows.Close()
+ return scanAgentRows(rows)
+}
+
+// GetByIDs returns agents matching the given UUIDs in a single query.
+// Replaces PG's = ANY($1) with a dynamic IN (?, ?, ...) clause.
+func (s *SQLiteAgentStore) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]store.AgentData, error) {
+ if len(ids) == 0 {
+ return nil, nil
+ }
+
+ placeholders := strings.Repeat("?,", len(ids))
+ placeholders = placeholders[:len(placeholders)-1]
+
+ args := make([]any, len(ids))
+ for i, id := range ids {
+ args[i] = id
+ }
+
+ var q string
+ if store.IsCrossTenant(ctx) {
+ q = `SELECT ` + agentSelectCols + `
+ FROM agents WHERE id IN (` + placeholders + `) AND deleted_at IS NULL`
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("no tenant context for batch agent lookup")
+ }
+ q = `SELECT ` + agentSelectCols + `
+ FROM agents WHERE id IN (` + placeholders + `) AND deleted_at IS NULL AND tenant_id = ?`
+ args = append(args, tid)
+ }
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, fmt.Errorf("batch agent ID lookup: %w", err)
+ }
+ defer rows.Close()
+ return scanAgentRows(rows)
+}
diff --git a/internal/store/sqlitestore/agents_context.go b/internal/store/sqlitestore/agents_context.go
new file mode 100644
index 00000000..7d0b6c43
--- /dev/null
+++ b/internal/store/sqlitestore/agents_context.go
@@ -0,0 +1,305 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "path/filepath"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// --- Agent-level Context Files ---
+
+func (s *SQLiteAgentStore) GetAgentContextFiles(ctx context.Context, agentID uuid.UUID) ([]store.AgentContextFileData, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ "SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = ?"+tClause,
+ append([]any{agentID}, tArgs...)...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.AgentContextFileData
+ for rows.Next() {
+ var d store.AgentContextFileData
+ if err := rows.Scan(&d.AgentID, &d.FileName, &d.Content); err != nil {
+ continue
+ }
+ result = append(result, d)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteAgentStore) SetAgentContextFile(ctx context.Context, agentID uuid.UUID, fileName, content string) error {
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO agent_context_files (id, agent_id, file_name, content, updated_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT (agent_id, file_name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`,
+ store.GenNewID(), agentID, fileName, content, time.Now(), tenantIDForInsert(ctx),
+ )
+ return err
+}
+
+// PropagateContextFile copies an agent-level context file to all existing user
+// instances that already have that file. Returns updated row count.
+// SQLite does not support UPDATE...FROM, so we use a 2-step approach.
+func (s *SQLiteAgentStore) PropagateContextFile(ctx context.Context, agentID uuid.UUID, fileName string) (int, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return 0, err
+ }
+
+ // Step 1: fetch the agent-level content.
+ var content string
+ err = s.db.QueryRowContext(ctx,
+ "SELECT content FROM agent_context_files WHERE agent_id = ? AND file_name = ?"+tClause,
+ append([]any{agentID, fileName}, tArgs...)...,
+ ).Scan(&content)
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+
+ // Step 2: update all matching user context files.
+ res, err := s.db.ExecContext(ctx,
+ "UPDATE user_context_files SET content = ?, updated_at = ? WHERE agent_id = ? AND file_name = ?"+tClause,
+ append([]any{content, time.Now(), agentID, fileName}, tArgs...)...,
+ )
+ if err != nil {
+ return 0, err
+ }
+ n, _ := res.RowsAffected()
+ return int(n), nil
+}
+
+// --- Per-user Context Files ---
+
+func (s *SQLiteAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid.UUID, userID string) ([]store.UserContextFileData, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = ? AND user_id = ?"+tClause,
+ append([]any{agentID, userID}, tArgs...)...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.UserContextFileData
+ for rows.Next() {
+ var d store.UserContextFileData
+ if err := rows.Scan(&d.AgentID, &d.UserID, &d.FileName, &d.Content); err != nil {
+ continue
+ }
+ result = append(result, d)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteAgentStore) SetUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName, content string) error {
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO user_context_files (id, agent_id, user_id, file_name, content, updated_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (agent_id, user_id, file_name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`,
+ store.GenNewID(), agentID, userID, fileName, content, time.Now(), tenantIDForInsert(ctx),
+ )
+ return err
+}
+
+func (s *SQLiteAgentStore) DeleteUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) error {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM user_context_files WHERE agent_id = ? AND user_id = ? AND file_name = ?"+tClause,
+ append([]any{agentID, userID, fileName}, tArgs...)...)
+ return err
+}
+
+// --- User-Agent Profiles ---
+
+// GetOrCreateUserProfile upserts a user profile and returns (isNew, effectiveWorkspace, error).
+// SQLite has no xmax trick; we use INSERT OR IGNORE + UPDATE + SELECT to detect insert vs update.
+func (s *SQLiteAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (bool, string, error) {
+ effectiveWs := config.ContractHome(workspace)
+ if channel != "" {
+ effectiveWs = filepath.Join(effectiveWs, channel)
+ }
+
+ tid := tenantIDForInsert(ctx)
+ now := time.Now()
+
+ // Try insert (ignored on conflict). Use changes() to detect if row was actually inserted.
+ res, err := s.db.ExecContext(ctx,
+ `INSERT OR IGNORE INTO user_agent_profiles (agent_id, user_id, workspace, first_seen_at, last_seen_at, tenant_id)
+ VALUES (?, ?, NULLIF(?, ''), ?, ?, ?)`,
+ agentID, userID, effectiveWs, now, now, tid,
+ )
+ if err != nil {
+ return false, effectiveWs, err
+ }
+ inserted, _ := res.RowsAffected()
+ isNew := inserted > 0
+
+ // Always bump last_seen_at for existing rows.
+ if !isNew {
+ _, err = s.db.ExecContext(ctx,
+ `UPDATE user_agent_profiles SET last_seen_at = ? WHERE agent_id = ? AND user_id = ?`,
+ now, agentID, userID,
+ )
+ if err != nil {
+ return false, effectiveWs, err
+ }
+ }
+
+ // Read back stored workspace.
+ var storedWorkspace sql.NullString
+ err = s.db.QueryRowContext(ctx,
+ `SELECT workspace FROM user_agent_profiles WHERE agent_id = ? AND user_id = ?`,
+ agentID, userID,
+ ).Scan(&storedWorkspace)
+ if err != nil {
+ return false, effectiveWs, err
+ }
+
+ ws := effectiveWs
+ if storedWorkspace.Valid && storedWorkspace.String != "" {
+ ws = storedWorkspace.String
+ }
+ return isNew, ws, nil
+}
+
+// EnsureUserProfile creates a minimal user_agent_profiles row if not exists.
+func (s *SQLiteAgentStore) EnsureUserProfile(ctx context.Context, agentID uuid.UUID, userID string) error {
+ now := time.Now()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT OR IGNORE INTO user_agent_profiles (agent_id, user_id, first_seen_at, last_seen_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?)`,
+ agentID, userID, now, now, tenantIDForInsert(ctx),
+ )
+ return err
+}
+
+// --- User Instances ---
+
+func (s *SQLiteAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) {
+ tClause, tArgs, err := scopeClauseAlias(ctx, "p")
+ if err != nil {
+ return nil, err
+ }
+ subTenantFilter := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ subTenantFilter = " AND tenant_id = ?"
+ // tArgs already has tid; reuse it by appending another copy below.
+ }
+ }
+ // Build args: subquery agent_id [+ optional tenant_id], main WHERE agent_id, scope args.
+ var queryArgs []any
+ queryArgs = append(queryArgs, agentID) // subquery: agent_id = ?
+ if subTenantFilter != "" {
+ queryArgs = append(queryArgs, tArgs[0]) // subquery: tenant_id = ?
+ }
+ queryArgs = append(queryArgs, agentID) // main WHERE: p.agent_id = ?
+ queryArgs = append(queryArgs, tArgs...) // scope clause args
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT p.user_id,
+ strftime('%Y-%m-%dT%H:%M:%SZ', p.first_seen_at) AS first_seen_at,
+ strftime('%Y-%m-%dT%H:%M:%SZ', p.last_seen_at) AS last_seen_at,
+ COALESCE(fc.cnt, 0) AS file_count,
+ COALESCE(p.metadata, '{}')
+ FROM user_agent_profiles p
+ LEFT JOIN (
+ SELECT user_id, COUNT(*) AS cnt
+ FROM user_context_files
+ WHERE agent_id = ?`+subTenantFilter+`
+ GROUP BY user_id
+ ) fc ON fc.user_id = p.user_id
+ WHERE p.agent_id = ?`+tClause+`
+ ORDER BY p.last_seen_at DESC
+ `, queryArgs...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.UserInstanceData
+ for rows.Next() {
+ var d store.UserInstanceData
+ var metaJSON []byte
+ if err := rows.Scan(&d.UserID, &d.FirstSeenAt, &d.LastSeenAt, &d.FileCount, &metaJSON); err != nil {
+ continue
+ }
+ if len(metaJSON) > 0 {
+ json.Unmarshal(metaJSON, &d.Metadata)
+ }
+ result = append(result, d)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteAgentStore) UpdateUserProfileMetadata(ctx context.Context, agentID uuid.UUID, userID string, metadata map[string]string) error {
+ metaJSON, err := json.Marshal(metadata)
+ if err != nil {
+ return err
+ }
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ `UPDATE user_agent_profiles SET metadata = json_patch(COALESCE(metadata, '{}'), ?)
+ WHERE agent_id = ? AND user_id = ?`+tClause,
+ append([]any{metaJSON, agentID, userID}, tArgs...)...,
+ )
+ return err
+}
+
+// --- User Overrides ---
+
+func (s *SQLiteAgentStore) GetUserOverride(ctx context.Context, agentID uuid.UUID, userID string) (*store.UserAgentOverrideData, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var d store.UserAgentOverrideData
+ err = s.db.QueryRowContext(ctx,
+ "SELECT agent_id, user_id, provider, model FROM user_agent_overrides WHERE agent_id = ? AND user_id = ?"+tClause,
+ append([]any{agentID, userID}, tArgs...)...,
+ ).Scan(&d.AgentID, &d.UserID, &d.Provider, &d.Model)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, nil
+ }
+ return &d, nil
+}
+
+func (s *SQLiteAgentStore) SetUserOverride(ctx context.Context, override *store.UserAgentOverrideData) error {
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO user_agent_overrides (id, agent_id, user_id, provider, model, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT (agent_id, user_id) DO UPDATE SET provider = excluded.provider, model = excluded.model`,
+ store.GenNewID(), override.AgentID, override.UserID, override.Provider, override.Model, tenantIDForInsert(ctx),
+ )
+ return err
+}
diff --git a/internal/store/sqlitestore/api_keys.go b/internal/store/sqlitestore/api_keys.go
new file mode 100644
index 00000000..11aeeafc
--- /dev/null
+++ b/internal/store/sqlitestore/api_keys.go
@@ -0,0 +1,224 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteAPIKeyStore implements store.APIKeyStore backed by SQLite.
+type SQLiteAPIKeyStore struct {
+ db *sql.DB
+}
+
+// NewSQLiteAPIKeyStore creates a new SQLite-backed API key store.
+func NewSQLiteAPIKeyStore(db *sql.DB) *SQLiteAPIKeyStore {
+ return &SQLiteAPIKeyStore{db: db}
+}
+
+func (s *SQLiteAPIKeyStore) Create(ctx context.Context, key *store.APIKeyData) error {
+ var ownerID *string
+ if key.OwnerID != "" {
+ ownerID = &key.OwnerID
+ }
+ var tenantID *uuid.UUID
+ if key.TenantID != uuid.Nil {
+ tenantID = &key.TenantID
+ }
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO api_keys (id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, created_by, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ key.ID, key.Name, key.Prefix, key.KeyHash, jsonStringArray(key.Scopes),
+ ownerID, tenantID, key.ExpiresAt, nilStr(key.CreatedBy), key.CreatedAt, key.UpdatedAt,
+ )
+ return err
+}
+
+func (s *SQLiteAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.APIKeyData, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at
+ FROM api_keys
+ WHERE key_hash = ? AND NOT revoked AND (expires_at IS NULL OR expires_at > datetime('now'))`,
+ keyHash,
+ )
+
+ var k store.APIKeyData
+ var createdBy *string
+ var ownerID *string
+ var tenantID *uuid.UUID
+ var scopesRaw []byte
+ var expiresAt, lastUsedAt nullSqliteTime
+ createdAt, updatedAt := scanTimePair()
+ err := row.Scan(
+ &k.ID, &k.Name, &k.Prefix, &k.KeyHash, &scopesRaw,
+ &ownerID, &tenantID, &expiresAt, &lastUsedAt, &k.Revoked, &createdBy,
+ createdAt, updatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ k.CreatedAt = createdAt.Time
+ k.UpdatedAt = updatedAt.Time
+ if expiresAt.Valid {
+ k.ExpiresAt = &expiresAt.Time
+ }
+ if lastUsedAt.Valid {
+ k.LastUsedAt = &lastUsedAt.Time
+ }
+ scanJSONStringArray(scopesRaw, &k.Scopes)
+ if createdBy != nil {
+ k.CreatedBy = *createdBy
+ }
+ if ownerID != nil {
+ k.OwnerID = *ownerID
+ }
+ if tenantID != nil {
+ k.TenantID = *tenantID
+ }
+ return &k, nil
+}
+
+func (s *SQLiteAPIKeyStore) List(ctx context.Context, ownerID string) ([]store.APIKeyData, error) {
+ var conditions []string
+ var args []any
+
+ if ownerID != "" {
+ conditions = append(conditions, "owner_id = ?")
+ args = append(args, ownerID)
+ }
+
+ // Tenant filter: include tenant-scoped keys + system keys (NULL tenant_id).
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ conditions = append(conditions, "(tenant_id = ? OR tenant_id IS NULL)")
+ args = append(args, tid)
+ }
+ }
+
+ where := ""
+ if len(conditions) > 0 {
+ where = " WHERE " + strings.Join(conditions, " AND ")
+ }
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, name, prefix, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at
+ FROM api_keys`+where+`
+ ORDER BY created_at DESC`,
+ args...,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var keys []store.APIKeyData
+ for rows.Next() {
+ var k store.APIKeyData
+ var createdBy *string
+ var oID *string
+ var tID *uuid.UUID
+ var scopesRaw []byte
+ var expiresAt, lastUsedAt nullSqliteTime
+ createdAt, updatedAt := scanTimePair()
+ if err := rows.Scan(
+ &k.ID, &k.Name, &k.Prefix, &scopesRaw,
+ &oID, &tID, &expiresAt, &lastUsedAt, &k.Revoked, &createdBy,
+ createdAt, updatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ k.CreatedAt = createdAt.Time
+ k.UpdatedAt = updatedAt.Time
+ if expiresAt.Valid {
+ k.ExpiresAt = &expiresAt.Time
+ }
+ if lastUsedAt.Valid {
+ k.LastUsedAt = &lastUsedAt.Time
+ }
+ scanJSONStringArray(scopesRaw, &k.Scopes)
+ if createdBy != nil {
+ k.CreatedBy = *createdBy
+ }
+ if oID != nil {
+ k.OwnerID = *oID
+ }
+ if tID != nil {
+ k.TenantID = *tID
+ }
+ keys = append(keys, k)
+ }
+ return keys, rows.Err()
+}
+
+func (s *SQLiteAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID, ownerID string) error {
+ q := "UPDATE api_keys SET revoked = 1, updated_at = ? WHERE id = ?"
+ args := []any{time.Now(), id}
+
+ if ownerID != "" {
+ q += " AND owner_id = ?"
+ args = append(args, ownerID)
+ }
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += " AND (tenant_id = ? OR tenant_id IS NULL)"
+ args = append(args, tid)
+ }
+ }
+
+ res, err := s.db.ExecContext(ctx, q, args...)
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return sql.ErrNoRows
+ }
+ return nil
+}
+
+func (s *SQLiteAPIKeyStore) Delete(ctx context.Context, id uuid.UUID, ownerID string) error {
+ q := "DELETE FROM api_keys WHERE id = ?"
+ args := []any{id}
+
+ if ownerID != "" {
+ q += " AND owner_id = ?"
+ args = append(args, ownerID)
+ }
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += " AND (tenant_id = ? OR tenant_id IS NULL)"
+ args = append(args, tid)
+ }
+ }
+
+ res, err := s.db.ExecContext(ctx, q, args...)
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return sql.ErrNoRows
+ }
+ return nil
+}
+
+func (s *SQLiteAPIKeyStore) TouchLastUsed(ctx context.Context, id uuid.UUID) error {
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE api_keys SET last_used_at = ? WHERE id = ?`,
+ time.Now(), id,
+ )
+ return err
+}
+
+// ensure interface is satisfied at compile time
+var _ store.APIKeyStore = (*SQLiteAPIKeyStore)(nil)
diff --git a/internal/store/sqlitestore/builtin-tools.go b/internal/store/sqlitestore/builtin-tools.go
new file mode 100644
index 00000000..33e603e0
--- /dev/null
+++ b/internal/store/sqlitestore/builtin-tools.go
@@ -0,0 +1,229 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteBuiltinToolStore implements store.BuiltinToolStore backed by SQLite.
+type SQLiteBuiltinToolStore struct {
+ db *sql.DB
+}
+
+func NewSQLiteBuiltinToolStore(db *sql.DB) *SQLiteBuiltinToolStore {
+ return &SQLiteBuiltinToolStore{db: db}
+}
+
+const builtinToolSelectCols = `name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at`
+
+func (s *SQLiteBuiltinToolStore) List(ctx context.Context) ([]store.BuiltinToolDef, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+builtinToolSelectCols+` FROM builtin_tools ORDER BY category, name`)
+ if err != nil {
+ return nil, err
+ }
+ return s.scanTools(rows)
+}
+
+func (s *SQLiteBuiltinToolStore) ListEnabled(ctx context.Context) ([]store.BuiltinToolDef, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+builtinToolSelectCols+` FROM builtin_tools WHERE enabled = 1 ORDER BY category, name`)
+ if err != nil {
+ return nil, err
+ }
+ return s.scanTools(rows)
+}
+
+func (s *SQLiteBuiltinToolStore) Get(ctx context.Context, name string) (*store.BuiltinToolDef, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+builtinToolSelectCols+` FROM builtin_tools WHERE name = ?`, name)
+ return s.scanTool(row)
+}
+
+func (s *SQLiteBuiltinToolStore) GetSettings(ctx context.Context, name string) (json.RawMessage, error) {
+ var settings json.RawMessage
+ err := s.db.QueryRowContext(ctx,
+ `SELECT settings FROM builtin_tools WHERE name = ?`, name).Scan(&settings)
+ if err != nil {
+ return nil, err
+ }
+ return settings, nil
+}
+
+func (s *SQLiteBuiltinToolStore) Update(ctx context.Context, name string, updates map[string]any) error {
+ allowed := make(map[string]any)
+ if v, ok := updates["enabled"]; ok {
+ allowed["enabled"] = v
+ }
+ if v, ok := updates["settings"]; ok {
+ switch sv := v.(type) {
+ case json.RawMessage:
+ allowed["settings"] = []byte(sv)
+ case map[string]any:
+ b, err := json.Marshal(sv)
+ if err != nil {
+ return fmt.Errorf("marshal settings: %w", err)
+ }
+ allowed["settings"] = b
+ case []byte:
+ allowed["settings"] = sv
+ case string:
+ allowed["settings"] = []byte(sv)
+ }
+ }
+ if len(allowed) == 0 {
+ return nil
+ }
+ allowed["updated_at"] = time.Now()
+
+ var setClauses []string
+ var args []any
+ for col, val := range allowed {
+ setClauses = append(setClauses, col+" = ?")
+ args = append(args, val)
+ }
+ args = append(args, name)
+ q := fmt.Sprintf("UPDATE builtin_tools SET %s WHERE name = ?", strings.Join(setClauses, ", "))
+ _, err := s.db.ExecContext(ctx, q, args...)
+ return err
+}
+
+// Seed inserts or updates builtin tool definitions.
+// Preserves user-customized enabled and settings values across upgrades.
+func (s *SQLiteBuiltinToolStore) Seed(ctx context.Context, tools []store.BuiltinToolDef) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ stmt, err := tx.PrepareContext(ctx,
+ `INSERT INTO builtin_tools (name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (name) DO UPDATE SET
+ display_name = excluded.display_name,
+ description = excluded.description,
+ category = excluded.category,
+ requires = excluded.requires,
+ metadata = excluded.metadata,
+ settings = CASE
+ WHEN builtin_tools.settings IS NULL OR builtin_tools.settings IN ('{}', 'null')
+ THEN excluded.settings
+ ELSE builtin_tools.settings
+ END,
+ updated_at = excluded.updated_at`)
+ if err != nil {
+ return fmt.Errorf("prepare seed stmt: %w", err)
+ }
+ defer stmt.Close()
+
+ now := time.Now()
+ names := make([]string, 0, len(tools))
+ for _, t := range tools {
+ settings := t.Settings
+ if settings == nil {
+ settings = json.RawMessage("{}")
+ }
+ metadata := t.Metadata
+ if metadata == nil {
+ metadata = json.RawMessage("{}")
+ }
+ _, err := stmt.ExecContext(ctx,
+ t.Name, t.DisplayName, t.Description, t.Category,
+ t.Enabled, []byte(settings), jsonStringArray(t.Requires), []byte(metadata), now, now,
+ )
+ if err != nil {
+ return fmt.Errorf("seed tool %s: %w", t.Name, err)
+ }
+ names = append(names, t.Name)
+ }
+
+ // Reconcile: remove stale entries not in the current seed list.
+ // SQLite has no ANY($1) — build NOT IN (?, ?, ...) instead.
+ if len(names) > 0 {
+ placeholders := strings.Repeat("?,", len(names))
+ placeholders = placeholders[:len(placeholders)-1]
+ delArgs := make([]any, len(names))
+ for i, n := range names {
+ delArgs[i] = n
+ }
+ if _, err := tx.ExecContext(ctx,
+ `DELETE FROM builtin_tools WHERE name NOT IN (`+placeholders+`)`, delArgs...); err != nil {
+ return fmt.Errorf("reconcile stale builtin tools: %w", err)
+ }
+ } else {
+ if _, err := tx.ExecContext(ctx, `DELETE FROM builtin_tools`); err != nil {
+ return fmt.Errorf("reconcile stale builtin tools: %w", err)
+ }
+ }
+
+ return tx.Commit()
+}
+
+func (s *SQLiteBuiltinToolStore) scanTool(row *sql.Row) (*store.BuiltinToolDef, error) {
+ var def store.BuiltinToolDef
+ var settings []byte
+ var requires []byte
+ var metadata []byte
+ createdAt, updatedAt := scanTimePair()
+
+ err := row.Scan(
+ &def.Name, &def.DisplayName, &def.Description, &def.Category,
+ &def.Enabled, &settings, &requires, &metadata, createdAt, updatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ def.CreatedAt = createdAt.Time
+ def.UpdatedAt = updatedAt.Time
+
+ if settings != nil {
+ def.Settings = json.RawMessage(settings)
+ }
+ if metadata != nil {
+ def.Metadata = json.RawMessage(metadata)
+ }
+ scanJSONStringArray(requires, &def.Requires)
+
+ return &def, nil
+}
+
+func (s *SQLiteBuiltinToolStore) scanTools(rows *sql.Rows) ([]store.BuiltinToolDef, error) {
+ defer rows.Close()
+ var result []store.BuiltinToolDef
+ for rows.Next() {
+ var def store.BuiltinToolDef
+ var settings []byte
+ var requires []byte
+ var metadata []byte
+ createdAt, updatedAt := scanTimePair()
+
+ if err := rows.Scan(
+ &def.Name, &def.DisplayName, &def.Description, &def.Category,
+ &def.Enabled, &settings, &requires, &metadata, createdAt, updatedAt,
+ ); err != nil {
+ continue
+ }
+ def.CreatedAt = createdAt.Time
+ def.UpdatedAt = updatedAt.Time
+
+ if settings != nil {
+ def.Settings = json.RawMessage(settings)
+ }
+ if metadata != nil {
+ def.Metadata = json.RawMessage(metadata)
+ }
+ scanJSONStringArray(requires, &def.Requires)
+
+ result = append(result, def)
+ }
+ return result, rows.Err()
+}
diff --git a/internal/store/sqlitestore/channel_instances.go b/internal/store/sqlitestore/channel_instances.go
new file mode 100644
index 00000000..dc88edce
--- /dev/null
+++ b/internal/store/sqlitestore/channel_instances.go
@@ -0,0 +1,365 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "maps"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/crypto"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteChannelInstanceStore implements store.ChannelInstanceStore backed by SQLite.
+type SQLiteChannelInstanceStore struct {
+ db *sql.DB
+ encKey string
+}
+
+func NewSQLiteChannelInstanceStore(db *sql.DB, encryptionKey string) *SQLiteChannelInstanceStore {
+ return &SQLiteChannelInstanceStore{db: db, encKey: encryptionKey}
+}
+
+const channelInstanceSelectCols = `id, name, display_name, channel_type, agent_id,
+ credentials, config, enabled, created_by, created_at, updated_at, tenant_id`
+
+func (s *SQLiteChannelInstanceStore) Create(ctx context.Context, inst *store.ChannelInstanceData) error {
+ if err := store.ValidateUserID(inst.CreatedBy); err != nil {
+ return err
+ }
+ if inst.ID == uuid.Nil {
+ inst.ID = store.GenNewID()
+ }
+
+ var credsBytes []byte
+ if len(inst.Credentials) > 0 && s.encKey != "" {
+ encrypted, err := crypto.Encrypt(string(inst.Credentials), s.encKey)
+ if err != nil {
+ return fmt.Errorf("encrypt credentials: %w", err)
+ }
+ credsBytes = []byte(encrypted)
+ } else {
+ credsBytes = inst.Credentials
+ }
+
+ now := time.Now()
+ inst.CreatedAt = now
+ inst.UpdatedAt = now
+ tid := tenantIDForInsert(ctx)
+
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO channel_instances (id, name, display_name, channel_type, agent_id,
+ credentials, config, enabled, created_by, created_at, updated_at, tenant_id)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
+ inst.ID, inst.Name, inst.DisplayName, inst.ChannelType, inst.AgentID,
+ credsBytes, jsonOrEmpty(inst.Config),
+ inst.Enabled, inst.CreatedBy, now, now, tid,
+ )
+ return err
+}
+
+func (s *SQLiteChannelInstanceStore) Get(ctx context.Context, id uuid.UUID) (*store.ChannelInstanceData, error) {
+ if store.IsCrossTenant(ctx) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = ?`, id)
+ return s.scanInstance(row)
+ }
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ args := append([]any{id}, tArgs...)
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = ?`+tClause, args...)
+ return s.scanInstance(row)
+}
+
+func (s *SQLiteChannelInstanceStore) GetByName(ctx context.Context, name string) (*store.ChannelInstanceData, error) {
+ if store.IsCrossTenant(ctx) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = ?`, name)
+ return s.scanInstance(row)
+ }
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ args := append([]any{name}, tArgs...)
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = ?`+tClause, args...)
+ return s.scanInstance(row)
+}
+
+func (s *SQLiteChannelInstanceStore) scanInstance(row *sql.Row) (*store.ChannelInstanceData, error) {
+ var inst store.ChannelInstanceData
+ var displayName *string
+ var creds []byte
+ var config *[]byte
+ createdAt, updatedAt := scanTimePair()
+
+ err := row.Scan(
+ &inst.ID, &inst.Name, &displayName, &inst.ChannelType, &inst.AgentID,
+ &creds, &config,
+ &inst.Enabled, &inst.CreatedBy, createdAt, updatedAt, &inst.TenantID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ inst.CreatedAt = createdAt.Time
+ inst.UpdatedAt = updatedAt.Time
+
+ inst.DisplayName = derefStr(displayName)
+ if config != nil {
+ inst.Config = *config
+ }
+
+ if len(creds) > 0 && s.encKey != "" {
+ decrypted, err := crypto.Decrypt(string(creds), s.encKey)
+ if err != nil {
+ slog.Warn("channel_instances: failed to decrypt credentials", "name", inst.Name, "error", err)
+ } else {
+ inst.Credentials = []byte(decrypted)
+ }
+ } else {
+ inst.Credentials = creds
+ }
+
+ return &inst, nil
+}
+
+func (s *SQLiteChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelInstanceData, error) {
+ defer rows.Close()
+ var result []store.ChannelInstanceData
+ for rows.Next() {
+ var inst store.ChannelInstanceData
+ var displayName *string
+ var creds []byte
+ var config *[]byte
+ createdAt, updatedAt := scanTimePair()
+
+ if err := rows.Scan(
+ &inst.ID, &inst.Name, &displayName, &inst.ChannelType, &inst.AgentID,
+ &creds, &config,
+ &inst.Enabled, &inst.CreatedBy, createdAt, updatedAt, &inst.TenantID,
+ ); err != nil {
+ continue
+ }
+ inst.CreatedAt = createdAt.Time
+ inst.UpdatedAt = updatedAt.Time
+
+ inst.DisplayName = derefStr(displayName)
+ if config != nil {
+ inst.Config = *config
+ }
+ if len(creds) > 0 && s.encKey != "" {
+ if decrypted, err := crypto.Decrypt(string(creds), s.encKey); err == nil {
+ inst.Credentials = []byte(decrypted)
+ }
+ } else {
+ inst.Credentials = creds
+ }
+
+ result = append(result, inst)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
+ if credsVal, ok := updates["credentials"]; ok && credsVal != nil {
+ var newCreds map[string]any
+ switch v := credsVal.(type) {
+ case map[string]any:
+ newCreds = v
+ default:
+ var raw []byte
+ switch vv := v.(type) {
+ case []byte:
+ raw = vv
+ case string:
+ raw = []byte(vv)
+ default:
+ if b, err := json.Marshal(v); err == nil {
+ raw = b
+ }
+ }
+ if len(raw) > 0 {
+ _ = json.Unmarshal(raw, &newCreds)
+ }
+ }
+
+ if len(newCreds) > 0 {
+ existing, err := s.loadExistingCreds(ctx, id)
+ if err != nil {
+ return fmt.Errorf("load existing credentials for merge: %w", err)
+ }
+ maps.Copy(existing, newCreds)
+ newCreds = existing
+ }
+
+ var credsBytes []byte
+ if len(newCreds) > 0 {
+ credsBytes, _ = json.Marshal(newCreds)
+ }
+ if len(credsBytes) > 0 && s.encKey != "" {
+ encrypted, err := crypto.Encrypt(string(credsBytes), s.encKey)
+ if err != nil {
+ return fmt.Errorf("encrypt credentials: %w", err)
+ }
+ credsBytes = []byte(encrypted)
+ }
+ updates["credentials"] = credsBytes
+ }
+ updates["updated_at"] = time.Now()
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "channel_instances", id, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "channel_instances", updates, id, tid)
+}
+
+func (s *SQLiteChannelInstanceStore) loadExistingCreds(ctx context.Context, id uuid.UUID) (map[string]any, error) {
+ var raw []byte
+ err := s.db.QueryRowContext(ctx, "SELECT credentials FROM channel_instances WHERE id = ?", id).Scan(&raw)
+ if err != nil || len(raw) == 0 {
+ return make(map[string]any), nil
+ }
+ if s.encKey != "" {
+ if dec, err := crypto.Decrypt(string(raw), s.encKey); err == nil {
+ raw = []byte(dec)
+ }
+ }
+ var m map[string]any
+ if err := json.Unmarshal(raw, &m); err != nil {
+ return make(map[string]any), nil
+ }
+ return m, nil
+}
+
+func (s *SQLiteChannelInstanceStore) Delete(ctx context.Context, id uuid.UUID) error {
+ if store.IsCrossTenant(ctx) {
+ _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = ?", id)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = ? AND tenant_id = ?", id, tid)
+ return err
+}
+
+func (s *SQLiteChannelInstanceStore) ListEnabled(ctx context.Context) ([]store.ChannelInstanceData, error) {
+ query := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances WHERE enabled = 1`
+ var qArgs []any
+ if !store.IsCrossTenant(ctx) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ query += tClause
+ qArgs = tArgs
+ }
+ query += ` ORDER BY name`
+ rows, err := s.db.QueryContext(ctx, query, qArgs...)
+ if err != nil {
+ return nil, err
+ }
+ return s.scanInstances(rows)
+}
+
+func (s *SQLiteChannelInstanceStore) ListAll(ctx context.Context) ([]store.ChannelInstanceData, error) {
+ query := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances WHERE 1=1`
+ var qArgs []any
+ if !store.IsCrossTenant(ctx) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ query += tClause
+ qArgs = tArgs
+ }
+ query += ` ORDER BY name`
+ rows, err := s.db.QueryContext(ctx, query, qArgs...)
+ if err != nil {
+ return nil, err
+ }
+ return s.scanInstances(rows)
+}
+
+func (s *SQLiteChannelInstanceStore) ListAllInstances(ctx context.Context) ([]store.ChannelInstanceData, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+channelInstanceSelectCols+` FROM channel_instances ORDER BY name`)
+ if err != nil {
+ return nil, err
+ }
+ return s.scanInstances(rows)
+}
+
+func (s *SQLiteChannelInstanceStore) ListAllEnabled(ctx context.Context) ([]store.ChannelInstanceData, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE enabled = 1 ORDER BY name`)
+ if err != nil {
+ return nil, err
+ }
+ return s.scanInstances(rows)
+}
+
+func buildChannelInstanceWhereSQLite(ctx context.Context, opts store.ChannelInstanceListOpts) (string, []any) {
+ var conditions []string
+ var args []any
+
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID != uuid.Nil {
+ conditions = append(conditions, "tenant_id = ?")
+ args = append(args, tenantID)
+ }
+ }
+
+ if opts.Search != "" {
+ escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search)
+ conditions = append(conditions, "(name LIKE ? ESCAPE '\\' OR display_name LIKE ? ESCAPE '\\' OR channel_type LIKE ? ESCAPE '\\')")
+ p := "%" + escaped + "%"
+ args = append(args, p, p, p)
+ }
+
+ where := ""
+ if len(conditions) > 0 {
+ where = " WHERE " + strings.Join(conditions, " AND ")
+ }
+ return where, args
+}
+
+func (s *SQLiteChannelInstanceStore) ListPaged(ctx context.Context, opts store.ChannelInstanceListOpts) ([]store.ChannelInstanceData, error) {
+ where, args := buildChannelInstanceWhereSQLite(ctx, opts)
+ limit := opts.Limit
+ if limit <= 0 {
+ limit = 50
+ }
+ q := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances` + where +
+ fmt.Sprintf(" ORDER BY name LIMIT %d OFFSET %d", limit, opts.Offset)
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ return s.scanInstances(rows)
+}
+
+func (s *SQLiteChannelInstanceStore) CountInstances(ctx context.Context, opts store.ChannelInstanceListOpts) (int, error) {
+ where, args := buildChannelInstanceWhereSQLite(ctx, opts)
+ var count int
+ err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_instances"+where, args...).Scan(&count)
+ return count, err
+}
diff --git a/internal/store/sqlitestore/config-secrets.go b/internal/store/sqlitestore/config-secrets.go
new file mode 100644
index 00000000..c3e014e1
--- /dev/null
+++ b/internal/store/sqlitestore/config-secrets.go
@@ -0,0 +1,101 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/crypto"
+)
+
+// SQLiteConfigSecretsStore implements store.ConfigSecretsStore backed by SQLite.
+type SQLiteConfigSecretsStore struct {
+ db *sql.DB
+ encKey string
+}
+
+func NewSQLiteConfigSecretsStore(db *sql.DB, encryptionKey string) *SQLiteConfigSecretsStore {
+ return &SQLiteConfigSecretsStore{db: db, encKey: encryptionKey}
+}
+
+func (s *SQLiteConfigSecretsStore) Get(ctx context.Context, key string) (string, error) {
+ tid := tenantIDForInsert(ctx)
+ var value []byte
+ err := s.db.QueryRowContext(ctx,
+ `SELECT value FROM config_secrets WHERE key = ? AND tenant_id = ?`, key, tid).Scan(&value)
+ if err != nil {
+ return "", err
+ }
+
+ if len(value) > 0 && s.encKey != "" {
+ decrypted, err := crypto.Decrypt(string(value), s.encKey)
+ if err != nil {
+ return "", fmt.Errorf("decrypt secret %q: %w", key, err)
+ }
+ return decrypted, nil
+ }
+ return string(value), nil
+}
+
+func (s *SQLiteConfigSecretsStore) Set(ctx context.Context, key, value string) error {
+ var stored []byte
+ if s.encKey != "" {
+ encrypted, err := crypto.Encrypt(value, s.encKey)
+ if err != nil {
+ return fmt.Errorf("encrypt secret %q: %w", key, err)
+ }
+ stored = []byte(encrypted)
+ } else {
+ stored = []byte(value)
+ }
+
+ tid := tenantIDForInsert(ctx)
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO config_secrets (key, value, updated_at, tenant_id) VALUES (?, ?, ?, ?)
+ ON CONFLICT (key, tenant_id) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
+ key, stored, time.Now(), tid,
+ )
+ return err
+}
+
+func (s *SQLiteConfigSecretsStore) Delete(ctx context.Context, key string) error {
+ tid := tenantIDForInsert(ctx)
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM config_secrets WHERE key = ? AND tenant_id = ?`, key, tid)
+ return err
+}
+
+func (s *SQLiteConfigSecretsStore) GetAll(ctx context.Context) (map[string]string, error) {
+ tid := tenantIDForInsert(ctx)
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT key, value FROM config_secrets WHERE tenant_id = ?`, tid)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := make(map[string]string)
+ for rows.Next() {
+ var key string
+ var value []byte
+ if err := rows.Scan(&key, &value); err != nil {
+ continue
+ }
+
+ if len(value) > 0 && s.encKey != "" {
+ decrypted, err := crypto.Decrypt(string(value), s.encKey)
+ if err != nil {
+ slog.Warn("config_secrets: failed to decrypt", "key", key, "error", err)
+ continue
+ }
+ result[key] = decrypted
+ } else {
+ result[key] = string(value)
+ }
+ }
+ return result, rows.Err()
+}
diff --git a/internal/store/sqlitestore/config_permissions.go b/internal/store/sqlitestore/config_permissions.go
new file mode 100644
index 00000000..3841f982
--- /dev/null
+++ b/internal/store/sqlitestore/config_permissions.go
@@ -0,0 +1,279 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+const permCacheTTL = 60 * time.Second
+
+type permRow struct {
+ Scope string
+ ConfigType string
+ Permission string
+}
+
+type permCacheEntry struct {
+ rows []permRow
+ fetched time.Time
+}
+
+type fwCacheEntry struct {
+ rows []store.ConfigPermission
+ fetched time.Time
+}
+
+// SQLiteConfigPermissionStore implements store.ConfigPermissionStore backed by SQLite.
+// Includes a TTL cache for CheckPermission to avoid per-request DB queries.
+type SQLiteConfigPermissionStore struct {
+ db *sql.DB
+ mu sync.RWMutex
+ cache map[string]permCacheEntry // key: "tenantID:agentID:userID"
+ fwMu sync.RWMutex
+ fwCache map[string]fwCacheEntry // key: "tenantID:agentID:scope"
+}
+
+func NewSQLiteConfigPermissionStore(db *sql.DB) *SQLiteConfigPermissionStore {
+ return &SQLiteConfigPermissionStore{
+ db: db,
+ cache: make(map[string]permCacheEntry),
+ fwCache: make(map[string]fwCacheEntry),
+ }
+}
+
+// InvalidateCache clears all cached permission entries.
+func (s *SQLiteConfigPermissionStore) InvalidateCache() {
+ s.mu.Lock()
+ s.cache = make(map[string]permCacheEntry)
+ s.mu.Unlock()
+
+ s.fwMu.Lock()
+ s.fwCache = make(map[string]fwCacheEntry)
+ s.fwMu.Unlock()
+}
+
+func (s *SQLiteConfigPermissionStore) CheckPermission(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) (bool, error) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ cacheKey := tid.String() + ":" + agentID.String() + ":" + userID
+
+ s.mu.RLock()
+ if entry, ok := s.cache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL {
+ s.mu.RUnlock()
+ return evalPermRows(entry.rows, scope, configType), nil
+ }
+ s.mu.RUnlock()
+
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return false, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT scope, config_type, permission FROM agent_config_permissions
+ WHERE agent_id = ? AND user_id = ?`+tClause,
+ append([]any{agentID, userID}, tArgs...)...,
+ )
+ if err != nil {
+ return false, err
+ }
+ defer rows.Close()
+
+ var permRows []permRow
+ for rows.Next() {
+ var r permRow
+ if err := rows.Scan(&r.Scope, &r.ConfigType, &r.Permission); err != nil {
+ return false, err
+ }
+ permRows = append(permRows, r)
+ }
+ if err := rows.Err(); err != nil {
+ return false, err
+ }
+
+ s.mu.Lock()
+ s.cache[cacheKey] = permCacheEntry{rows: permRows, fetched: time.Now()}
+ s.mu.Unlock()
+
+ return evalPermRows(permRows, scope, configType), nil
+}
+
+func (s *SQLiteConfigPermissionStore) Grant(ctx context.Context, perm *store.ConfigPermission) error {
+ meta := perm.Metadata
+ if meta == nil {
+ meta = json.RawMessage("{}")
+ }
+ now := time.Now()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO agent_config_permissions (agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at, tenant_id)
+ VALUES (?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT (agent_id, scope, config_type, user_id) DO UPDATE SET
+ permission = excluded.permission,
+ granted_by = excluded.granted_by,
+ metadata = excluded.metadata,
+ updated_at = excluded.updated_at`,
+ perm.AgentID, perm.Scope, perm.ConfigType, perm.UserID, perm.Permission, perm.GrantedBy, meta, now, now, tenantIDForInsert(ctx),
+ )
+ if err == nil {
+ s.InvalidateCache()
+ }
+ return err
+}
+
+func (s *SQLiteConfigPermissionStore) Revoke(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) error {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ `DELETE FROM agent_config_permissions WHERE agent_id = ? AND scope = ? AND config_type = ? AND user_id = ?`+tClause,
+ append([]any{agentID, scope, configType, userID}, tArgs...)...,
+ )
+ if err == nil {
+ s.InvalidateCache()
+ }
+ return err
+}
+
+func (s *SQLiteConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, configType, scope string) ([]store.ConfigPermission, error) {
+ query := `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
+ FROM agent_config_permissions WHERE agent_id = ?`
+ args := []any{agentID}
+
+ if configType != "" {
+ query += " AND config_type = ?"
+ args = append(args, configType)
+ }
+ if scope != "" {
+ query += " AND scope = ?"
+ args = append(args, scope)
+ }
+
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if tClause != "" {
+ query += tClause
+ args = append(args, tArgs...)
+ }
+ query += " ORDER BY created_at"
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ return scanConfigPermissions(rows)
+}
+
+func (s *SQLiteConfigPermissionStore) ListFileWriters(ctx context.Context, agentID uuid.UUID, scope string) ([]store.ConfigPermission, error) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ cacheKey := tid.String() + ":" + agentID.String() + ":" + scope
+
+ s.fwMu.RLock()
+ if entry, ok := s.fwCache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL {
+ s.fwMu.RUnlock()
+ return entry.rows, nil
+ }
+ s.fwMu.RUnlock()
+
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
+ FROM agent_config_permissions
+ WHERE agent_id = ? AND config_type = 'file_writer' AND scope = ? AND permission = 'allow'`+tClause+`
+ ORDER BY created_at`,
+ append([]any{agentID, scope}, tArgs...)...,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ perms, err := scanConfigPermissions(rows)
+ if err != nil {
+ return nil, err
+ }
+
+ s.fwMu.Lock()
+ s.fwCache[cacheKey] = fwCacheEntry{rows: perms, fetched: time.Now()}
+ s.fwMu.Unlock()
+
+ return perms, nil
+}
+
+func scanConfigPermissions(rows *sql.Rows) ([]store.ConfigPermission, error) {
+ var perms []store.ConfigPermission
+ for rows.Next() {
+ var p store.ConfigPermission
+ var metadata []byte
+ createdAt, updatedAt := scanTimePair()
+ if err := rows.Scan(
+ &p.ID, &p.AgentID, &p.Scope, &p.ConfigType, &p.UserID, &p.Permission, &p.GrantedBy, &metadata, createdAt, updatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ p.CreatedAt = createdAt.Time
+ p.UpdatedAt = updatedAt.Time
+ p.Metadata = metadata
+ perms = append(perms, p)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return perms, nil
+}
+
+// evalPermRows evaluates cached permission rows against scope and configType (deny-first).
+func evalPermRows(rows []permRow, scope, configType string) bool {
+ var hasDeny, hasAllow bool
+ for _, r := range rows {
+ if !matchWildcard(r.Scope, scope) || !matchWildcard(r.ConfigType, configType) {
+ continue
+ }
+ switch r.Permission {
+ case "deny":
+ hasDeny = true
+ case "allow":
+ hasAllow = true
+ }
+ }
+ if hasDeny {
+ return false
+ }
+ return hasAllow
+}
+
+// matchWildcard performs simple wildcard matching for scope/config_type.
+func matchWildcard(pattern, value string) bool {
+ if pattern == "*" {
+ return true
+ }
+ if strings.HasSuffix(pattern, ":*") {
+ prefix := strings.TrimSuffix(pattern, "*")
+ return strings.HasPrefix(value, prefix) || value == strings.TrimSuffix(prefix, ":")
+ }
+ return pattern == value
+}
+
+// ensure interface is satisfied at compile time
+var _ store.ConfigPermissionStore = (*SQLiteConfigPermissionStore)(nil)
diff --git a/internal/store/sqlitestore/contacts.go b/internal/store/sqlitestore/contacts.go
new file mode 100644
index 00000000..9d872d86
--- /dev/null
+++ b/internal/store/sqlitestore/contacts.go
@@ -0,0 +1,255 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteContactStore implements store.ContactStore backed by SQLite.
+type SQLiteContactStore struct {
+ db *sql.DB
+}
+
+func NewSQLiteContactStore(db *sql.DB) *SQLiteContactStore {
+ return &SQLiteContactStore{db: db}
+}
+
+func (s *SQLiteContactStore) UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+ // SQLite UPSERT: ON CONFLICT ... DO UPDATE SET
+ // NULLIF emulated with CASE WHEN
+ _, err := s.db.ExecContext(ctx, `
+ INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, tenant_id)
+ VALUES (?, NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), ?)
+ ON CONFLICT (tenant_id, channel_type, sender_id) DO UPDATE SET
+ display_name = COALESCE(NULLIF(excluded.display_name,''), channel_contacts.display_name),
+ username = COALESCE(NULLIF(excluded.username,''), channel_contacts.username),
+ user_id = COALESCE(NULLIF(excluded.user_id,''), channel_contacts.user_id),
+ channel_instance = COALESCE(NULLIF(excluded.channel_instance,''), channel_contacts.channel_instance),
+ peer_kind = COALESCE(NULLIF(excluded.peer_kind,''), channel_contacts.peer_kind),
+ last_seen_at = CURRENT_TIMESTAMP`,
+ channelType,
+ channelInstance, "",
+ senderID,
+ userID, "",
+ displayName, "",
+ username, "",
+ peerKind, "",
+ tenantID,
+ )
+ return err
+}
+
+func contactWhereSQLite(ctx context.Context, opts store.ContactListOpts) (string, []any) {
+ var conditions []string
+ var args []any
+
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID != uuid.Nil {
+ conditions = append(conditions, "tenant_id = ?")
+ args = append(args, tenantID)
+ }
+ }
+
+ if opts.ChannelType != "" {
+ conditions = append(conditions, "channel_type = ?")
+ args = append(args, opts.ChannelType)
+ }
+ if opts.PeerKind != "" {
+ conditions = append(conditions, "peer_kind = ?")
+ args = append(args, opts.PeerKind)
+ }
+ if opts.Search != "" {
+ escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search)
+ pattern := escaped + "%"
+ conditions = append(conditions, "(display_name LIKE ? ESCAPE '\\' OR username LIKE ? ESCAPE '\\' OR sender_id LIKE ? ESCAPE '\\')")
+ args = append(args, pattern, pattern, pattern)
+ }
+
+ where := ""
+ if len(conditions) > 0 {
+ where = " WHERE " + strings.Join(conditions, " AND ")
+ }
+ return where, args
+}
+
+const contactSelectCols = `id, channel_type, channel_instance, sender_id, user_id,
+ display_name, username, avatar_url, peer_kind, merged_id,
+ first_seen_at, last_seen_at`
+
+func scanContact(rows *sql.Rows) (store.ChannelContact, error) {
+ var c store.ChannelContact
+ err := rows.Scan(
+ &c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID,
+ &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID,
+ &c.FirstSeenAt, &c.LastSeenAt,
+ )
+ return c, err
+}
+
+func (s *SQLiteContactStore) ListContacts(ctx context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) {
+ where, args := contactWhereSQLite(ctx, opts)
+
+ limit := opts.Limit
+ if limit <= 0 {
+ limit = 50
+ }
+ query := `SELECT ` + contactSelectCols + ` FROM channel_contacts` + where +
+ fmt.Sprintf(" ORDER BY last_seen_at DESC LIMIT %d", limit)
+ if opts.Offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", opts.Offset)
+ }
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var contacts []store.ChannelContact
+ for rows.Next() {
+ c, err := scanContact(rows)
+ if err != nil {
+ return nil, err
+ }
+ contacts = append(contacts, c)
+ }
+ return contacts, rows.Err()
+}
+
+func (s *SQLiteContactStore) CountContacts(ctx context.Context, opts store.ContactListOpts) (int, error) {
+ where, args := contactWhereSQLite(ctx, opts)
+ var count int
+ err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_contacts"+where, args...).Scan(&count)
+ return count, err
+}
+
+func (s *SQLiteContactStore) GetContactsBySenderIDs(ctx context.Context, senderIDs []string) (map[string]store.ChannelContact, error) {
+ if len(senderIDs) == 0 {
+ return map[string]store.ChannelContact{}, nil
+ }
+
+ placeholders := make([]string, len(senderIDs))
+ args := make([]any, len(senderIDs))
+ for i, id := range senderIDs {
+ placeholders[i] = "?"
+ args[i] = id
+ }
+
+ // SQLite has no DISTINCT ON; emulate with GROUP BY + MAX rowid trick via subquery
+ query := `SELECT id, channel_type, channel_instance, sender_id, user_id,
+ display_name, username, avatar_url, peer_kind, merged_id,
+ first_seen_at, last_seen_at
+ FROM channel_contacts
+ WHERE sender_id IN (` + strings.Join(placeholders, ",") + `)
+ GROUP BY sender_id
+ ORDER BY sender_id, last_seen_at DESC`
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := make(map[string]store.ChannelContact, len(senderIDs))
+ for rows.Next() {
+ c, err := scanContact(rows)
+ if err != nil {
+ return nil, err
+ }
+ result[c.SenderID] = c
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteContactStore) GetContactByID(ctx context.Context, id uuid.UUID) (*store.ChannelContact, error) {
+ tid := store.TenantIDFromContext(ctx)
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+contactSelectCols+`
+ FROM channel_contacts WHERE id = ? AND tenant_id = ?`, id, tid)
+ var c store.ChannelContact
+ if err := row.Scan(
+ &c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID,
+ &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID,
+ &c.FirstSeenAt, &c.LastSeenAt,
+ ); err != nil {
+ return nil, err
+ }
+ return &c, nil
+}
+
+func (s *SQLiteContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID, tenantUserID uuid.UUID) error {
+ if len(contactIDs) == 0 {
+ return nil
+ }
+ tid := store.TenantIDFromContext(ctx)
+
+ placeholders := make([]string, len(contactIDs))
+ args := make([]any, len(contactIDs))
+ for i, id := range contactIDs {
+ placeholders[i] = "?"
+ args[i] = id
+ }
+ args = append(args, tenantUserID, tid)
+ q := fmt.Sprintf(
+ "UPDATE channel_contacts SET merged_id = ? WHERE id IN (%s) AND tenant_id = ?",
+ strings.Join(placeholders, ","),
+ )
+ _, err := s.db.ExecContext(ctx, q, args...)
+ return err
+}
+
+func (s *SQLiteContactStore) UnmergeContacts(ctx context.Context, contactIDs []uuid.UUID) error {
+ if len(contactIDs) == 0 {
+ return nil
+ }
+ tid := store.TenantIDFromContext(ctx)
+
+ placeholders := make([]string, len(contactIDs))
+ args := make([]any, len(contactIDs))
+ for i, id := range contactIDs {
+ placeholders[i] = "?"
+ args[i] = id
+ }
+ args = append(args, tid)
+ q := fmt.Sprintf(
+ "UPDATE channel_contacts SET merged_id = NULL WHERE id IN (%s) AND tenant_id = ?",
+ strings.Join(placeholders, ","),
+ )
+ _, err := s.db.ExecContext(ctx, q, args...)
+ return err
+}
+
+func (s *SQLiteContactStore) GetContactsByMergedID(ctx context.Context, mergedID uuid.UUID) ([]store.ChannelContact, error) {
+ tid := store.TenantIDFromContext(ctx)
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+contactSelectCols+`
+ FROM channel_contacts WHERE merged_id = ? AND tenant_id = ?
+ ORDER BY last_seen_at DESC`, mergedID, tid)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var contacts []store.ChannelContact
+ for rows.Next() {
+ c, err := scanContact(rows)
+ if err != nil {
+ return nil, err
+ }
+ contacts = append(contacts, c)
+ }
+ return contacts, rows.Err()
+}
diff --git a/internal/store/sqlitestore/cron.go b/internal/store/sqlitestore/cron.go
new file mode 100644
index 00000000..3f959b4f
--- /dev/null
+++ b/internal/store/sqlitestore/cron.go
@@ -0,0 +1,249 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "sync"
+ "time"
+
+ "github.com/adhocore/gronx"
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/cron"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+const defaultCronCacheTTL = 2 * time.Minute
+
+// SQLiteCronStore implements store.CronStore backed by SQLite.
+type SQLiteCronStore struct {
+ db *sql.DB
+ mu sync.Mutex
+ baseCtx context.Context
+ cancelCtx context.CancelFunc
+ onJob func(job *store.CronJob) (*store.CronJobResult, error)
+ onEvent func(event store.CronEvent)
+ running bool
+ stop chan struct{}
+
+ jobCache []store.CronJob
+ cacheLoaded bool
+ cacheTime time.Time
+ cacheTTL time.Duration
+
+ retryCfg cron.RetryConfig
+ defaultTZ string
+}
+
+func NewSQLiteCronStore(db *sql.DB) *SQLiteCronStore {
+ return &SQLiteCronStore{db: db, cacheTTL: defaultCronCacheTTL, retryCfg: cron.DefaultRetryConfig()}
+}
+
+func (s *SQLiteCronStore) SetRetryConfig(cfg cron.RetryConfig) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.retryCfg = cfg
+}
+
+func (s *SQLiteCronStore) SetDefaultTimezone(tz string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.defaultTZ = tz
+}
+
+func (s *SQLiteCronStore) Start() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.running {
+ return nil
+ }
+ s.baseCtx, s.cancelCtx = context.WithCancel(context.Background())
+ s.stop = make(chan struct{})
+ s.running = true
+ s.recomputeStaleJobs()
+ go s.runLoop()
+ slog.Info("sqlite cron service started")
+ return nil
+}
+
+func (s *SQLiteCronStore) Stop() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if !s.running {
+ return
+ }
+ close(s.stop)
+ if s.cancelCtx != nil {
+ s.cancelCtx()
+ }
+ s.running = false
+}
+
+func (s *SQLiteCronStore) SetOnJob(handler func(job *store.CronJob) (*store.CronJobResult, error)) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.onJob = handler
+}
+
+func (s *SQLiteCronStore) SetOnEvent(handler func(event store.CronEvent)) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.onEvent = handler
+}
+
+func (s *SQLiteCronStore) emitEvent(event store.CronEvent) {
+ s.mu.Lock()
+ fn := s.onEvent
+ s.mu.Unlock()
+ if fn != nil {
+ fn(event)
+ }
+}
+
+// --- Scan helpers ---
+
+type cronRowScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanCronRow(row cronRowScanner) (*store.CronJob, error) {
+ var id uuid.UUID
+ var tenantID uuid.UUID
+ var agentID *uuid.UUID
+ var userID *string
+ var name, scheduleKind string
+ var enabled, deleteAfterRun bool
+ var cronExpr, tz, lastStatus, lastError *string
+ var runAt, nextRunAt, lastRunAt *time.Time
+ var intervalMS *int64
+ var payloadJSON []byte
+ var createdAt, updatedAt time.Time
+
+ err := row.Scan(&id, &tenantID, &agentID, &userID, &name, &enabled, &scheduleKind, &cronExpr, &runAt, &tz,
+ &intervalMS, &payloadJSON, &deleteAfterRun, &nextRunAt, &lastRunAt, &lastStatus, &lastError,
+ &createdAt, &updatedAt)
+ if err != nil {
+ return nil, err
+ }
+
+ var payload store.CronPayload
+ if len(payloadJSON) > 0 {
+ if err := json.Unmarshal(payloadJSON, &payload); err != nil {
+ return nil, fmt.Errorf("failed to parse cron job payload: %w", err)
+ }
+ }
+
+ job := &store.CronJob{
+ ID: id.String(),
+ TenantID: tenantID,
+ Name: name,
+ Enabled: enabled,
+ Schedule: store.CronSchedule{Kind: scheduleKind},
+ Payload: payload,
+ CreatedAtMS: createdAt.UnixMilli(),
+ UpdatedAtMS: updatedAt.UnixMilli(),
+ DeleteAfterRun: deleteAfterRun,
+ }
+
+ if agentID != nil {
+ job.AgentID = agentID.String()
+ }
+ if userID != nil {
+ job.UserID = *userID
+ }
+ if cronExpr != nil {
+ job.Schedule.Expr = *cronExpr
+ }
+ if runAt != nil {
+ ms := runAt.UnixMilli()
+ job.Schedule.AtMS = &ms
+ }
+ if intervalMS != nil {
+ job.Schedule.EveryMS = intervalMS
+ }
+ if tz != nil {
+ job.Schedule.TZ = *tz
+ }
+ if nextRunAt != nil {
+ ms := nextRunAt.UnixMilli()
+ job.State.NextRunAtMS = &ms
+ }
+ if lastRunAt != nil {
+ ms := lastRunAt.UnixMilli()
+ job.State.LastRunAtMS = &ms
+ }
+ if lastStatus != nil {
+ job.State.LastStatus = *lastStatus
+ }
+ if lastError != nil {
+ job.State.LastError = *lastError
+ }
+
+ return job, nil
+}
+
+// computeNextRun calculates the next run time for a schedule.
+func computeNextRun(schedule *store.CronSchedule, now time.Time, defaultTZ string) *time.Time {
+ switch schedule.Kind {
+ case "at":
+ if schedule.AtMS != nil {
+ t := time.UnixMilli(*schedule.AtMS)
+ if t.After(now) {
+ return &t
+ }
+ }
+ return nil
+ case "every":
+ if schedule.EveryMS != nil && *schedule.EveryMS > 0 {
+ t := now.Add(time.Duration(*schedule.EveryMS) * time.Millisecond)
+ return &t
+ }
+ return nil
+ case "cron":
+ if schedule.Expr == "" {
+ return nil
+ }
+ tz := schedule.TZ
+ if tz == "" {
+ tz = defaultTZ
+ }
+ evalTime := now
+ if tz != "" {
+ if loc, err := time.LoadLocation(tz); err == nil {
+ evalTime = now.In(loc)
+ }
+ }
+ nextTime, err := gronx.NextTickAfter(schedule.Expr, evalTime, false)
+ if err != nil {
+ return nil
+ }
+ utcNext := nextTime.UTC()
+ return &utcNext
+ default:
+ return nil
+ }
+}
+
+func (s *SQLiteCronStore) scanJob(ctx context.Context, id uuid.UUID) (*store.CronJob, error) {
+ q := `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
+ interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error,
+ created_at, updated_at FROM cron_jobs WHERE id = ?`
+ args := []any{id}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ q += " AND tenant_id = ?"
+ args = append(args, tid)
+ }
+
+ row := s.db.QueryRowContext(ctx, q, args...)
+ return scanCronRow(row)
+}
diff --git a/internal/store/sqlitestore/cron_crud.go b/internal/store/sqlitestore/cron_crud.go
new file mode 100644
index 00000000..3a3c3023
--- /dev/null
+++ b/internal/store/sqlitestore/cron_crud.go
@@ -0,0 +1,413 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/adhocore/gronx"
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+func (s *SQLiteCronStore) AddJob(ctx context.Context, name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) {
+ if schedule.TZ == "" && schedule.Kind == "cron" && s.defaultTZ != "" {
+ schedule.TZ = s.defaultTZ
+ }
+ if schedule.TZ != "" {
+ if _, err := time.LoadLocation(schedule.TZ); err != nil {
+ return nil, fmt.Errorf("invalid timezone: %s", schedule.TZ)
+ }
+ }
+
+ payload := store.CronPayload{
+ Kind: "agent_turn", Message: message, Deliver: deliver, Channel: channel, To: to,
+ }
+ payloadJSON, _ := json.Marshal(payload)
+
+ id := uuid.Must(uuid.NewV7())
+ now := time.Now()
+ scheduleKind := schedule.Kind
+ deleteAfterRun := schedule.Kind == "at"
+
+ var cronExpr, tz *string
+ var runAt *time.Time
+ if schedule.Expr != "" {
+ cronExpr = &schedule.Expr
+ }
+ if schedule.AtMS != nil {
+ t := time.UnixMilli(*schedule.AtMS)
+ runAt = &t
+ }
+ if schedule.TZ != "" {
+ tz = &schedule.TZ
+ }
+
+ var agentUUID *uuid.UUID
+ if agentID != "" {
+ if aid, err := uuid.Parse(agentID); err == nil {
+ agentUUID = &aid
+ }
+ }
+
+ var userIDPtr *string
+ if userID != "" {
+ userIDPtr = &userID
+ }
+
+ var intervalMS *int64
+ if schedule.EveryMS != nil {
+ intervalMS = schedule.EveryMS
+ }
+
+ nextRun := computeNextRun(&schedule, now, s.defaultTZ)
+
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO cron_jobs (id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
+ interval_ms, payload, delete_after_run, next_run_at, created_at, updated_at)
+ VALUES (?,?,?,?,?,1,?,?,?,?,?,?,?,?,?,?)`,
+ id, tenantIDForInsert(ctx), agentUUID, userIDPtr, name, scheduleKind, cronExpr, runAt, tz,
+ intervalMS, payloadJSON, deleteAfterRun, nextRun, now, now,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("create cron job: %w", err)
+ }
+
+ s.InvalidateCache()
+ job, _ := s.GetJob(ctx, id.String())
+ return job, nil
+}
+
+func (s *SQLiteCronStore) GetJob(ctx context.Context, jobID string) (*store.CronJob, bool) {
+ id, err := uuid.Parse(jobID)
+ if err != nil {
+ return nil, false
+ }
+ job, err := s.scanJob(ctx, id)
+ if err != nil {
+ return nil, false
+ }
+ return job, true
+}
+
+func (s *SQLiteCronStore) ListJobs(ctx context.Context, includeDisabled bool, agentID, userID string) []store.CronJob {
+ q := `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
+ interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error,
+ created_at, updated_at FROM cron_jobs WHERE 1=1`
+
+ var args []any
+
+ if !includeDisabled {
+ q += " AND enabled = ?"
+ args = append(args, true)
+ }
+ if agentID != "" {
+ if aid, err := uuid.Parse(agentID); err == nil {
+ q += " AND agent_id = ?"
+ args = append(args, aid)
+ }
+ }
+ if userID != "" {
+ q += " AND user_id = ?"
+ args = append(args, userID)
+ }
+
+ clause, targs, tErr := scopeClause(ctx)
+ if tErr != nil {
+ slog.Warn("cron.ListJobs: tenant context missing, returning empty (fail-closed)", "error", tErr)
+ return nil
+ }
+ if clause != "" {
+ q += clause
+ args = append(args, targs...)
+ }
+
+ q += " ORDER BY created_at DESC"
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+
+ var result []store.CronJob
+ for rows.Next() {
+ job, err := scanCronRow(rows)
+ if err != nil {
+ continue
+ }
+ result = append(result, *job)
+ }
+ if err := rows.Err(); err != nil {
+ slog.Warn("cron: list jobs iteration error", "error", err)
+ }
+ return result
+}
+
+func (s *SQLiteCronStore) RemoveJob(ctx context.Context, jobID string) error {
+ id, err := uuid.Parse(jobID)
+ if err != nil {
+ return fmt.Errorf("invalid job ID: %s", jobID)
+ }
+
+ q := "DELETE FROM cron_jobs WHERE id = ?"
+ args := []any{id}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ q += " AND tenant_id = ?"
+ args = append(args, tid)
+ }
+
+ res, err := s.db.ExecContext(ctx, q, args...)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return fmt.Errorf("job not found")
+ }
+ s.InvalidateCache()
+ return nil
+}
+
+func (s *SQLiteCronStore) EnableJob(ctx context.Context, jobID string, enabled bool) error {
+ id, err := uuid.Parse(jobID)
+ if err != nil {
+ return fmt.Errorf("invalid job ID: %s", jobID)
+ }
+
+ q := "UPDATE cron_jobs SET enabled = ?, updated_at = ? WHERE id = ?"
+ args := []any{enabled, time.Now(), id}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ q += " AND tenant_id = ?"
+ args = append(args, tid)
+ }
+
+ res, err := s.db.ExecContext(ctx, q, args...)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return fmt.Errorf("job not found")
+ }
+ s.InvalidateCache()
+ return nil
+}
+
+func (s *SQLiteCronStore) UpdateJob(ctx context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) {
+ id, err := uuid.Parse(jobID)
+ if err != nil {
+ return nil, fmt.Errorf("invalid job ID: %s", jobID)
+ }
+
+ updates := make(map[string]any)
+ if patch.Name != "" {
+ updates["name"] = patch.Name
+ }
+ if patch.Enabled != nil {
+ updates["enabled"] = *patch.Enabled
+ }
+
+ // Build tenant WHERE suffix for schedule reads.
+ tenantSuffix := ""
+ var tenantArg []any
+ if !store.IsCrossTenant(ctx) {
+ if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil {
+ tenantSuffix = " AND tenant_id = ?"
+ tenantArg = append(tenantArg, tid)
+ }
+ }
+
+ if patch.Schedule != nil {
+ var curKind string
+ var curExpr, curTZ *string
+ var curIntervalMS *int64
+ var curRunAt *time.Time
+ if err := s.db.QueryRowContext(ctx,
+ "SELECT schedule_kind, cron_expression, timezone, interval_ms, run_at FROM cron_jobs WHERE id = ?"+tenantSuffix,
+ append([]any{id}, tenantArg...)...,
+ ).Scan(&curKind, &curExpr, &curTZ, &curIntervalMS, &curRunAt); err != nil {
+ return nil, fmt.Errorf("failed to fetch current schedule for job %s: %w", jobID, err)
+ }
+
+ newKind := patch.Schedule.Kind
+ if newKind == "" {
+ newKind = curKind
+ }
+ updates["schedule_kind"] = newKind
+
+ switch newKind {
+ case "cron":
+ if patch.Schedule.Expr != "" {
+ updates["cron_expression"] = patch.Schedule.Expr
+ }
+ if patch.Schedule.TZ != "" {
+ if _, err := time.LoadLocation(patch.Schedule.TZ); err != nil {
+ return nil, fmt.Errorf("invalid timezone: %s", patch.Schedule.TZ)
+ }
+ updates["timezone"] = patch.Schedule.TZ
+ }
+ if curKind != "cron" {
+ updates["interval_ms"] = nil
+ updates["run_at"] = nil
+ }
+ case "every":
+ if patch.Schedule.EveryMS != nil {
+ updates["interval_ms"] = *patch.Schedule.EveryMS
+ }
+ if curKind != "every" {
+ updates["cron_expression"] = nil
+ updates["timezone"] = nil
+ updates["run_at"] = nil
+ }
+ case "at":
+ if patch.Schedule.AtMS != nil {
+ t := time.UnixMilli(*patch.Schedule.AtMS)
+ updates["run_at"] = t
+ }
+ if curKind != "at" {
+ updates["cron_expression"] = nil
+ updates["timezone"] = nil
+ updates["interval_ms"] = nil
+ }
+ }
+
+ merged := buildMergedSchedule(newKind, curKind, patch.Schedule, curExpr, curTZ, curIntervalMS, curRunAt)
+ if err := validateSchedule(&merged); err != nil {
+ return nil, err
+ }
+ next := computeNextRun(&merged, time.Now(), s.defaultTZ)
+ updates["next_run_at"] = next
+ }
+
+ if patch.DeleteAfterRun != nil {
+ updates["delete_after_run"] = *patch.DeleteAfterRun
+ }
+ if patch.AgentID != nil {
+ if *patch.AgentID == "" {
+ updates["agent_id"] = nil
+ } else if aid, parseErr := uuid.Parse(*patch.AgentID); parseErr == nil {
+ updates["agent_id"] = aid
+ }
+ }
+
+ needsPayloadUpdate := patch.Message != "" || patch.Deliver != nil || patch.Channel != nil || patch.To != nil || patch.WakeHeartbeat != nil
+ if needsPayloadUpdate {
+ var payloadJSON []byte
+ if scanErr := s.db.QueryRowContext(ctx, "SELECT payload FROM cron_jobs WHERE id = ?"+tenantSuffix, append([]any{id}, tenantArg...)...).Scan(&payloadJSON); scanErr == nil {
+ var p store.CronPayload
+ if len(payloadJSON) > 0 {
+ if err := json.Unmarshal(payloadJSON, &p); err != nil {
+ return nil, fmt.Errorf("failed to parse existing payload for job %s: %w", jobID, err)
+ }
+ }
+ if patch.Message != "" {
+ p.Message = patch.Message
+ }
+ if patch.Deliver != nil {
+ p.Deliver = *patch.Deliver
+ }
+ if patch.Channel != nil {
+ p.Channel = *patch.Channel
+ }
+ if patch.To != nil {
+ p.To = *patch.To
+ }
+ if patch.WakeHeartbeat != nil {
+ p.WakeHeartbeat = *patch.WakeHeartbeat
+ }
+ merged, err := json.Marshal(p)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal payload for job %s: %w", jobID, err)
+ }
+ updates["payload"] = merged
+ }
+ }
+
+ updates["updated_at"] = time.Now()
+
+ var execErr error
+ if store.IsCrossTenant(ctx) {
+ execErr = execMapUpdate(ctx, s.db, "cron_jobs", id, updates)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ execErr = fmt.Errorf("tenant_id required for update")
+ } else {
+ execErr = execMapUpdateWhereTenant(ctx, s.db, "cron_jobs", updates, id, tid)
+ }
+ }
+ if execErr != nil {
+ return nil, execErr
+ }
+
+ s.InvalidateCache()
+ job, _ := s.scanJob(ctx, id)
+ return job, nil
+}
+
+func buildMergedSchedule(newKind, curKind string, patch *store.CronSchedule, curExpr, curTZ *string, curIntervalMS *int64, curRunAt *time.Time) store.CronSchedule {
+ merged := store.CronSchedule{Kind: newKind}
+ switch newKind {
+ case "cron":
+ if patch.Expr != "" {
+ merged.Expr = patch.Expr
+ } else if curExpr != nil {
+ merged.Expr = *curExpr
+ }
+ if patch.TZ != "" {
+ merged.TZ = patch.TZ
+ } else if curTZ != nil && newKind == curKind {
+ merged.TZ = *curTZ
+ }
+ case "every":
+ if patch.EveryMS != nil {
+ merged.EveryMS = patch.EveryMS
+ } else if curIntervalMS != nil {
+ merged.EveryMS = curIntervalMS
+ }
+ case "at":
+ if patch.AtMS != nil {
+ merged.AtMS = patch.AtMS
+ } else if curRunAt != nil {
+ ms := curRunAt.UnixMilli()
+ merged.AtMS = &ms
+ }
+ }
+ return merged
+}
+
+func validateSchedule(s *store.CronSchedule) error {
+ switch s.Kind {
+ case "cron":
+ if s.Expr == "" {
+ return fmt.Errorf("cron schedule requires expr")
+ }
+ gx := gronx.New()
+ if !gx.IsValid(s.Expr) {
+ return fmt.Errorf("invalid cron expression: %s", s.Expr)
+ }
+ case "every":
+ if s.EveryMS == nil || *s.EveryMS <= 0 {
+ return fmt.Errorf("every schedule requires positive everyMs")
+ }
+ case "at":
+ if s.AtMS == nil {
+ return fmt.Errorf("at schedule requires atMs")
+ }
+ }
+ return nil
+}
diff --git a/internal/store/sqlitestore/cron_exec.go b/internal/store/sqlitestore/cron_exec.go
new file mode 100644
index 00000000..3afa808b
--- /dev/null
+++ b/internal/store/sqlitestore/cron_exec.go
@@ -0,0 +1,137 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+func (s *SQLiteCronStore) RunJob(ctx context.Context, jobID string, force bool) (bool, string, error) {
+ job, ok := s.GetJob(ctx, jobID)
+ if !ok {
+ return false, "", fmt.Errorf("job %s not found", jobID)
+ }
+
+ s.mu.Lock()
+ handler := s.onJob
+ s.mu.Unlock()
+
+ if handler == nil {
+ return false, "", fmt.Errorf("no job handler configured")
+ }
+
+ if id, parseErr := uuid.Parse(jobID); parseErr == nil {
+ if _, execErr := s.db.ExecContext(ctx, "UPDATE cron_jobs SET last_status = 'running', updated_at = ? WHERE id = ?", time.Now(), id); execErr != nil {
+ slog.Warn("cron: failed to mark job running", "job", jobID, "error", execErr)
+ }
+ }
+ s.InvalidateCache()
+
+ s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name, UserID: job.UserID})
+ s.executeOneJob(*job, handler)
+
+ s.mu.Lock()
+ s.cacheLoaded = false
+ s.mu.Unlock()
+ return true, "", nil
+}
+
+func (s *SQLiteCronStore) GetRunLog(ctx context.Context, jobID string, limit, offset int) ([]store.CronRunLogEntry, int) {
+ if limit <= 0 {
+ limit = 20
+ }
+ if offset < 0 {
+ offset = 0
+ }
+
+ const cols = "r.job_id, r.status, r.error, r.summary, r.ran_at, COALESCE(r.duration_ms, 0), COALESCE(r.input_tokens, 0), COALESCE(r.output_tokens, 0)"
+
+ // Tenant isolation via JOIN with cron_jobs.
+ var tenantJoin, tenantWhere string
+ var tenantArgs []any
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ tenantJoin = " JOIN cron_jobs j ON r.job_id = j.id"
+ tenantWhere = " AND j.tenant_id = ?"
+ tenantArgs = append(tenantArgs, tid)
+ }
+ }
+
+ var total int
+ var rows *sql.Rows
+ var err error
+
+ if jobID != "" {
+ id, parseErr := uuid.Parse(jobID)
+ if parseErr != nil {
+ return nil, 0
+ }
+ countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE r.job_id = ?%s", tenantJoin, tenantWhere)
+ countArgs := append(tenantArgs, id)
+ s.db.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
+
+ dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE r.job_id = ?%s ORDER BY r.ran_at DESC LIMIT ? OFFSET ?",
+ cols, tenantJoin, tenantWhere)
+ dataArgs := append(tenantArgs, id, limit, offset)
+ rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...)
+ } else {
+ countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE 1=1%s", tenantJoin, tenantWhere)
+ s.db.QueryRowContext(ctx, countQ, tenantArgs...).Scan(&total)
+
+ dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE 1=1%s ORDER BY r.ran_at DESC LIMIT ? OFFSET ?",
+ cols, tenantJoin, tenantWhere)
+ dataArgs := append(tenantArgs, limit, offset)
+ rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...)
+ }
+ if err != nil {
+ return nil, 0
+ }
+ defer rows.Close()
+
+ var result []store.CronRunLogEntry
+ for rows.Next() {
+ var jobUUID uuid.UUID
+ var status string
+ var errStr, summary *string
+ var ranAt time.Time
+ var durationMS int64
+ var inputTokens, outputTokens int
+ if err := rows.Scan(&jobUUID, &status, &errStr, &summary, &ranAt, &durationMS, &inputTokens, &outputTokens); err != nil {
+ continue
+ }
+ result = append(result, store.CronRunLogEntry{
+ Ts: ranAt.UnixMilli(),
+ JobID: jobUUID.String(),
+ Status: status,
+ Error: derefStr(errStr),
+ Summary: derefStr(summary),
+ DurationMS: durationMS,
+ InputTokens: inputTokens,
+ OutputTokens: outputTokens,
+ })
+ }
+ if rErr := rows.Err(); rErr != nil {
+ slog.Warn("cron: run log iteration error", "error", rErr)
+ }
+ return result, total
+}
+
+func (s *SQLiteCronStore) Status() map[string]any {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var count int64
+ s.db.QueryRow("SELECT COUNT(*) FROM cron_jobs WHERE enabled = 1").Scan(&count)
+ return map[string]any{
+ "enabled": s.running,
+ "jobs": count,
+ }
+}
diff --git a/internal/store/sqlitestore/cron_scheduler.go b/internal/store/sqlitestore/cron_scheduler.go
new file mode 100644
index 00000000..a3bd32eb
--- /dev/null
+++ b/internal/store/sqlitestore/cron_scheduler.go
@@ -0,0 +1,253 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "log/slog"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/cron"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+func (s *SQLiteCronStore) GetDueJobs(now time.Time) []store.CronJob {
+ s.mu.Lock()
+ if !s.cacheLoaded || time.Since(s.cacheTime) > s.cacheTTL {
+ s.refreshJobCache()
+ }
+ jobs := s.jobCache
+ s.mu.Unlock()
+
+ var due []store.CronJob
+ for i := range jobs {
+ if jobs[i].Enabled && jobs[i].State.NextRunAtMS != nil {
+ nextRun := time.UnixMilli(*jobs[i].State.NextRunAtMS)
+ if !nextRun.After(now) {
+ due = append(due, jobs[i])
+ }
+ }
+ }
+ return due
+}
+
+// refreshJobCache reloads all enabled jobs from DB. Must be called with mu held.
+func (s *SQLiteCronStore) refreshJobCache() {
+ rows, err := s.db.QueryContext(s.baseCtx,
+ `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
+ interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error,
+ created_at, updated_at FROM cron_jobs WHERE enabled = 1`)
+ if err != nil {
+ return
+ }
+ defer rows.Close()
+
+ s.jobCache = nil
+ for rows.Next() {
+ job, err := scanCronRow(rows)
+ if err != nil {
+ continue
+ }
+ s.jobCache = append(s.jobCache, *job)
+ }
+ if err := rows.Err(); err != nil {
+ slog.Warn("cron: cache refresh iteration error", "error", err)
+ }
+ s.cacheLoaded = true
+ s.cacheTime = time.Now()
+}
+
+// InvalidateCache forces a cache refresh on the next GetDueJobs call.
+func (s *SQLiteCronStore) InvalidateCache() {
+ s.mu.Lock()
+ s.cacheLoaded = false
+ s.mu.Unlock()
+}
+
+// recomputeStaleJobs fixes enabled jobs with next_run_at = NULL on startup.
+func (s *SQLiteCronStore) recomputeStaleJobs() {
+ rows, err := s.db.QueryContext(s.baseCtx,
+ `SELECT id, schedule_kind, cron_expression, run_at, timezone, interval_ms
+ FROM cron_jobs WHERE enabled = 1 AND next_run_at IS NULL`)
+ if err != nil {
+ slog.Warn("cron: failed to query stale jobs", "error", err)
+ return
+ }
+ defer rows.Close()
+
+ now := time.Now()
+ var fixed int
+ for rows.Next() {
+ var id uuid.UUID
+ var scheduleKind string
+ var cronExpr, tz *string
+ var runAt *time.Time
+ var intervalMS *int64
+
+ if err := rows.Scan(&id, &scheduleKind, &cronExpr, &runAt, &tz, &intervalMS); err != nil {
+ continue
+ }
+
+ schedule := store.CronSchedule{Kind: scheduleKind}
+ if cronExpr != nil {
+ schedule.Expr = *cronExpr
+ }
+ if runAt != nil {
+ ms := runAt.UnixMilli()
+ schedule.AtMS = &ms
+ }
+ if intervalMS != nil {
+ schedule.EveryMS = intervalMS
+ }
+ if tz != nil {
+ schedule.TZ = *tz
+ }
+
+ next := computeNextRun(&schedule, now, s.defaultTZ)
+ if next == nil {
+ if scheduleKind == "at" {
+ s.db.ExecContext(s.baseCtx, "UPDATE cron_jobs SET enabled = 0, updated_at = ? WHERE id = ?", now, id)
+ }
+ continue
+ }
+
+ s.db.ExecContext(s.baseCtx, "UPDATE cron_jobs SET next_run_at = ?, updated_at = ? WHERE id = ?", *next, now, id)
+ fixed++
+ }
+ if err := rows.Err(); err != nil {
+ slog.Warn("cron: recompute stale iteration error", "error", err)
+ }
+
+ if fixed > 0 {
+ slog.Info("cron: recomputed stale next_run_at on startup", "fixed", fixed)
+ }
+}
+
+func (s *SQLiteCronStore) runLoop() {
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-s.stop:
+ return
+ case <-ticker.C:
+ s.checkAndRunDueJobs()
+ }
+ }
+}
+
+func (s *SQLiteCronStore) checkAndRunDueJobs() {
+ dueJobs := s.GetDueJobs(time.Now())
+ if len(dueJobs) == 0 {
+ return
+ }
+
+ s.mu.Lock()
+ handler := s.onJob
+ s.mu.Unlock()
+
+ if handler == nil {
+ return
+ }
+
+ // Clear next_run for all due jobs first to prevent duplicate fires.
+ for _, job := range dueJobs {
+ if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
+ s.db.ExecContext(s.baseCtx, "UPDATE cron_jobs SET next_run_at = NULL WHERE id = ?", id)
+ }
+ }
+
+ // Execute jobs in parallel — scheduler enforces per-session serialization.
+ var wg sync.WaitGroup
+ for _, job := range dueJobs {
+ wg.Add(1)
+ go func(job store.CronJob) {
+ defer wg.Done()
+ s.executeOneJob(job, handler)
+ }(job)
+ }
+ wg.Wait()
+
+ s.mu.Lock()
+ s.cacheLoaded = false
+ s.mu.Unlock()
+}
+
+// executeOneJob runs a single cron job with retry, logs the result, and updates next_run_at.
+func (s *SQLiteCronStore) executeOneJob(job store.CronJob, handler func(job *store.CronJob) (*store.CronJobResult, error)) {
+ startTime := time.Now()
+
+ var lastResult *store.CronJobResult
+ resultStr, attempts, err := cron.ExecuteWithRetry(func() (string, error) {
+ r, e := handler(&job)
+ if e != nil {
+ return "", e
+ }
+ lastResult = r
+ if r != nil {
+ return r.Content, nil
+ }
+ return "", nil
+ }, s.retryCfg)
+
+ durationMS := time.Since(startTime).Milliseconds()
+
+ if attempts > 1 {
+ slog.Info("cron job retried", "id", job.ID, "attempts", attempts, "success", err == nil)
+ }
+
+ now := time.Now()
+ status := "ok"
+ var lastError *string
+ if err != nil {
+ status = "error"
+ errStr := err.Error()
+ lastError = &errStr
+ }
+
+ var inputTokens, outputTokens int
+ if lastResult != nil {
+ inputTokens = lastResult.InputTokens
+ outputTokens = lastResult.OutputTokens
+ }
+
+ logID := uuid.Must(uuid.NewV7())
+ var summary *string
+ if err == nil {
+ truncated := cron.TruncateOutput(resultStr)
+ summary = &truncated
+ }
+ if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
+ var agentUUID *uuid.UUID
+ if aid, aidErr := uuid.Parse(job.AgentID); aidErr == nil {
+ agentUUID = &aid
+ }
+ s.db.ExecContext(s.baseCtx,
+ `INSERT INTO cron_run_logs (id, job_id, agent_id, status, error, summary, duration_ms, input_tokens, output_tokens, ran_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?)`,
+ logID, id, agentUUID, status, lastError, summary, durationMS, inputTokens, outputTokens, now,
+ )
+ }
+
+ if job.DeleteAfterRun {
+ if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
+ s.db.ExecContext(s.baseCtx, "DELETE FROM cron_jobs WHERE id = ?", id)
+ }
+ } else if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
+ schedule := job.Schedule
+ next := computeNextRun(&schedule, now, s.defaultTZ)
+ s.db.ExecContext(s.baseCtx,
+ "UPDATE cron_jobs SET last_run_at = ?, last_status = ?, last_error = ?, next_run_at = ?, updated_at = ? WHERE id = ?",
+ now, status, lastError, next, now, id,
+ )
+ }
+
+ evt := store.CronEvent{Action: "completed", JobID: job.ID, JobName: job.Name, UserID: job.UserID, Status: status}
+ if err != nil {
+ evt.Action = "error"
+ evt.Error = err.Error()
+ }
+ s.emitEvent(evt)
+}
diff --git a/internal/store/sqlitestore/factory.go b/internal/store/sqlitestore/factory.go
new file mode 100644
index 00000000..88b60adf
--- /dev/null
+++ b/internal/store/sqlitestore/factory.go
@@ -0,0 +1,57 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// NewSQLiteStores creates all stores backed by SQLite.
+// Mirrors pg.NewPGStores() — returns the same *store.Stores struct.
+func NewSQLiteStores(cfg store.StoreConfig) (*store.Stores, error) {
+ db, err := OpenDB(cfg.SQLitePath)
+ if err != nil {
+ return nil, fmt.Errorf("open sqlite: %w", err)
+ }
+
+ // Apply schema (create tables on first run, migrate on upgrade).
+ if err := EnsureSchema(db); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("ensure schema: %w", err)
+ }
+
+ slog.Info("sqlite stores initialized", "path", cfg.SQLitePath)
+
+ return &store.Stores{
+ DB: db,
+ Sessions: NewSQLiteSessionStore(db),
+ Agents: NewSQLiteAgentStore(db),
+ Providers: NewSQLiteProviderStore(db, cfg.EncryptionKey),
+ Tracing: NewSQLiteTracingStore(db),
+ ConfigSecrets: NewSQLiteConfigSecretsStore(db, cfg.EncryptionKey),
+ BuiltinTools: NewSQLiteBuiltinToolStore(db),
+ Heartbeats: NewSQLiteHeartbeatStore(db),
+ Tenants: NewSQLiteTenantStore(db),
+ BuiltinToolTenantCfgs: NewSQLiteBuiltinToolTenantConfigStore(db),
+ SkillTenantCfgs: NewSQLiteSkillTenantConfigStore(db),
+ SystemConfigs: NewSQLiteSystemConfigStore(db),
+ Snapshots: NewSQLiteSnapshotStore(db),
+ Cron: NewSQLiteCronStore(db),
+ ChannelInstances: NewSQLiteChannelInstanceStore(db, cfg.EncryptionKey),
+ Pairing: NewSQLitePairingStore(db),
+ PendingMessages: NewSQLitePendingMessageStore(db),
+ Contacts: NewSQLiteContactStore(db),
+ Teams: NewSQLiteTeamStore(db),
+ Skills: NewSQLiteSkillStore(db, cfg.SkillsStorageDir),
+ MCP: NewSQLiteMCPServerStore(db, cfg.EncryptionKey),
+ Activity: NewSQLiteActivityStore(db),
+ APIKeys: NewSQLiteAPIKeyStore(db),
+ ConfigPermissions: NewSQLiteConfigPermissionStore(db),
+ Memory: NewSQLiteMemoryStore(db),
+ // Phase 2 Batch B+C stores (nil = gracefully skipped by gateway):
+ // AgentLinks, KnowledgeGraph, SecureCLI
+ }, nil
+}
diff --git a/internal/store/sqlitestore/heartbeat.go b/internal/store/sqlitestore/heartbeat.go
new file mode 100644
index 00000000..f84c099b
--- /dev/null
+++ b/internal/store/sqlitestore/heartbeat.go
@@ -0,0 +1,371 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteHeartbeatStore implements store.HeartbeatStore backed by SQLite.
+type SQLiteHeartbeatStore struct {
+ db *sql.DB
+ mu sync.Mutex
+ onEvent func(store.HeartbeatEvent)
+
+ dueCache []store.AgentHeartbeat
+ cacheLoaded bool
+ cacheTime time.Time
+ cacheTTL time.Duration
+}
+
+const defaultHeartbeatCacheTTL = 30 * time.Second
+
+func NewSQLiteHeartbeatStore(db *sql.DB) *SQLiteHeartbeatStore {
+ return &SQLiteHeartbeatStore{db: db, cacheTTL: defaultHeartbeatCacheTTL}
+}
+
+func (s *SQLiteHeartbeatStore) SetOnEvent(fn func(store.HeartbeatEvent)) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.onEvent = fn
+}
+
+func (s *SQLiteHeartbeatStore) emitEvent(event store.HeartbeatEvent) {
+ s.mu.Lock()
+ fn := s.onEvent
+ s.mu.Unlock()
+ if fn != nil {
+ fn(event)
+ }
+}
+
+func (s *SQLiteHeartbeatStore) InvalidateCache() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.cacheLoaded = false
+}
+
+const heartbeatSelectCols = `id, agent_id, enabled, interval_sec, prompt, provider_id, model,
+ isolated_session, light_context, ack_max_chars, max_retries,
+ active_hours_start, active_hours_end, timezone,
+ channel, chat_id,
+ next_run_at, last_run_at, last_status, last_error,
+ run_count, suppress_count, metadata, created_at, updated_at`
+
+func scanHeartbeat(row interface {
+ Scan(...any) error
+}) (*store.AgentHeartbeat, error) {
+ var hb store.AgentHeartbeat
+ var metadata []byte
+ var nextRunAt, lastRunAt nullSqliteTime
+ createdAt, updatedAt := scanTimePair()
+ err := row.Scan(
+ &hb.ID, &hb.AgentID, &hb.Enabled, &hb.IntervalSec, &hb.Prompt, &hb.ProviderID, &hb.Model,
+ &hb.IsolatedSession, &hb.LightContext, &hb.AckMaxChars, &hb.MaxRetries,
+ &hb.ActiveHoursStart, &hb.ActiveHoursEnd, &hb.Timezone,
+ &hb.Channel, &hb.ChatID,
+ &nextRunAt, &lastRunAt, &hb.LastStatus, &hb.LastError,
+ &hb.RunCount, &hb.SuppressCount, &metadata, createdAt, updatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ hb.CreatedAt = createdAt.Time
+ hb.UpdatedAt = updatedAt.Time
+ if nextRunAt.Valid {
+ hb.NextRunAt = &nextRunAt.Time
+ }
+ if lastRunAt.Valid {
+ hb.LastRunAt = &lastRunAt.Time
+ }
+ hb.Metadata = metadata
+ return &hb, nil
+}
+
+func (s *SQLiteHeartbeatStore) Get(ctx context.Context, agentID uuid.UUID) (*store.AgentHeartbeat, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+heartbeatSelectCols+` FROM agent_heartbeats WHERE agent_id = ?`, agentID,
+ )
+ return scanHeartbeat(row)
+}
+
+func (s *SQLiteHeartbeatStore) Upsert(ctx context.Context, hb *store.AgentHeartbeat) error {
+ meta := hb.Metadata
+ if meta == nil {
+ meta = json.RawMessage("{}")
+ }
+ now := time.Now().UTC()
+ createdAt, updatedAt := scanTimePair()
+ err := s.db.QueryRowContext(ctx,
+ `INSERT INTO agent_heartbeats
+ (agent_id, enabled, interval_sec, prompt, provider_id, model,
+ isolated_session, light_context, ack_max_chars, max_retries,
+ active_hours_start, active_hours_end, timezone,
+ channel, chat_id, next_run_at, metadata, created_at, updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT (agent_id) DO UPDATE SET
+ enabled = excluded.enabled,
+ interval_sec = excluded.interval_sec,
+ prompt = excluded.prompt,
+ provider_id = excluded.provider_id,
+ model = excluded.model,
+ isolated_session = excluded.isolated_session,
+ light_context = excluded.light_context,
+ ack_max_chars = excluded.ack_max_chars,
+ max_retries = excluded.max_retries,
+ active_hours_start = excluded.active_hours_start,
+ active_hours_end = excluded.active_hours_end,
+ timezone = excluded.timezone,
+ channel = excluded.channel,
+ chat_id = excluded.chat_id,
+ next_run_at = excluded.next_run_at,
+ metadata = excluded.metadata,
+ updated_at = excluded.updated_at
+ RETURNING id, created_at, updated_at`,
+ hb.AgentID, hb.Enabled, hb.IntervalSec, hb.Prompt, hb.ProviderID, hb.Model,
+ hb.IsolatedSession, hb.LightContext, hb.AckMaxChars, hb.MaxRetries,
+ hb.ActiveHoursStart, hb.ActiveHoursEnd, hb.Timezone,
+ hb.Channel, hb.ChatID, hb.NextRunAt, meta, now, now,
+ ).Scan(&hb.ID, createdAt, updatedAt)
+ if err != nil {
+ return err
+ }
+ hb.CreatedAt = createdAt.Time
+ hb.UpdatedAt = updatedAt.Time
+ s.InvalidateCache()
+ return nil
+}
+
+func (s *SQLiteHeartbeatStore) ListDue(ctx context.Context, now time.Time) ([]store.AgentHeartbeat, error) {
+ s.mu.Lock()
+ if s.cacheLoaded && time.Since(s.cacheTime) < s.cacheTTL {
+ cached := s.dueCache
+ s.mu.Unlock()
+ var due []store.AgentHeartbeat
+ for _, hb := range cached {
+ if hb.NextRunAt != nil && !hb.NextRunAt.After(now) {
+ due = append(due, hb)
+ }
+ }
+ return due, nil
+ }
+ s.mu.Unlock()
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+heartbeatSelectCols+`
+ FROM agent_heartbeats
+ WHERE enabled = 1 AND next_run_at IS NOT NULL`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var all []store.AgentHeartbeat
+ for rows.Next() {
+ hb, err := scanHeartbeat(rows)
+ if err != nil {
+ return nil, err
+ }
+ all = append(all, *hb)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ s.mu.Lock()
+ s.dueCache = all
+ s.cacheLoaded = true
+ s.cacheTime = time.Now()
+ s.mu.Unlock()
+
+ var due []store.AgentHeartbeat
+ for _, hb := range all {
+ if hb.NextRunAt != nil && !hb.NextRunAt.After(now) {
+ due = append(due, hb)
+ }
+ }
+ return due, nil
+}
+
+func (s *SQLiteHeartbeatStore) UpdateState(ctx context.Context, id uuid.UUID, state store.HeartbeatState) error {
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE agent_heartbeats SET
+ next_run_at = ?, last_run_at = ?, last_status = ?, last_error = ?,
+ run_count = ?, suppress_count = ?, updated_at = ?
+ WHERE id = ?`,
+ state.NextRunAt, state.LastRunAt, state.LastStatus, state.LastError,
+ state.RunCount, state.SuppressCount, time.Now().UTC(), id,
+ )
+ if err == nil {
+ s.InvalidateCache()
+ }
+ return err
+}
+
+func (s *SQLiteHeartbeatStore) Delete(ctx context.Context, agentID uuid.UUID) error {
+ _, err := s.db.ExecContext(ctx, `DELETE FROM agent_heartbeats WHERE agent_id = ?`, agentID)
+ if err == nil {
+ s.InvalidateCache()
+ }
+ return err
+}
+
+func (s *SQLiteHeartbeatStore) InsertLog(ctx context.Context, log *store.HeartbeatRunLog) error {
+ meta := log.Metadata
+ if meta == nil {
+ meta = json.RawMessage("{}")
+ }
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO heartbeat_run_logs
+ (heartbeat_id, agent_id, status, summary, error,
+ duration_ms, input_tokens, output_tokens, skip_reason, metadata, ran_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
+ log.HeartbeatID, log.AgentID, log.Status, log.Summary, log.Error,
+ log.DurationMS, log.InputTokens, log.OutputTokens, log.SkipReason, meta, log.RanAt,
+ )
+ return err
+}
+
+func (s *SQLiteHeartbeatStore) ListLogs(ctx context.Context, agentID uuid.UUID, limit, offset int) ([]store.HeartbeatRunLog, int, error) {
+ if limit <= 0 {
+ limit = 20
+ }
+
+ var total int
+ if err := s.db.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM heartbeat_run_logs WHERE agent_id = ?`, agentID,
+ ).Scan(&total); err != nil {
+ return nil, 0, err
+ }
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, heartbeat_id, agent_id, status, summary, error,
+ duration_ms, input_tokens, output_tokens, skip_reason, metadata, ran_at, created_at
+ FROM heartbeat_run_logs WHERE agent_id = ?
+ ORDER BY ran_at DESC LIMIT ? OFFSET ?`,
+ agentID, limit, offset,
+ )
+ if err != nil {
+ return nil, 0, err
+ }
+ defer rows.Close()
+
+ var logs []store.HeartbeatRunLog
+ for rows.Next() {
+ var l store.HeartbeatRunLog
+ var metadata []byte
+ var ranAt, createdAt sqliteTime
+ if err := rows.Scan(
+ &l.ID, &l.HeartbeatID, &l.AgentID, &l.Status, &l.Summary, &l.Error,
+ &l.DurationMS, &l.InputTokens, &l.OutputTokens, &l.SkipReason, &metadata, &ranAt, &createdAt,
+ ); err != nil {
+ return nil, 0, err
+ }
+ l.RanAt = ranAt.Time
+ l.CreatedAt = createdAt.Time
+ l.Metadata = metadata
+ logs = append(logs, l)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, 0, err
+ }
+ return logs, total, nil
+}
+
+// ListDeliveryTargets returns distinct (channel, chatID, title, kind) pairs from sessions.
+// SQLite adaptation: replaces DISTINCT ON + split_part with GROUP BY + substr/instr + Go-side dedup.
+// Session key format: agent:{key}:{channel}:{kind}:{chatId}
+func (s *SQLiteHeartbeatStore) ListDeliveryTargets(ctx context.Context, agentID uuid.UUID) ([]store.DeliveryTarget, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT
+ s.channel,
+ substr(s.session_key, instr(s.session_key||':', ':') + 1) AS raw_tail,
+ json_extract(s.metadata, '$.chat_title') AS chat_title,
+ s.session_key,
+ cc.display_name,
+ cc.username
+ FROM sessions s
+ LEFT JOIN channel_contacts cc
+ ON cc.channel_type = s.channel
+ WHERE s.agent_id = ?
+ AND s.channel IS NOT NULL AND s.channel != ''
+ AND s.session_key NOT LIKE '%:heartbeat%'
+ AND s.session_key NOT LIKE '%:cron%'
+ AND s.session_key NOT LIKE '%:subagent%'
+ AND s.session_key NOT LIKE '%:team:%'
+ GROUP BY s.channel, s.session_key`,
+ agentID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ seen := make(map[string]bool)
+ var targets []store.DeliveryTarget
+ for rows.Next() {
+ var channel, rawTail, sessionKey string
+ var chatTitle, displayName, username *string
+ if err := rows.Scan(&channel, &rawTail, &chatTitle, &sessionKey, &displayName, &username); err != nil {
+ return nil, err
+ }
+
+ // Parse chatId: 5th colon-separated segment of session_key.
+ chatID := extractSessionKeyPart(sessionKey, 5)
+ if chatID == "" {
+ continue
+ }
+ key := channel + ":" + chatID
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+
+ title := ""
+ switch {
+ case chatTitle != nil && *chatTitle != "":
+ title = *chatTitle
+ case displayName != nil && *displayName != "":
+ title = *displayName
+ case username != nil && *username != "":
+ title = "@" + *username
+ }
+
+ kind := "other"
+ switch {
+ case strings.Contains(sessionKey, ":group:"):
+ kind = "group"
+ case strings.Contains(sessionKey, ":direct:"):
+ kind = "dm"
+ }
+
+ targets = append(targets, store.DeliveryTarget{
+ Channel: channel,
+ ChatID: chatID,
+ Title: title,
+ Kind: kind,
+ })
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return targets, nil
+}
+
+// extractSessionKeyPart returns the n-th colon-delimited segment (1-indexed).
+func extractSessionKeyPart(key string, n int) string {
+ parts := strings.SplitN(key, ":", n+1)
+ if len(parts) < n {
+ return ""
+ }
+ return parts[n-1]
+}
diff --git a/internal/store/sqlitestore/helpers.go b/internal/store/sqlitestore/helpers.go
new file mode 100644
index 00000000..e8d70b10
--- /dev/null
+++ b/internal/store/sqlitestore/helpers.go
@@ -0,0 +1,220 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// validColumnName matches safe SQL identifiers (letters, digits, underscores).
+var validColumnName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
+
+// --- Nullable helpers ---
+
+func nilStr(s string) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
+}
+
+func nilInt(v int) *int {
+ if v == 0 {
+ return nil
+ }
+ return &v
+}
+
+func nilUUID(u *uuid.UUID) *uuid.UUID {
+ if u == nil || *u == uuid.Nil {
+ return nil
+ }
+ return u
+}
+
+func nilTime(t *time.Time) *time.Time {
+ if t == nil || t.IsZero() {
+ return nil
+ }
+ return t
+}
+
+func derefStr(s *string) string {
+ if s == nil {
+ return ""
+ }
+ return *s
+}
+
+func derefUUID(u *uuid.UUID) uuid.UUID {
+ if u == nil {
+ return uuid.Nil
+ }
+ return *u
+}
+
+// --- JSON helpers ---
+
+func jsonOrEmpty(data []byte) []byte {
+ if data == nil {
+ return []byte("{}")
+ }
+ return data
+}
+
+func jsonOrEmptyArray(data []byte) []byte {
+ if data == nil {
+ return []byte("[]")
+ }
+ return data
+}
+
+func jsonOrNull(data json.RawMessage) any {
+ if data == nil {
+ return nil
+ }
+ // Return []byte for consistency with PG helpers (store implementations expect []byte).
+ return []byte(data)
+}
+
+func derefBytes(b *[]byte) []byte {
+ if b == nil {
+ return nil
+ }
+ return *b
+}
+
+// jsonStringArray converts a Go string slice to a JSON array string for SQLite storage.
+// SQLite stores arrays as JSON text (no native array type).
+func jsonStringArray(arr []string) string {
+ if arr == nil {
+ return "[]"
+ }
+ data, _ := json.Marshal(arr)
+ return string(data)
+}
+
+// scanJSONStringArray parses a JSON array stored as TEXT into a Go string slice.
+func scanJSONStringArray(data []byte, dest *[]string) {
+ if data == nil || len(data) == 0 {
+ return
+ }
+ _ = json.Unmarshal(data, dest)
+}
+
+// sqliteVal marshals complex Go types (maps, slices) to JSON strings
+// since the SQLite driver cannot serialize them directly.
+func sqliteVal(v any) any {
+ if v == nil {
+ return nil
+ }
+ switch v.(type) {
+ case string, int, int64, float64, bool, time.Time, []byte, json.RawMessage:
+ return v
+ }
+ // For maps, slices, etc. — marshal to JSON string.
+ b, err := json.Marshal(v)
+ if err != nil {
+ return nil
+ }
+ return string(b)
+}
+
+// --- Dynamic UPDATE helper ---
+
+// execMapUpdate builds and runs a dynamic UPDATE with ? placeholders.
+func execMapUpdate(ctx context.Context, db *sql.DB, table string, id uuid.UUID, updates map[string]any) error {
+ if len(updates) == 0 {
+ return nil
+ }
+ var setClauses []string
+ var args []any
+ for col, val := range updates {
+ if !validColumnName.MatchString(col) {
+ slog.Warn("security.invalid_column_name", "table", table, "column", col)
+ return fmt.Errorf("invalid column name: %q", col)
+ }
+ setClauses = append(setClauses, col+" = ?")
+ args = append(args, sqliteVal(val))
+ }
+ // Auto-set updated_at for tables that have the column.
+ if _, ok := updates["updated_at"]; !ok && tableHasUpdatedAt(table) {
+ setClauses = append(setClauses, "updated_at = ?")
+ args = append(args, time.Now().UTC())
+ }
+ args = append(args, id)
+ q := fmt.Sprintf("UPDATE %s SET %s WHERE id = ?", table, strings.Join(setClauses, ", "))
+ _, err := db.ExecContext(ctx, q, args...)
+ return err
+}
+
+var tablesWithUpdatedAt = map[string]bool{
+ "agents": true, "llm_providers": true, "sessions": true,
+ "channel_instances": true, "cron_jobs": true,
+ "skills": true, "mcp_servers": true, "agent_links": true,
+ "agent_teams": true, "team_tasks": true, "builtin_tools": true,
+ "agent_context_files": true, "user_context_files": true,
+ "user_agent_overrides": true, "config_secrets": true,
+ "memory_documents": true, "memory_chunks": true, "embedding_cache": true,
+ "secure_cli_binaries": true, "tenants": true,
+}
+
+func tableHasUpdatedAt(table string) bool {
+ return tablesWithUpdatedAt[table]
+}
+
+// --- Tenant filter helpers ---
+
+func tenantIDForInsert(ctx context.Context) uuid.UUID {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return store.MasterTenantID
+ }
+ return tid
+}
+
+func requireTenantID(ctx context.Context) (uuid.UUID, error) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return uuid.Nil, fmt.Errorf("tenant_id required")
+ }
+ return tid, nil
+}
+
+// execMapUpdateWhereTenant builds and runs a dynamic UPDATE with ? placeholders,
+// adding both id and tenant_id to the WHERE clause for tenant-scoped updates.
+func execMapUpdateWhereTenant(ctx context.Context, db *sql.DB, table string, updates map[string]any, id, tenantID uuid.UUID) error {
+ if len(updates) == 0 {
+ return nil
+ }
+ var setClauses []string
+ var args []any
+ for col, val := range updates {
+ if !validColumnName.MatchString(col) {
+ slog.Warn("security.invalid_column_name", "table", table, "column", col)
+ return fmt.Errorf("invalid column name: %q", col)
+ }
+ setClauses = append(setClauses, col+" = ?")
+ args = append(args, sqliteVal(val))
+ }
+ if _, ok := updates["updated_at"]; !ok && tableHasUpdatedAt(table) {
+ setClauses = append(setClauses, "updated_at = ?")
+ args = append(args, time.Now().UTC())
+ }
+ args = append(args, id, tenantID)
+ q := fmt.Sprintf("UPDATE %s SET %s WHERE id = ? AND tenant_id = ?",
+ table, strings.Join(setClauses, ", "))
+ _, err := db.ExecContext(ctx, q, args...)
+ return err
+}
diff --git a/internal/store/sqlitestore/mcp_servers.go b/internal/store/sqlitestore/mcp_servers.go
new file mode 100644
index 00000000..eb29a074
--- /dev/null
+++ b/internal/store/sqlitestore/mcp_servers.go
@@ -0,0 +1,272 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/crypto"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteMCPServerStore implements store.MCPServerStore backed by SQLite.
+type SQLiteMCPServerStore struct {
+ db *sql.DB
+ encKey string
+}
+
+func NewSQLiteMCPServerStore(db *sql.DB, encryptionKey string) *SQLiteMCPServerStore {
+ return &SQLiteMCPServerStore{db: db, encKey: encryptionKey}
+}
+
+func (s *SQLiteMCPServerStore) CreateServer(ctx context.Context, srv *store.MCPServerData) error {
+ if err := store.ValidateUserID(srv.CreatedBy); err != nil {
+ return err
+ }
+ if srv.ID == uuid.Nil {
+ srv.ID = store.GenNewID()
+ }
+
+ apiKey := srv.APIKey
+ if s.encKey != "" && apiKey != "" {
+ encrypted, err := crypto.Encrypt(apiKey, s.encKey)
+ if err != nil {
+ return fmt.Errorf("encrypt api key: %w", err)
+ }
+ apiKey = encrypted
+ }
+
+ now := time.Now().UTC()
+ srv.CreatedAt = now
+ srv.UpdatedAt = now
+ encHeaders := s.encryptJSON(jsonOrEmpty(srv.Headers))
+ encEnv := s.encryptJSON(jsonOrEmpty(srv.Env))
+
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO mcp_servers (id, name, display_name, transport, command, args, url, headers, env,
+ api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at, tenant_id)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
+ srv.ID, srv.Name, nilStr(srv.DisplayName), srv.Transport, nilStr(srv.Command),
+ jsonOrEmpty(srv.Args), nilStr(srv.URL), encHeaders, encEnv,
+ nilStr(apiKey), nilStr(srv.ToolPrefix), srv.TimeoutSec,
+ jsonOrEmpty(srv.Settings), srv.Enabled, srv.CreatedBy, now, now, tenantID,
+ )
+ return err
+}
+
+func (s *SQLiteMCPServerStore) GetServer(ctx context.Context, id uuid.UUID) (*store.MCPServerData, error) {
+ if store.IsCrossTenant(ctx) {
+ return s.scanServer(s.db.QueryRowContext(ctx,
+ `SELECT id, name, display_name, transport, command, args, url, headers, env,
+ api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
+ FROM mcp_servers WHERE id = ?`, id))
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, sql.ErrNoRows
+ }
+ return s.scanServer(s.db.QueryRowContext(ctx,
+ `SELECT id, name, display_name, transport, command, args, url, headers, env,
+ api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
+ FROM mcp_servers WHERE id = ? AND tenant_id = ?`, id, tenantID))
+}
+
+func (s *SQLiteMCPServerStore) GetServerByName(ctx context.Context, name string) (*store.MCPServerData, error) {
+ if store.IsCrossTenant(ctx) {
+ return s.scanServer(s.db.QueryRowContext(ctx,
+ `SELECT id, name, display_name, transport, command, args, url, headers, env,
+ api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
+ FROM mcp_servers WHERE name = ?`, name))
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, sql.ErrNoRows
+ }
+ return s.scanServer(s.db.QueryRowContext(ctx,
+ `SELECT id, name, display_name, transport, command, args, url, headers, env,
+ api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
+ FROM mcp_servers WHERE name = ? AND tenant_id = ?`, name, tenantID))
+}
+
+func (s *SQLiteMCPServerStore) scanServer(row *sql.Row) (*store.MCPServerData, error) {
+ var srv store.MCPServerData
+ var displayName, command, url, apiKey, toolPrefix *string
+ var args, headers, env *[]byte
+ createdAt, updatedAt := scanTimePair()
+ err := row.Scan(
+ &srv.ID, &srv.Name, &displayName, &srv.Transport, &command,
+ &args, &url, &headers, &env,
+ &apiKey, &toolPrefix, &srv.TimeoutSec,
+ &srv.Settings, &srv.Enabled, &srv.CreatedBy, createdAt, updatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ srv.CreatedAt = createdAt.Time
+ srv.UpdatedAt = updatedAt.Time
+ srv.DisplayName = derefStr(displayName)
+ srv.Command = derefStr(command)
+ srv.URL = derefStr(url)
+ srv.ToolPrefix = derefStr(toolPrefix)
+ srv.Args = derefBytes(args)
+ srv.Headers = s.decryptJSON(derefBytes(headers))
+ srv.Env = s.decryptJSON(derefBytes(env))
+ if apiKey != nil && *apiKey != "" && s.encKey != "" {
+ decrypted, err := crypto.Decrypt(*apiKey, s.encKey)
+ if err != nil {
+ slog.Warn("mcp: failed to decrypt api key", "server", srv.Name, "error", err)
+ } else {
+ srv.APIKey = decrypted
+ }
+ } else {
+ srv.APIKey = derefStr(apiKey)
+ }
+ return &srv, nil
+}
+
+func (s *SQLiteMCPServerStore) ListServers(ctx context.Context) ([]store.MCPServerData, error) {
+ query := `SELECT id, name, display_name, transport, command, args, url, headers, env,
+ api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
+ FROM mcp_servers`
+ var qArgs []any
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return []store.MCPServerData{}, nil
+ }
+ query += ` WHERE tenant_id = ?`
+ qArgs = append(qArgs, tenantID)
+ }
+ query += ` ORDER BY name`
+ rows, err := s.db.QueryContext(ctx, query, qArgs...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := make([]store.MCPServerData, 0)
+ for rows.Next() {
+ var srv store.MCPServerData
+ var displayName, command, url, apiKey, toolPrefix *string
+ var args, headers, env *[]byte
+ createdAt, updatedAt := scanTimePair()
+ if err := rows.Scan(
+ &srv.ID, &srv.Name, &displayName, &srv.Transport, &command,
+ &args, &url, &headers, &env,
+ &apiKey, &toolPrefix, &srv.TimeoutSec,
+ &srv.Settings, &srv.Enabled, &srv.CreatedBy, createdAt, updatedAt,
+ ); err != nil {
+ continue
+ }
+ srv.CreatedAt = createdAt.Time
+ srv.UpdatedAt = updatedAt.Time
+ srv.DisplayName = derefStr(displayName)
+ srv.Command = derefStr(command)
+ srv.URL = derefStr(url)
+ srv.ToolPrefix = derefStr(toolPrefix)
+ srv.Args = derefBytes(args)
+ srv.Headers = s.decryptJSON(derefBytes(headers))
+ srv.Env = s.decryptJSON(derefBytes(env))
+ if apiKey != nil && *apiKey != "" && s.encKey != "" {
+ if decrypted, err := crypto.Decrypt(*apiKey, s.encKey); err == nil {
+ srv.APIKey = decrypted
+ }
+ } else {
+ srv.APIKey = derefStr(apiKey)
+ }
+ result = append(result, srv)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteMCPServerStore) UpdateServer(ctx context.Context, id uuid.UUID, updates map[string]any) error {
+ if key, ok := updates["api_key"]; ok {
+ if keyStr, isStr := key.(string); isStr && keyStr != "" && s.encKey != "" {
+ encrypted, err := crypto.Encrypt(keyStr, s.encKey)
+ if err != nil {
+ return fmt.Errorf("encrypt api key: %w", err)
+ }
+ updates["api_key"] = encrypted
+ }
+ }
+ for _, field := range []string{"env", "headers"} {
+ if v, ok := updates[field]; ok {
+ var raw []byte
+ switch val := v.(type) {
+ case json.RawMessage:
+ raw = []byte(val)
+ default:
+ raw, _ = json.Marshal(val)
+ }
+ if len(raw) > 0 {
+ updates[field] = json.RawMessage(s.encryptJSON(raw))
+ }
+ }
+ }
+ updates["updated_at"] = time.Now().UTC()
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "mcp_servers", id, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "mcp_servers", updates, id, tid)
+}
+
+func (s *SQLiteMCPServerStore) DeleteServer(ctx context.Context, id uuid.UUID) error {
+ if store.IsCrossTenant(ctx) {
+ _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = ?", id)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = ? AND tenant_id = ?", id, tid)
+ return err
+}
+
+// encryptJSON encrypts a JSON blob by wrapping ciphertext as a JSON string.
+// Unencrypted: {"key":"val"} (JSON object). Encrypted: "aes-gcm:..." (JSON string).
+func (s *SQLiteMCPServerStore) encryptJSON(data []byte) []byte {
+ if s.encKey == "" || len(data) == 0 || string(data) == "{}" || string(data) == "null" {
+ return data
+ }
+ enc, err := crypto.Encrypt(string(data), s.encKey)
+ if err != nil {
+ slog.Warn("mcp: failed to encrypt json", "error", err)
+ return data
+ }
+ wrapped, _ := json.Marshal(enc)
+ return wrapped
+}
+
+// decryptJSON decrypts a JSON blob if it is an encrypted JSON string.
+func (s *SQLiteMCPServerStore) decryptJSON(data []byte) []byte {
+ if s.encKey == "" || len(data) == 0 || data[0] != '"' {
+ return data
+ }
+ var encStr string
+ if json.Unmarshal(data, &encStr) != nil {
+ return data
+ }
+ dec, err := crypto.Decrypt(encStr, s.encKey)
+ if err != nil {
+ slog.Warn("mcp: failed to decrypt json", "error", err)
+ return data
+ }
+ return []byte(dec)
+}
diff --git a/internal/store/sqlitestore/mcp_servers_access.go b/internal/store/sqlitestore/mcp_servers_access.go
new file mode 100644
index 00000000..afae89fe
--- /dev/null
+++ b/internal/store/sqlitestore/mcp_servers_access.go
@@ -0,0 +1,391 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/crypto"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// --- Agent Grants ---
+
+func (s *SQLiteMCPServerStore) GrantToAgent(ctx context.Context, g *store.MCPAgentGrant) error {
+ if err := store.ValidateUserID(g.GrantedBy); err != nil {
+ return err
+ }
+ if g.ID == uuid.Nil {
+ g.ID = store.GenNewID()
+ }
+ g.CreatedAt = time.Now().UTC()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at, tenant_id)
+ VALUES (?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT (server_id, agent_id) DO UPDATE SET
+ enabled = excluded.enabled, tool_allow = excluded.tool_allow,
+ tool_deny = excluded.tool_deny, config_overrides = excluded.config_overrides,
+ granted_by = excluded.granted_by`,
+ g.ID, g.ServerID, g.AgentID, g.Enabled,
+ jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides),
+ g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx),
+ )
+ return err
+}
+
+func (s *SQLiteMCPServerStore) RevokeFromAgent(ctx context.Context, serverID, agentID uuid.UUID) error {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM mcp_agent_grants WHERE server_id = ? AND agent_id = ?"+tClause,
+ append([]any{serverID, agentID}, tArgs...)...)
+ return err
+}
+
+func (s *SQLiteMCPServerStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]store.MCPAgentGrant, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at
+ FROM mcp_agent_grants WHERE agent_id = ?`+tClause,
+ append([]any{agentID}, tArgs...)...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.MCPAgentGrant
+ for rows.Next() {
+ var g store.MCPAgentGrant
+ var toolAllow, toolDeny, configOverrides *string
+ var createdAt sqliteTime
+ if err := rows.Scan(&g.ID, &g.ServerID, &g.AgentID, &g.Enabled,
+ &toolAllow, &toolDeny, &configOverrides, &g.GrantedBy, &createdAt); err != nil {
+ continue
+ }
+ if toolAllow != nil {
+ g.ToolAllow = json.RawMessage(*toolAllow)
+ }
+ if toolDeny != nil {
+ g.ToolDeny = json.RawMessage(*toolDeny)
+ }
+ if configOverrides != nil {
+ g.ConfigOverrides = json.RawMessage(*configOverrides)
+ }
+ g.CreatedAt = createdAt.Time
+ result = append(result, g)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteMCPServerStore) ListServerGrants(ctx context.Context, serverID uuid.UUID) ([]store.MCPAgentGrant, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, server_id, agent_id, enabled,
+ COALESCE(tool_allow, '[]'), COALESCE(tool_deny, '[]'),
+ COALESCE(config_overrides, '{}'), granted_by, created_at
+ FROM mcp_agent_grants WHERE server_id = ?`+tClause+` ORDER BY created_at`,
+ append([]any{serverID}, tArgs...)...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := make([]store.MCPAgentGrant, 0)
+ for rows.Next() {
+ var g store.MCPAgentGrant
+ var toolAllow, toolDeny, configOverrides string
+ var createdAt sqliteTime
+ if err := rows.Scan(&g.ID, &g.ServerID, &g.AgentID, &g.Enabled,
+ &toolAllow, &toolDeny, &configOverrides, &g.GrantedBy, &createdAt); err != nil {
+ slog.Warn("mcp.list_server_grants.scan", "error", err)
+ continue
+ }
+ g.ToolAllow = json.RawMessage(toolAllow)
+ g.ToolDeny = json.RawMessage(toolDeny)
+ g.ConfigOverrides = json.RawMessage(configOverrides)
+ g.CreatedAt = createdAt.Time
+ result = append(result, g)
+ }
+ return result, rows.Err()
+}
+
+// --- Counts ---
+
+func (s *SQLiteMCPServerStore) CountAgentGrantsByServer(ctx context.Context) (map[uuid.UUID]int, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT server_id, COUNT(*) FROM mcp_agent_grants WHERE 1=1`+tClause+` GROUP BY server_id`,
+ tArgs...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := make(map[uuid.UUID]int)
+ for rows.Next() {
+ var serverID uuid.UUID
+ var count int
+ if err := rows.Scan(&serverID, &count); err != nil {
+ continue
+ }
+ result[serverID] = count
+ }
+ return result, rows.Err()
+}
+
+// --- User Grants ---
+
+func (s *SQLiteMCPServerStore) GrantToUser(ctx context.Context, g *store.MCPUserGrant) error {
+ if err := store.ValidateUserID(g.UserID); err != nil {
+ return err
+ }
+ if err := store.ValidateUserID(g.GrantedBy); err != nil {
+ return err
+ }
+ if g.ID == uuid.Nil {
+ g.ID = store.GenNewID()
+ }
+ g.CreatedAt = time.Now().UTC()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, tool_deny, granted_by, created_at, tenant_id)
+ VALUES (?,?,?,?,?,?,?,?,?)
+ ON CONFLICT (server_id, user_id) DO UPDATE SET
+ enabled = excluded.enabled, tool_allow = excluded.tool_allow,
+ tool_deny = excluded.tool_deny, granted_by = excluded.granted_by`,
+ g.ID, g.ServerID, g.UserID, g.Enabled,
+ jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny),
+ g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx),
+ )
+ return err
+}
+
+func (s *SQLiteMCPServerStore) RevokeFromUser(ctx context.Context, serverID uuid.UUID, userID string) error {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM mcp_user_grants WHERE server_id = ? AND user_id = ?"+tClause,
+ append([]any{serverID, userID}, tArgs...)...)
+ return err
+}
+
+// --- Resolution ---
+
+func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.MCPAccessInfo, error) {
+ tClause, tArgs, err := scopeClauseAlias(ctx, "ms")
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env,
+ ms.api_key, ms.tool_prefix, ms.timeout_sec, ms.settings, ms.enabled, ms.created_by, ms.created_at, ms.updated_at,
+ mag.tool_allow, mag.tool_deny
+ FROM mcp_servers ms
+ INNER JOIN mcp_agent_grants mag ON ms.id = mag.server_id AND mag.agent_id = ? AND mag.enabled = 1
+ LEFT JOIN mcp_user_grants mug ON ms.id = mug.server_id AND mug.user_id = ?
+ WHERE ms.enabled = 1
+ AND (mug.id IS NULL OR mug.enabled = 1)`+tClause,
+ append([]any{agentID, userID}, tArgs...)...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := make([]store.MCPAccessInfo, 0)
+ for rows.Next() {
+ var srv store.MCPServerData
+ var displayName, command, url, apiKey, toolPrefix *string
+ var args, headers, env *[]byte
+ var toolAllowJSON, toolDenyJSON *[]byte
+
+ createdAt, updatedAt := scanTimePair()
+ if err := rows.Scan(
+ &srv.ID, &srv.Name, &displayName, &srv.Transport, &command,
+ &args, &url, &headers, &env,
+ &apiKey, &toolPrefix, &srv.TimeoutSec,
+ &srv.Settings, &srv.Enabled, &srv.CreatedBy, createdAt, updatedAt,
+ &toolAllowJSON, &toolDenyJSON,
+ ); err != nil {
+ continue
+ }
+ srv.CreatedAt = createdAt.Time
+ srv.UpdatedAt = updatedAt.Time
+ srv.DisplayName = derefStr(displayName)
+ srv.Command = derefStr(command)
+ srv.URL = derefStr(url)
+ srv.ToolPrefix = derefStr(toolPrefix)
+ srv.Args = derefBytes(args)
+ srv.Headers = s.decryptJSON(derefBytes(headers))
+ srv.Env = s.decryptJSON(derefBytes(env))
+ if apiKey != nil && *apiKey != "" && s.encKey != "" {
+ if decrypted, err := crypto.Decrypt(*apiKey, s.encKey); err == nil {
+ srv.APIKey = decrypted
+ }
+ } else {
+ srv.APIKey = derefStr(apiKey)
+ }
+
+ info := store.MCPAccessInfo{Server: srv}
+ if toolAllowJSON != nil {
+ json.Unmarshal(*toolAllowJSON, &info.ToolAllow)
+ }
+ if toolDenyJSON != nil {
+ json.Unmarshal(*toolDenyJSON, &info.ToolDeny)
+ }
+ result = append(result, info)
+ }
+ return result, rows.Err()
+}
+
+// --- Access Requests ---
+
+func (s *SQLiteMCPServerStore) CreateRequest(ctx context.Context, req *store.MCPAccessRequest) error {
+ if err := store.ValidateUserID(req.RequestedBy); err != nil {
+ return err
+ }
+ if req.ID == uuid.Nil {
+ req.ID = store.GenNewID()
+ }
+ req.Status = "pending"
+ req.CreatedAt = time.Now().UTC()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO mcp_access_requests (id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, created_at, tenant_id)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
+ req.ID, req.ServerID, nilUUID(req.AgentID), nilStr(req.UserID),
+ req.Scope, req.Status, nilStr(req.Reason),
+ jsonOrNull(req.ToolAllow), req.RequestedBy, req.CreatedAt, tenantIDForInsert(ctx),
+ )
+ return err
+}
+
+func (s *SQLiteMCPServerStore) ListPendingRequests(ctx context.Context) ([]store.MCPAccessRequest, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by,
+ reviewed_by, reviewed_at, review_note, created_at
+ FROM mcp_access_requests WHERE status = 'pending'`+tClause+` ORDER BY created_at`,
+ tArgs...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.MCPAccessRequest
+ for rows.Next() {
+ var r store.MCPAccessRequest
+ var agentID *uuid.UUID
+ var userID, reviewedBy, reviewNote *string
+ var reviewedAtSt, createdAtSt sqliteTime
+ if err := rows.Scan(&r.ID, &r.ServerID, &agentID, &userID, &r.Scope, &r.Status,
+ &r.Reason, &r.ToolAllow, &r.RequestedBy,
+ &reviewedBy, &reviewedAtSt, &reviewNote, &createdAtSt); err != nil {
+ continue
+ }
+ if !reviewedAtSt.Time.IsZero() {
+ r.ReviewedAt = &reviewedAtSt.Time
+ }
+ r.CreatedAt = createdAtSt.Time
+ r.AgentID = agentID
+ r.UserID = derefStr(userID)
+ r.ReviewedBy = derefStr(reviewedBy)
+ r.ReviewNote = derefStr(reviewNote)
+ result = append(result, r)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteMCPServerStore) ReviewRequest(ctx context.Context, requestID uuid.UUID, approved bool, reviewedBy, note string) error {
+ if err := store.ValidateUserID(reviewedBy); err != nil {
+ return err
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return err
+ }
+
+ var req store.MCPAccessRequest
+ var agentID *uuid.UUID
+ var userID *string
+ err = tx.QueryRowContext(ctx,
+ `SELECT id, server_id, agent_id, user_id, scope, status, tool_allow
+ FROM mcp_access_requests WHERE id = ? AND status = 'pending'`+tClause,
+ append([]any{requestID}, tArgs...)...,
+ ).Scan(&req.ID, &req.ServerID, &agentID, &userID, &req.Scope, &req.Status, &req.ToolAllow)
+ if err != nil {
+ return fmt.Errorf("request not found or not pending: %w", err)
+ }
+
+ status := "rejected"
+ if approved {
+ status = "approved"
+ }
+ now := time.Now().UTC()
+
+ _, err = tx.ExecContext(ctx,
+ `UPDATE mcp_access_requests SET status = ?, reviewed_by = ?, reviewed_at = ?, review_note = ? WHERE id = ?`,
+ status, reviewedBy, now, nilStr(note), requestID,
+ )
+ if err != nil {
+ return err
+ }
+
+ if approved {
+ switch req.Scope {
+ case "agent":
+ if agentID == nil {
+ return fmt.Errorf("agent_id required for agent scope")
+ }
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, granted_by, created_at, tenant_id)
+ VALUES (?,?,?,1,?,?,?,?)
+ ON CONFLICT (server_id, agent_id) DO UPDATE SET enabled = 1, tool_allow = excluded.tool_allow, granted_by = excluded.granted_by`,
+ store.GenNewID(), req.ServerID, *agentID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx),
+ )
+ case "user":
+ if userID == nil || *userID == "" {
+ return fmt.Errorf("user_id required for user scope")
+ }
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, granted_by, created_at, tenant_id)
+ VALUES (?,?,?,1,?,?,?,?)
+ ON CONFLICT (server_id, user_id) DO UPDATE SET enabled = 1, tool_allow = excluded.tool_allow, granted_by = excluded.granted_by`,
+ store.GenNewID(), req.ServerID, *userID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx),
+ )
+ default:
+ return fmt.Errorf("unknown scope: %s", req.Scope)
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
diff --git a/internal/store/sqlitestore/mcp_user_credentials.go b/internal/store/sqlitestore/mcp_user_credentials.go
new file mode 100644
index 00000000..33d70c9b
--- /dev/null
+++ b/internal/store/sqlitestore/mcp_user_credentials.go
@@ -0,0 +1,103 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/crypto"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// GetUserCredentials returns per-user credential overrides for an MCP server.
+// Returns (nil, nil) if no per-user credentials exist.
+func (s *SQLiteMCPServerStore) GetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) (*store.MCPUserCredentials, error) {
+ tid := tenantIDForInsert(ctx)
+ var apiKey sql.NullString
+ var headersEnc, envEnc []byte
+
+ err := s.db.QueryRowContext(ctx,
+ `SELECT api_key, headers, env FROM mcp_user_credentials
+ WHERE server_id = ? AND user_id = ? AND tenant_id = ?`,
+ serverID, userID, tid,
+ ).Scan(&apiKey, &headersEnc, &envEnc)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ creds := &store.MCPUserCredentials{}
+ if apiKey.Valid && apiKey.String != "" && s.encKey != "" {
+ if dec, err := crypto.Decrypt(apiKey.String, s.encKey); err == nil {
+ creds.APIKey = dec
+ }
+ } else if apiKey.Valid {
+ creds.APIKey = apiKey.String
+ }
+ if len(headersEnc) > 0 {
+ dec := s.decryptJSON(headersEnc)
+ json.Unmarshal(dec, &creds.Headers)
+ }
+ if len(envEnc) > 0 {
+ dec := s.decryptJSON(envEnc)
+ json.Unmarshal(dec, &creds.Env)
+ }
+
+ return creds, nil
+}
+
+// SetUserCredentials creates or updates per-user MCP credentials.
+func (s *SQLiteMCPServerStore) SetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string, creds store.MCPUserCredentials) error {
+ tid := tenantIDForInsert(ctx)
+
+ var apiKeyEnc sql.NullString
+ if creds.APIKey != "" && s.encKey != "" {
+ enc, err := crypto.Encrypt(creds.APIKey, s.encKey)
+ if err != nil {
+ return fmt.Errorf("encrypt mcp user api_key: %w", err)
+ }
+ apiKeyEnc = sql.NullString{String: enc, Valid: true}
+ } else if creds.APIKey != "" {
+ apiKeyEnc = sql.NullString{String: creds.APIKey, Valid: true}
+ }
+
+ var headersEnc, envEnc []byte
+ if len(creds.Headers) > 0 {
+ raw, _ := json.Marshal(creds.Headers)
+ headersEnc = s.encryptJSON(raw)
+ }
+ if len(creds.Env) > 0 {
+ raw, _ := json.Marshal(creds.Env)
+ envEnc = s.encryptJSON(raw)
+ }
+
+ now := time.Now().UTC()
+ id := store.GenNewID()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO mcp_user_credentials (id, server_id, user_id, api_key, headers, env, tenant_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (server_id, user_id, tenant_id) DO UPDATE SET
+ api_key = excluded.api_key, headers = excluded.headers, env = excluded.env, updated_at = excluded.updated_at`,
+ id, serverID, userID, apiKeyEnc, headersEnc, envEnc, tid, now, now,
+ )
+ return err
+}
+
+// DeleteUserCredentials removes per-user MCP credentials.
+func (s *SQLiteMCPServerStore) DeleteUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) error {
+ tid := tenantIDForInsert(ctx)
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM mcp_user_credentials WHERE server_id = ? AND user_id = ? AND tenant_id = ?`,
+ serverID, userID, tid,
+ )
+ return err
+}
diff --git a/internal/store/sqlitestore/memory.go b/internal/store/sqlitestore/memory.go
new file mode 100644
index 00000000..c30d13cd
--- /dev/null
+++ b/internal/store/sqlitestore/memory.go
@@ -0,0 +1,86 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "database/sql"
+ "sync"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteMemoryStore implements store.MemoryStore backed by SQLite.
+// Vector search is not available in the Lite edition (VectorSearch: false).
+// FTS uses simple LIKE queries instead of tsvector.
+type SQLiteMemoryStore struct {
+ db *sql.DB
+ provider store.EmbeddingProvider
+ mu sync.RWMutex
+ cfg SQLiteMemoryConfig
+}
+
+// SQLiteMemoryConfig configures the SQLite memory store.
+type SQLiteMemoryConfig struct {
+ MaxChunkLen int
+ ChunkOverlap int
+ MaxResults int
+ TextWeight float64
+ VectorWeight float64
+}
+
+// DefaultSQLiteMemoryConfig returns sensible defaults.
+func DefaultSQLiteMemoryConfig() SQLiteMemoryConfig {
+ return SQLiteMemoryConfig{
+ MaxChunkLen: 1000,
+ ChunkOverlap: 200,
+ MaxResults: 6,
+ TextWeight: 1.0,
+ VectorWeight: 0.0, // no vector search in SQLite edition
+ }
+}
+
+// NewSQLiteMemoryStore creates a new SQLite-backed memory store.
+func NewSQLiteMemoryStore(db *sql.DB) *SQLiteMemoryStore {
+ return &SQLiteMemoryStore{db: db, cfg: DefaultSQLiteMemoryConfig()}
+}
+
+// SetEmbeddingProvider stores the provider reference but embeddings are not
+// persisted in SQLite (no vector column). Kept for interface compatibility.
+func (s *SQLiteMemoryStore) SetEmbeddingProvider(provider store.EmbeddingProvider) {
+ s.provider = provider
+}
+
+// UpdateChunkConfig updates chunk splitting parameters at runtime.
+func (s *SQLiteMemoryStore) UpdateChunkConfig(maxChunkLen, chunkOverlap int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if maxChunkLen > 0 {
+ s.cfg.MaxChunkLen = maxChunkLen
+ }
+ if chunkOverlap >= 0 {
+ s.cfg.ChunkOverlap = chunkOverlap
+ }
+}
+
+// chunkConfig returns current chunk parameters (thread-safe).
+func (s *SQLiteMemoryStore) chunkConfig() (maxLen, overlap int) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.cfg.MaxChunkLen, s.cfg.ChunkOverlap
+}
+
+func (s *SQLiteMemoryStore) Close() error { return nil }
+
+// scanDocumentRow scans (path, hash, user_id, updated_at) into DocumentInfo.
+func scanDocumentRow(path, hash string, uid *string, updatedAt time.Time) store.DocumentInfo {
+ info := store.DocumentInfo{
+ Path: path,
+ Hash: hash,
+ UpdatedAt: updatedAt.UnixMilli(),
+ }
+ if uid != nil {
+ info.UserID = *uid
+ }
+ return info
+}
diff --git a/internal/store/sqlitestore/memory_docs.go b/internal/store/sqlitestore/memory_docs.go
new file mode 100644
index 00000000..fcbae213
--- /dev/null
+++ b/internal/store/sqlitestore/memory_docs.go
@@ -0,0 +1,401 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/memory"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+func (s *SQLiteMemoryStore) GetDocument(ctx context.Context, agentID, userID, path string) (string, error) {
+ aid := agentID
+ var content string
+ var err error
+
+ if userID == "" {
+ tc, tcArgs, tcErr := scopeClause(ctx)
+ if tcErr != nil {
+ return "", tcErr
+ }
+ err = s.db.QueryRowContext(ctx,
+ "SELECT content FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id IS NULL"+tc,
+ append([]any{aid, path}, tcArgs...)...).Scan(&content)
+ } else {
+ tc, tcArgs, tcErr := scopeClause(ctx)
+ if tcErr != nil {
+ return "", tcErr
+ }
+ err = s.db.QueryRowContext(ctx,
+ "SELECT content FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id = ?"+tc,
+ append([]any{aid, path, userID}, tcArgs...)...).Scan(&content)
+ }
+ if err != nil {
+ return "", err
+ }
+ return content, nil
+}
+
+func (s *SQLiteMemoryStore) PutDocument(ctx context.Context, agentID, userID, path, content string) error {
+ hash := memory.ContentHash(content)
+ id := uuid.Must(uuid.NewV7()).String()
+ now := time.Now().UTC()
+ tid := tenantIDForInsert(ctx).String()
+
+ var uid *string
+ if userID != "" {
+ uid = &userID
+ }
+
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO memory_documents (id, agent_id, user_id, path, content, hash, tenant_id, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (agent_id, COALESCE(user_id, ''), path)
+ DO UPDATE SET content = excluded.content, hash = excluded.hash,
+ tenant_id = excluded.tenant_id, updated_at = excluded.updated_at`,
+ id, agentID, uid, path, content, hash, tid, now,
+ )
+ return err
+}
+
+func (s *SQLiteMemoryStore) DeleteDocument(ctx context.Context, agentID, userID, path string) error {
+ var res sql.Result
+ var err error
+
+ if userID == "" {
+ tc, tcArgs, tcErr := scopeClause(ctx)
+ if tcErr != nil {
+ return tcErr
+ }
+ res, err = s.db.ExecContext(ctx,
+ "DELETE FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id IS NULL"+tc,
+ append([]any{agentID, path}, tcArgs...)...)
+ } else {
+ tc, tcArgs, tcErr := scopeClause(ctx)
+ if tcErr != nil {
+ return tcErr
+ }
+ res, err = s.db.ExecContext(ctx,
+ "DELETE FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id = ?"+tc,
+ append([]any{agentID, path, userID}, tcArgs...)...)
+ }
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return fmt.Errorf("document not found: %s", path)
+ }
+ return nil
+}
+
+func (s *SQLiteMemoryStore) ListDocuments(ctx context.Context, agentID, userID string) ([]store.DocumentInfo, error) {
+ var rows *sql.Rows
+ var err error
+
+ if userID == "" {
+ tc, tcArgs, tcErr := scopeClause(ctx)
+ if tcErr != nil {
+ return nil, tcErr
+ }
+ rows, err = s.db.QueryContext(ctx,
+ "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = ? AND user_id IS NULL"+tc,
+ append([]any{agentID}, tcArgs...)...)
+ } else {
+ tc, tcArgs, tcErr := scopeClause(ctx)
+ if tcErr != nil {
+ return nil, tcErr
+ }
+ rows, err = s.db.QueryContext(ctx,
+ "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = ? AND (user_id IS NULL OR user_id = ?)"+tc,
+ append([]any{agentID, userID}, tcArgs...)...)
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.DocumentInfo
+ for rows.Next() {
+ var path, hash string
+ var uid *string
+ var updatedAt time.Time
+ if err := rows.Scan(&path, &hash, &uid, &updatedAt); err != nil {
+ continue
+ }
+ result = append(result, scanDocumentRow(path, hash, uid, updatedAt))
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return result, nil
+}
+
+// IndexDocument chunks a document and stores chunks (without embeddings in SQLite).
+func (s *SQLiteMemoryStore) IndexDocument(ctx context.Context, agentID, userID, path string) error {
+ content, err := s.GetDocument(ctx, agentID, userID, path)
+ if err != nil {
+ return err
+ }
+
+ // Get document ID
+ var docID string
+ if userID == "" {
+ tc, tcArgs, tcErr := scopeClause(ctx)
+ if tcErr != nil {
+ return tcErr
+ }
+ err = s.db.QueryRowContext(ctx,
+ "SELECT id FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id IS NULL"+tc,
+ append([]any{agentID, path}, tcArgs...)...).Scan(&docID)
+ } else {
+ tc, tcArgs, tcErr := scopeClause(ctx)
+ if tcErr != nil {
+ return tcErr
+ }
+ err = s.db.QueryRowContext(ctx,
+ "SELECT id FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id = ?"+tc,
+ append([]any{agentID, path, userID}, tcArgs...)...).Scan(&docID)
+ }
+ if err != nil {
+ return err
+ }
+
+ // Delete old chunks
+ if _, delErr := s.db.ExecContext(ctx, "DELETE FROM memory_chunks WHERE document_id = ?", docID); delErr != nil {
+ return fmt.Errorf("delete old chunks: %w", delErr)
+ }
+
+ // Resolve chunk config: per-agent override → global default
+ chunkLen, chunkOverlap := s.chunkConfig()
+ if rc := store.RunContextFromCtx(ctx); rc != nil && rc.MemoryCfg != nil {
+ if rc.MemoryCfg.MaxChunkLen > 0 {
+ chunkLen = rc.MemoryCfg.MaxChunkLen
+ }
+ if rc.MemoryCfg.ChunkOverlap > 0 {
+ chunkOverlap = rc.MemoryCfg.ChunkOverlap
+ }
+ }
+
+ chunks := memory.ChunkText(content, chunkLen, chunkOverlap)
+ if len(chunks) == 0 {
+ return nil
+ }
+
+ tid := tenantIDForInsert(ctx).String()
+ var uid *string
+ if userID != "" {
+ uid = &userID
+ }
+
+ for _, tc := range chunks {
+ hash := memory.ContentHash(tc.Text)
+ chunkID := uuid.Must(uuid.NewV7()).String()
+ now := time.Now().UTC()
+
+ if _, err := s.db.ExecContext(ctx,
+ `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, tenant_id, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT DO NOTHING`,
+ chunkID, agentID, docID, uid, path, tc.StartLine, tc.EndLine, hash, tc.Text, tid, now,
+ ); err != nil {
+ slog.Warn("memory: insert chunk failed", "path", path, "error", err)
+ }
+ }
+ return nil
+}
+
+func (s *SQLiteMemoryStore) IndexAll(ctx context.Context, agentID, userID string) error {
+ docs, err := s.ListDocuments(ctx, agentID, userID)
+ if err != nil {
+ return err
+ }
+ for _, doc := range docs {
+ s.IndexDocument(ctx, agentID, doc.UserID, doc.Path)
+ }
+ return nil
+}
+
+// ListAllDocumentsGlobal returns all documents across all agents (admin overview).
+func (s *SQLiteMemoryStore) ListAllDocumentsGlobal(ctx context.Context) ([]store.DocumentInfo, error) {
+ var q string
+ var args []any
+
+ if !store.IsCrossTenant(ctx) {
+ tid, err := requireTenantID(ctx)
+ if err != nil {
+ return nil, err
+ }
+ q = `SELECT agent_id, path, hash, user_id, updated_at
+ FROM memory_documents WHERE tenant_id = ? ORDER BY updated_at DESC`
+ args = []any{tid.String()}
+ } else {
+ q = `SELECT agent_id, path, hash, user_id, updated_at
+ FROM memory_documents ORDER BY updated_at DESC`
+ }
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.DocumentInfo
+ for rows.Next() {
+ var agentID, path, hash string
+ var uid *string
+ var updatedAt time.Time
+ if err := rows.Scan(&agentID, &path, &hash, &uid, &updatedAt); err != nil {
+ continue
+ }
+ info := scanDocumentRow(path, hash, uid, updatedAt)
+ info.AgentID = agentID
+ result = append(result, info)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return result, nil
+}
+
+// ListAllDocuments returns all documents for an agent across all users.
+func (s *SQLiteMemoryStore) ListAllDocuments(ctx context.Context, agentID string) ([]store.DocumentInfo, error) {
+ tc, tcArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT agent_id, path, hash, user_id, updated_at
+ FROM memory_documents WHERE agent_id = ?`+tc+`
+ ORDER BY updated_at DESC`,
+ append([]any{agentID}, tcArgs...)...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.DocumentInfo
+ for rows.Next() {
+ var aID, path, hash string
+ var uid *string
+ var updatedAt time.Time
+ if err := rows.Scan(&aID, &path, &hash, &uid, &updatedAt); err != nil {
+ continue
+ }
+ info := scanDocumentRow(path, hash, uid, updatedAt)
+ info.AgentID = aID
+ result = append(result, info)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return result, nil
+}
+
+// GetDocumentDetail returns full document info with chunk count.
+// EmbeddedCount is always 0 (no embedding column in SQLite).
+func (s *SQLiteMemoryStore) GetDocumentDetail(ctx context.Context, agentID, userID, path string) (*store.DocumentDetail, error) {
+ var q string
+ var args []any
+
+ if userID == "" {
+ tc, tcArgs, err := scopeClauseAlias(ctx, "d")
+ if err != nil {
+ return nil, err
+ }
+ q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at,
+ COUNT(c.id) AS chunk_count
+ FROM memory_documents d
+ LEFT JOIN memory_chunks c ON c.document_id = d.id
+ WHERE d.agent_id = ? AND d.path = ? AND d.user_id IS NULL` + tc + `
+ GROUP BY d.id`
+ args = append([]any{agentID, path}, tcArgs...)
+ } else {
+ tc, tcArgs, err := scopeClauseAlias(ctx, "d")
+ if err != nil {
+ return nil, err
+ }
+ q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at,
+ COUNT(c.id) AS chunk_count
+ FROM memory_documents d
+ LEFT JOIN memory_chunks c ON c.document_id = d.id
+ WHERE d.agent_id = ? AND d.path = ? AND d.user_id = ?` + tc + `
+ GROUP BY d.id`
+ args = append([]any{agentID, path, userID}, tcArgs...)
+ }
+
+ var detail store.DocumentDetail
+ var uid *string
+ var createdAt, updatedAt time.Time
+ err := s.db.QueryRowContext(ctx, q, args...).Scan(
+ &detail.Path, &detail.Content, &detail.Hash, &uid,
+ &createdAt, &updatedAt, &detail.ChunkCount,
+ )
+ if err != nil {
+ return nil, err
+ }
+ if uid != nil {
+ detail.UserID = *uid
+ }
+ detail.CreatedAt = createdAt.UnixMilli()
+ detail.UpdatedAt = updatedAt.UnixMilli()
+ // EmbeddedCount always 0 — no embedding column in SQLite
+ return &detail, nil
+}
+
+// ListChunks returns chunks for a document identified by agent, user, and path.
+func (s *SQLiteMemoryStore) ListChunks(ctx context.Context, agentID, userID, path string) ([]store.ChunkInfo, error) {
+ var q string
+ var args []any
+
+ if userID == "" {
+ tc, tcArgs, err := scopeClauseAlias(ctx, "d")
+ if err != nil {
+ return nil, err
+ }
+ q = `SELECT c.id, c.start_line, c.end_line, c.text
+ FROM memory_chunks c
+ JOIN memory_documents d ON c.document_id = d.id
+ WHERE d.agent_id = ? AND d.path = ? AND d.user_id IS NULL` + tc + `
+ ORDER BY c.start_line`
+ args = append([]any{agentID, path}, tcArgs...)
+ } else {
+ tc, tcArgs, err := scopeClauseAlias(ctx, "d")
+ if err != nil {
+ return nil, err
+ }
+ q = `SELECT c.id, c.start_line, c.end_line, c.text
+ FROM memory_chunks c
+ JOIN memory_documents d ON c.document_id = d.id
+ WHERE d.agent_id = ? AND d.path = ? AND d.user_id = ?` + tc + `
+ ORDER BY c.start_line`
+ args = append([]any{agentID, path, userID}, tcArgs...)
+ }
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.ChunkInfo
+ for rows.Next() {
+ var ci store.ChunkInfo
+ if err := rows.Scan(&ci.ID, &ci.StartLine, &ci.EndLine, &ci.TextPreview); err != nil {
+ continue
+ }
+ // HasEmbedding always false — no embedding column in SQLite
+ result = append(result, ci)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return result, nil
+}
diff --git a/internal/store/sqlitestore/memory_search.go b/internal/store/sqlitestore/memory_search.go
new file mode 100644
index 00000000..5ab22660
--- /dev/null
+++ b/internal/store/sqlitestore/memory_search.go
@@ -0,0 +1,120 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "strings"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// Search performs LIKE-based text search over memory_chunks.
+// Vector search is not available in the SQLite edition — VectorSearch is always false.
+// Merges global (user_id IS NULL) + per-user chunks, with user boost.
+func (s *SQLiteMemoryStore) Search(ctx context.Context, query string, agentID, userID string, opts store.MemorySearchOptions) ([]store.MemorySearchResult, error) {
+ maxResults := opts.MaxResults
+ if maxResults <= 0 {
+ maxResults = s.cfg.MaxResults
+ }
+
+ results, err := s.likeSearch(ctx, query, agentID, userID, maxResults*2)
+ if err != nil {
+ return nil, err
+ }
+
+ // Apply filters and cap results
+ var filtered []store.MemorySearchResult
+ for _, r := range results {
+ if opts.MinScore > 0 && r.Score < opts.MinScore {
+ continue
+ }
+ if opts.PathPrefix != "" && !strings.HasPrefix(r.Path, opts.PathPrefix) {
+ continue
+ }
+ filtered = append(filtered, r)
+ if len(filtered) >= maxResults {
+ break
+ }
+ }
+ return filtered, nil
+}
+
+// likeSearch performs a case-insensitive LIKE search across chunk text.
+// Returns results scored 1.0 (global) or 1.2 (personal, boosted).
+func (s *SQLiteMemoryStore) likeSearch(ctx context.Context, query, agentID, userID string, limit int) ([]store.MemorySearchResult, error) {
+ pattern := "%" + escapeLike(query) + "%"
+
+ var q string
+ var args []any
+
+ if userID != "" {
+ tc, tcArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ q = `SELECT path, start_line, end_line, text, user_id
+ FROM memory_chunks
+ WHERE agent_id = ? AND (user_id IS NULL OR user_id = ?)
+ AND text LIKE ? ESCAPE '\'` + tc + `
+ ORDER BY user_id DESC
+ LIMIT ?`
+ args = append([]any{agentID, userID, pattern}, tcArgs...)
+ args = append(args, limit)
+ } else {
+ tc, tcArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ q = `SELECT path, start_line, end_line, text, user_id
+ FROM memory_chunks
+ WHERE agent_id = ? AND user_id IS NULL
+ AND text LIKE ? ESCAPE '\'` + tc + `
+ LIMIT ?`
+ args = append([]any{agentID, pattern}, tcArgs...)
+ args = append(args, limit)
+ }
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var results []store.MemorySearchResult
+ for rows.Next() {
+ var path, text string
+ var startLine, endLine int
+ var uid *string
+ if err := rows.Scan(&path, &startLine, &endLine, &text, &uid); err != nil {
+ continue
+ }
+ scope := "global"
+ score := 1.0
+ if uid != nil && *uid != "" {
+ scope = "personal"
+ score = 1.2 // personal boost, mirrors PG hybrid merge
+ }
+ results = append(results, store.MemorySearchResult{
+ Path: path,
+ StartLine: startLine,
+ EndLine: endLine,
+ Score: score,
+ Snippet: text,
+ Source: "memory",
+ Scope: scope,
+ })
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return results, nil
+}
+
+// escapeLike escapes special LIKE metacharacters: % _ \
+func escapeLike(s string) string {
+ s = strings.ReplaceAll(s, `\`, `\\`)
+ s = strings.ReplaceAll(s, `%`, `\%`)
+ s = strings.ReplaceAll(s, `_`, `\_`)
+ return s
+}
diff --git a/internal/store/sqlitestore/pairing.go b/internal/store/sqlitestore/pairing.go
new file mode 100644
index 00000000..0d93c4c1
--- /dev/null
+++ b/internal/store/sqlitestore/pairing.go
@@ -0,0 +1,243 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+const (
+ codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+ codeLength = 8
+ codeTTL = 60 * time.Minute
+ pairedDeviceTTL = 30 * 24 * time.Hour
+ maxPendingPerAccount = 3
+)
+
+// SQLitePairingStore implements store.PairingStore backed by SQLite.
+type SQLitePairingStore struct {
+ db *sql.DB
+ onRequest func(code, senderID, channel, chatID string)
+}
+
+func NewSQLitePairingStore(db *sql.DB) *SQLitePairingStore {
+ return &SQLitePairingStore{db: db}
+}
+
+func (s *SQLitePairingStore) SetOnRequest(cb func(code, senderID, channel, chatID string)) {
+ s.onRequest = cb
+}
+
+func (s *SQLitePairingStore) RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) {
+ tid := tenantIDForInsert(ctx)
+ now := time.Now()
+
+ s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now)
+
+ var count int64
+ s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM pairing_requests WHERE account_id = ? AND tenant_id = ?", accountID, tid).Scan(&count)
+ if count >= maxPendingPerAccount {
+ return "", fmt.Errorf("max pending pairing requests (%d) exceeded", maxPendingPerAccount)
+ }
+
+ var existingCode string
+ err := s.db.QueryRowContext(ctx, "SELECT code FROM pairing_requests WHERE sender_id = ? AND channel = ? AND tenant_id = ?", senderID, channel, tid).Scan(&existingCode)
+ if err == nil {
+ return existingCode, nil
+ }
+
+ metaJSON := []byte("{}")
+ if len(metadata) > 0 {
+ metaJSON, _ = json.Marshal(metadata)
+ }
+
+ code := generatePairingCode()
+ _, err = s.db.ExecContext(ctx,
+ `INSERT INTO pairing_requests (id, code, sender_id, channel, chat_id, account_id, expires_at, created_at, metadata, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL), now, metaJSON, tid,
+ )
+ if err != nil {
+ return "", fmt.Errorf("create pairing request: %w", err)
+ }
+ if s.onRequest != nil {
+ go s.onRequest(code, senderID, channel, chatID)
+ }
+ return code, nil
+}
+
+func (s *SQLitePairingStore) ApprovePairing(ctx context.Context, code, approvedBy string) (*store.PairedDeviceData, error) {
+ now := time.Now()
+ s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now)
+
+ var reqID uuid.UUID
+ var senderID, channel, chatID string
+ var metaJSON []byte
+ var reqTenantID uuid.UUID
+
+ err := s.db.QueryRowContext(ctx,
+ "SELECT id, sender_id, channel, chat_id, COALESCE(metadata, '{}'), tenant_id FROM pairing_requests WHERE code = ?", code,
+ ).Scan(&reqID, &senderID, &channel, &chatID, &metaJSON, &reqTenantID)
+ if err != nil {
+ return nil, fmt.Errorf("pairing code %s not found or expired", code)
+ }
+
+ s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE id = ?", reqID)
+
+ expiresAt := now.Add(pairedDeviceTTL)
+ _, err = s.db.ExecContext(ctx,
+ `INSERT INTO paired_devices (id, sender_id, channel, chat_id, paired_by, paired_at, metadata, expires_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ uuid.Must(uuid.NewV7()), senderID, channel, chatID, approvedBy, now, metaJSON, expiresAt, reqTenantID,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("create paired device: %w", err)
+ }
+
+ var meta map[string]string
+ if len(metaJSON) > 0 {
+ json.Unmarshal(metaJSON, &meta)
+ }
+
+ return &store.PairedDeviceData{
+ SenderID: senderID,
+ Channel: channel,
+ ChatID: chatID,
+ PairedAt: now.UnixMilli(),
+ PairedBy: approvedBy,
+ Metadata: meta,
+ }, nil
+}
+
+func (s *SQLitePairingStore) DenyPairing(ctx context.Context, code string) error {
+ result, err := s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE code = ?", code)
+ if err != nil {
+ return err
+ }
+ n, _ := result.RowsAffected()
+ if n == 0 {
+ return fmt.Errorf("pairing code %s not found or expired", code)
+ }
+ return nil
+}
+
+func (s *SQLitePairingStore) RevokePairing(ctx context.Context, senderID, channel string) error {
+ tid := tenantIDForInsert(ctx)
+ result, err := s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE sender_id = ? AND channel = ? AND tenant_id = ?", senderID, channel, tid)
+ if err != nil {
+ return err
+ }
+ n, _ := result.RowsAffected()
+ if n == 0 {
+ return fmt.Errorf("paired device not found: %s/%s", channel, senderID)
+ }
+ return nil
+}
+
+func (s *SQLitePairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) {
+ tid := tenantIDForInsert(ctx)
+ var count int64
+ err := s.db.QueryRowContext(ctx,
+ "SELECT COUNT(*) FROM paired_devices WHERE sender_id = ? AND channel = ? AND tenant_id = ? AND (expires_at IS NULL OR expires_at > ?)",
+ senderID, channel, tid, time.Now(),
+ ).Scan(&count)
+ if err != nil {
+ return false, fmt.Errorf("pairing check query: %w", err)
+ }
+ return count > 0, nil
+}
+
+func (s *SQLitePairingStore) ListPending(ctx context.Context) []store.PairingRequestData {
+ tid := tenantIDForInsert(ctx)
+ now := time.Now()
+
+ s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now)
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT code, sender_id, channel, chat_id, account_id, created_at, expires_at, COALESCE(metadata, '{}')
+ FROM pairing_requests WHERE tenant_id = ? ORDER BY created_at DESC`, tid)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+
+ var result []store.PairingRequestData
+ for rows.Next() {
+ var d store.PairingRequestData
+ var createdAt, expiresAt time.Time
+ var metaJSON []byte
+ if err := rows.Scan(&d.Code, &d.SenderID, &d.Channel, &d.ChatID, &d.AccountID, &createdAt, &expiresAt, &metaJSON); err != nil {
+ continue
+ }
+ d.CreatedAt = createdAt.UnixMilli()
+ d.ExpiresAt = expiresAt.UnixMilli()
+ if len(metaJSON) > 0 {
+ json.Unmarshal(metaJSON, &d.Metadata)
+ }
+ result = append(result, d)
+ }
+ if err := rows.Err(); err != nil {
+ slog.Warn("pairing: list pending iteration error", "error", err)
+ }
+ if result == nil {
+ return []store.PairingRequestData{}
+ }
+ return result
+}
+
+func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDeviceData {
+ tid := tenantIDForInsert(ctx)
+ now := time.Now()
+
+ s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < ?", now)
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}')
+ FROM paired_devices WHERE tenant_id = ? ORDER BY paired_at DESC`, tid)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+
+ var result []store.PairedDeviceData
+ for rows.Next() {
+ var d store.PairedDeviceData
+ var pairedAt time.Time
+ var metaJSON []byte
+ if err := rows.Scan(&d.SenderID, &d.Channel, &d.ChatID, &d.PairedBy, &pairedAt, &metaJSON); err != nil {
+ continue
+ }
+ d.PairedAt = pairedAt.UnixMilli()
+ if len(metaJSON) > 0 {
+ json.Unmarshal(metaJSON, &d.Metadata)
+ }
+ result = append(result, d)
+ }
+ if err := rows.Err(); err != nil {
+ slog.Warn("pairing: list paired iteration error", "error", err)
+ }
+ if result == nil {
+ return []store.PairedDeviceData{}
+ }
+ return result
+}
+
+func generatePairingCode() string {
+ b := make([]byte, codeLength)
+ rand.Read(b)
+ code := make([]byte, codeLength)
+ for i := range code {
+ code[i] = codeAlphabet[int(b[i])%len(codeAlphabet)]
+ }
+ return string(code)
+}
diff --git a/internal/store/sqlitestore/pending_messages.go b/internal/store/sqlitestore/pending_messages.go
new file mode 100644
index 00000000..fbedf38a
--- /dev/null
+++ b/internal/store/sqlitestore/pending_messages.go
@@ -0,0 +1,286 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLitePendingMessageStore implements store.PendingMessageStore backed by SQLite.
+type SQLitePendingMessageStore struct {
+ db *sql.DB
+}
+
+func NewSQLitePendingMessageStore(db *sql.DB) *SQLitePendingMessageStore {
+ return &SQLitePendingMessageStore{db: db}
+}
+
+func (s *SQLitePendingMessageStore) AppendBatch(ctx context.Context, msgs []store.PendingMessage) error {
+ if len(msgs) == 0 {
+ return nil
+ }
+
+ const cols = 11
+ placeholders := make([]string, len(msgs))
+ args := make([]any, 0, len(msgs)*cols)
+ now := time.Now()
+ tid := tenantIDForInsert(ctx)
+
+ for i := range msgs {
+ if msgs[i].ID == uuid.Nil {
+ msgs[i].ID = uuid.Must(uuid.NewV7())
+ }
+ placeholders[i] = "(?,?,?,?,?,?,?,?,?,?,?)"
+ args = append(args, msgs[i].ID, msgs[i].ChannelName, msgs[i].HistoryKey,
+ msgs[i].Sender, msgs[i].SenderID, msgs[i].Body, msgs[i].PlatformMsgID, msgs[i].IsSummary, now, now, tid)
+ }
+
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at, tenant_id)
+ VALUES `+strings.Join(placeholders, ","),
+ args...,
+ )
+ return err
+}
+
+func (s *SQLitePendingMessageStore) ListByKey(ctx context.Context, channelName, historyKey string) ([]store.PendingMessage, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ args := append([]any{channelName, historyKey}, tArgs...)
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at
+ FROM channel_pending_messages
+ WHERE channel_name = ? AND history_key = ?`+tClause+`
+ ORDER BY created_at ASC`,
+ args...,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.PendingMessage
+ for rows.Next() {
+ var m store.PendingMessage
+ createdAt, updatedAt := scanTimePair()
+ if err := rows.Scan(&m.ID, &m.ChannelName, &m.HistoryKey, &m.Sender, &m.SenderID, &m.Body, &m.PlatformMsgID, &m.IsSummary, createdAt, updatedAt); err != nil {
+ return nil, err
+ }
+ m.CreatedAt = createdAt.Time
+ m.UpdatedAt = updatedAt.Time
+ result = append(result, m)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLitePendingMessageStore) DeleteByKey(ctx context.Context, channelName, historyKey string) error {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return err
+ }
+ args := append([]any{channelName, historyKey}, tArgs...)
+ _, err = s.db.ExecContext(ctx,
+ `DELETE FROM channel_pending_messages WHERE channel_name = ? AND history_key = ?`+tClause,
+ args...,
+ )
+ return err
+}
+
+func (s *SQLitePendingMessageStore) Compact(ctx context.Context, deleteIDs []uuid.UUID, summary *store.PendingMessage) error {
+ if len(deleteIDs) == 0 {
+ return nil
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin compact tx: %w", err)
+ }
+ defer tx.Rollback()
+
+ placeholders := make([]string, len(deleteIDs))
+ args := make([]any, len(deleteIDs))
+ for i, id := range deleteIDs {
+ placeholders[i] = "?"
+ args[i] = id
+ }
+
+ res, err := tx.ExecContext(ctx,
+ fmt.Sprintf("DELETE FROM channel_pending_messages WHERE id IN (%s)", strings.Join(placeholders, ",")),
+ args...,
+ )
+ if err != nil {
+ return fmt.Errorf("compact delete: %w", err)
+ }
+
+ affected, _ := res.RowsAffected()
+ if affected == 0 {
+ return nil
+ }
+
+ if summary.ID == uuid.Nil {
+ summary.ID = uuid.Must(uuid.NewV7())
+ }
+ now := time.Now()
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ summary.ID, summary.ChannelName, summary.HistoryKey, summary.Sender, summary.SenderID, summary.Body, summary.PlatformMsgID, true, now, now, tenantIDForInsert(ctx),
+ )
+ if err != nil {
+ return fmt.Errorf("compact insert summary: %w", err)
+ }
+
+ return tx.Commit()
+}
+
+func (s *SQLitePendingMessageStore) DeleteStale(ctx context.Context, olderThan time.Duration) (int64, error) {
+ cutoff := time.Now().Add(-olderThan)
+ tid := tenantIDForInsert(ctx)
+ result, err := s.db.ExecContext(ctx,
+ `DELETE FROM channel_pending_messages WHERE updated_at < ? AND tenant_id = ?`,
+ cutoff, tid,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected()
+}
+
+func (s *SQLitePendingMessageStore) ListGroups(ctx context.Context) ([]store.PendingMessageGroup, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ // SQLite: BOOL_OR → MAX(is_summary), EXISTS subquery logic preserved
+ q := `SELECT channel_name, history_key,
+ COUNT(*) AS message_count,
+ MAX(is_summary) AND NOT EXISTS (
+ SELECT 1 FROM channel_pending_messages n
+ WHERE n.channel_name = m.channel_name
+ AND n.history_key = m.history_key
+ AND NOT n.is_summary
+ AND n.created_at > (
+ SELECT MAX(s.created_at)
+ FROM channel_pending_messages s
+ WHERE s.channel_name = m.channel_name
+ AND s.history_key = m.history_key
+ AND s.is_summary
+ )
+ ) AS has_summary,
+ MAX(created_at) AS last_activity
+ FROM channel_pending_messages m
+ WHERE 1=1` + tClause + `
+ GROUP BY channel_name, history_key
+ ORDER BY last_activity DESC`
+
+ rows, err := s.db.QueryContext(ctx, q, tArgs...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.PendingMessageGroup
+ for rows.Next() {
+ var g store.PendingMessageGroup
+ if err := rows.Scan(&g.ChannelName, &g.HistoryKey, &g.MessageCount, &g.HasSummary, &g.LastActivity); err != nil {
+ return nil, err
+ }
+ result = append(result, g)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLitePendingMessageStore) CountAll(ctx context.Context) (int64, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return 0, err
+ }
+ var count int64
+ err = s.db.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM channel_pending_messages WHERE 1=1`+tClause,
+ tArgs...,
+ ).Scan(&count)
+ return count, err
+}
+
+func (s *SQLitePendingMessageStore) CountByKey(ctx context.Context, channelName, historyKey string) (int, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return 0, err
+ }
+ args := append([]any{channelName, historyKey}, tArgs...)
+ var count int
+ err = s.db.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM channel_pending_messages WHERE channel_name = ? AND history_key = ?`+tClause,
+ args...,
+ ).Scan(&count)
+ return count, err
+}
+
+func (s *SQLitePendingMessageStore) ResolveGroupTitles(ctx context.Context, groups []store.PendingMessageGroup) (map[string]string, error) {
+ if len(groups) == 0 {
+ return nil, nil
+ }
+
+ // Build OR conditions using LIKE with ? placeholders
+ conditions := make([]string, 0, len(groups))
+ args := make([]any, 0, len(groups)*2)
+ for _, g := range groups {
+ conditions = append(conditions, "(session_key LIKE '%:' || ? || ':group:' || ? || '%')")
+ args = append(args, g.ChannelName, g.HistoryKey)
+ }
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ args = append(args, tid)
+ }
+
+ tenantFilter := ""
+ if !store.IsCrossTenant(ctx) {
+ tenantFilter = " AND tenant_id = ?"
+ }
+
+ rows, err := s.db.QueryContext(ctx,
+ "SELECT session_key, json_extract(metadata, '$.chat_title')"+
+ " FROM sessions"+
+ " WHERE json_extract(metadata, '$.chat_title') != ''"+
+ " AND ("+strings.Join(conditions, " OR ")+")"+tenantFilter,
+ args...,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := make(map[string]string)
+ for rows.Next() {
+ var sessionKey, title string
+ if err := rows.Scan(&sessionKey, &title); err != nil {
+ return nil, err
+ }
+ for _, g := range groups {
+ pattern := ":" + g.ChannelName + ":group:" + g.HistoryKey
+ if strings.Contains(sessionKey, pattern) {
+ mapKey := g.ChannelName + ":" + g.HistoryKey
+ if _, exists := result[mapKey]; !exists {
+ result[mapKey] = title
+ }
+ break
+ }
+ }
+ }
+ return result, rows.Err()
+}
diff --git a/internal/store/sqlitestore/pool.go b/internal/store/sqlitestore/pool.go
new file mode 100644
index 00000000..84af8280
--- /dev/null
+++ b/internal/store/sqlitestore/pool.go
@@ -0,0 +1,52 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "database/sql"
+ "fmt"
+ "log/slog"
+
+ _ "modernc.org/sqlite" // pure-Go SQLite driver
+)
+
+// OpenDB opens a SQLite database at the given path with WAL mode and recommended pragmas.
+// Uses modernc.org/sqlite (pure Go, zero CGo).
+//
+// Desktop app concurrency model:
+// - WAL mode: allows concurrent readers alongside a single writer
+// - MaxOpenConns(2): limits contention; one reader + one writer can proceed in parallel
+// - busy_timeout=15000: writer retries for 15s before SQLITE_BUSY error
+// - _txlock=immediate: write transactions acquire lock immediately (fail-fast on contention)
+func OpenDB(path string) (*sql.DB, error) {
+ dsn := fmt.Sprintf("file:%s?_txlock=immediate&_foreign_keys=ON", path)
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("open sqlite: %w", err)
+ }
+
+ // SQLite is single-writer; WAL allows concurrent readers.
+ // Keep pool small to reduce lock contention.
+ db.SetMaxOpenConns(2)
+
+ // Set PRAGMAs explicitly — DSN params may not be applied by modernc.org/sqlite.
+ pragmas := []string{
+ "PRAGMA journal_mode = WAL",
+ "PRAGMA busy_timeout = 15000",
+ "PRAGMA synchronous = NORMAL",
+ "PRAGMA cache_size = -8000", // 8MB cache
+ }
+ for _, p := range pragmas {
+ if _, err := db.Exec(p); err != nil {
+ slog.Warn("sqlite pragma failed", "pragma", p, "error", err)
+ }
+ }
+
+ // Verify connection works.
+ if err := db.Ping(); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("ping sqlite: %w", err)
+ }
+
+ return db, nil
+}
diff --git a/internal/store/sqlitestore/providers.go b/internal/store/sqlitestore/providers.go
new file mode 100644
index 00000000..be8a6854
--- /dev/null
+++ b/internal/store/sqlitestore/providers.go
@@ -0,0 +1,199 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/crypto"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteProviderStore implements store.ProviderStore backed by SQLite.
+type SQLiteProviderStore struct {
+ db *sql.DB
+ encKey string // AES-256 encryption key for API keys (empty = plain text)
+}
+
+func NewSQLiteProviderStore(db *sql.DB, encryptionKey string) *SQLiteProviderStore {
+ if encryptionKey != "" {
+ slog.Info("provider store: API key encryption enabled")
+ } else {
+ slog.Warn("provider store: API key encryption disabled (plain text storage)")
+ }
+ return &SQLiteProviderStore{db: db, encKey: encryptionKey}
+}
+
+func (s *SQLiteProviderStore) CreateProvider(ctx context.Context, p *store.LLMProviderData) error {
+ if p.ID == uuid.Nil {
+ p.ID = store.GenNewID()
+ }
+
+ apiKey := p.APIKey
+ if s.encKey != "" && apiKey != "" {
+ encrypted, err := crypto.Encrypt(apiKey, s.encKey)
+ if err != nil {
+ return fmt.Errorf("encrypt api key: %w", err)
+ }
+ apiKey = encrypted
+ }
+
+ settings := p.Settings
+ if len(settings) == 0 {
+ settings = []byte("{}")
+ }
+
+ now := time.Now()
+ p.CreatedAt = now
+ p.UpdatedAt = now
+ tid := tenantIDForInsert(ctx)
+ p.TenantID = tid
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO llm_providers (id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ p.ID, p.Name, p.DisplayName, p.ProviderType, p.APIBase, apiKey, p.Enabled, settings, now, now, tid,
+ )
+ return err
+}
+
+func (s *SQLiteProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store.LLMProviderData, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var p store.LLMProviderData
+ var apiKey string
+ createdAt, updatedAt := scanTimePair()
+ args := append([]any{id}, tArgs...)
+ err = s.db.QueryRowContext(ctx,
+ `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id
+ FROM llm_providers WHERE id = ?`+tClause,
+ args...,
+ ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, createdAt, updatedAt, &p.TenantID)
+ if err != nil {
+ return nil, fmt.Errorf("provider not found: %s", id)
+ }
+ p.CreatedAt = createdAt.Time
+ p.UpdatedAt = updatedAt.Time
+ p.APIKey = s.decryptKey(apiKey, p.Name)
+ return &p, nil
+}
+
+func (s *SQLiteProviderStore) GetProviderByName(ctx context.Context, name string) (*store.LLMProviderData, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var p store.LLMProviderData
+ var apiKey string
+ createdAt, updatedAt := scanTimePair()
+ args := append([]any{name}, tArgs...)
+ err = s.db.QueryRowContext(ctx,
+ `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id
+ FROM llm_providers WHERE name = ?`+tClause,
+ args...,
+ ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, createdAt, updatedAt, &p.TenantID)
+ if err != nil {
+ return nil, fmt.Errorf("provider not found: %s", name)
+ }
+ p.CreatedAt = createdAt.Time
+ p.UpdatedAt = updatedAt.Time
+ p.APIKey = s.decryptKey(apiKey, p.Name)
+ return &p, nil
+}
+
+func (s *SQLiteProviderStore) ListProviders(ctx context.Context) ([]store.LLMProviderData, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ q := `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id
+ FROM llm_providers WHERE true` + tClause + ` ORDER BY name`
+ rows, err := s.db.QueryContext(ctx, q, tArgs...)
+ if err != nil {
+ return nil, err
+ }
+ return s.scanProviders(rows)
+}
+
+// ListAllProviders returns all providers across all tenants. Server-internal only.
+func (s *SQLiteProviderStore) ListAllProviders(ctx context.Context) ([]store.LLMProviderData, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id
+ FROM llm_providers ORDER BY name`)
+ if err != nil {
+ return nil, err
+ }
+ return s.scanProviders(rows)
+}
+
+func (s *SQLiteProviderStore) UpdateProvider(ctx context.Context, id uuid.UUID, updates map[string]any) error {
+ if apiKey, ok := updates["api_key"]; ok && s.encKey != "" {
+ if keyStr, ok := apiKey.(string); ok && keyStr != "" {
+ encrypted, err := crypto.Encrypt(keyStr, s.encKey)
+ if err != nil {
+ return fmt.Errorf("encrypt api key: %w", err)
+ }
+ updates["api_key"] = encrypted
+ }
+ }
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "llm_providers", id, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "llm_providers", updates, id, tid)
+}
+
+func (s *SQLiteProviderStore) DeleteProvider(ctx context.Context, id uuid.UUID) error {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return err
+ }
+ args := append([]any{id}, tArgs...)
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM llm_providers WHERE id = ?"+tClause,
+ args...,
+ )
+ return err
+}
+
+func (s *SQLiteProviderStore) decryptKey(apiKey, providerName string) string {
+ if s.encKey != "" && apiKey != "" {
+ decrypted, err := crypto.Decrypt(apiKey, s.encKey)
+ if err != nil {
+ slog.Warn("failed to decrypt provider API key", "provider", providerName, "error", err)
+ return apiKey
+ }
+ return decrypted
+ }
+ return apiKey
+}
+
+func (s *SQLiteProviderStore) scanProviders(rows *sql.Rows) ([]store.LLMProviderData, error) {
+ defer rows.Close()
+ var result []store.LLMProviderData
+ for rows.Next() {
+ var p store.LLMProviderData
+ var apiKey string
+ createdAt, updatedAt := scanTimePair()
+ if err := rows.Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, createdAt, updatedAt, &p.TenantID); err != nil {
+ slog.Error("providers.scan", "error", err)
+ continue
+ }
+ p.CreatedAt = createdAt.Time
+ p.UpdatedAt = updatedAt.Time
+ p.APIKey = s.decryptKey(apiKey, p.Name)
+ result = append(result, p)
+ }
+ return result, rows.Err()
+}
+
diff --git a/internal/store/sqlitestore/scan_time.go b/internal/store/sqlitestore/scan_time.go
new file mode 100644
index 00000000..fc3eacf8
--- /dev/null
+++ b/internal/store/sqlitestore/scan_time.go
@@ -0,0 +1,95 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// sqliteTime wraps time.Time to handle SQLite's text-based timestamp storage.
+// modernc.org/sqlite returns timestamps as strings; this type implements sql.Scanner
+// to parse them automatically during rows.Scan().
+type sqliteTime struct {
+ Time time.Time
+}
+
+// Scan implements sql.Scanner for SQLite text timestamps.
+func (st *sqliteTime) Scan(src any) error {
+ if src == nil {
+ st.Time = time.Time{}
+ return nil
+ }
+ switch v := src.(type) {
+ case time.Time:
+ st.Time = v
+ return nil
+ case string:
+ return st.parseString(v)
+ case []byte:
+ return st.parseString(string(v))
+ default:
+ return fmt.Errorf("sqliteTime: unsupported type %T", src)
+ }
+}
+
+var timeFormats = []string{
+ time.RFC3339Nano,
+ time.RFC3339,
+ "2006-01-02T15:04:05.000Z",
+ "2006-01-02T15:04:05Z",
+ "2006-01-02 15:04:05.999999999 -0700 MST",
+ "2006-01-02 15:04:05.999999999 -0700 -07",
+ "2006-01-02 15:04:05.999999999",
+ "2006-01-02 15:04:05",
+ "2006-01-02",
+}
+
+func (st *sqliteTime) parseString(s string) error {
+ // Strip Go monotonic clock suffix (e.g. " m=+12.492694459")
+ if idx := strings.Index(s, " m="); idx > 0 {
+ s = s[:idx]
+ }
+ for _, layout := range timeFormats {
+ if t, err := time.Parse(layout, s); err == nil {
+ st.Time = t
+ return nil
+ }
+ }
+ return fmt.Errorf("sqliteTime: cannot parse %q", s)
+}
+
+// scanTimePair is a convenience for scanning created_at + updated_at columns.
+// After scan, copy .Time back to the target time.Time fields.
+func scanTimePair() (createdAt, updatedAt *sqliteTime) {
+ return &sqliteTime{}, &sqliteTime{}
+}
+
+// nullSqliteTime wraps sql.NullTime equivalent for nullable timestamp columns.
+type nullSqliteTime struct {
+ Time time.Time
+ Valid bool
+}
+
+// Scan implements sql.Scanner for nullable SQLite text timestamps.
+func (nt *nullSqliteTime) Scan(src any) error {
+ if src == nil {
+ nt.Time = time.Time{}
+ nt.Valid = false
+ return nil
+ }
+ st := &sqliteTime{}
+ if err := st.Scan(src); err != nil {
+ return err
+ }
+ nt.Time = st.Time
+ nt.Valid = true
+ return nil
+}
+
+// NullTime returns sql.NullTime from nullSqliteTime.
+func (nt *nullSqliteTime) NullTime() sql.NullTime {
+ return sql.NullTime{Time: nt.Time, Valid: nt.Valid}
+}
diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go
new file mode 100644
index 00000000..f9e321ae
--- /dev/null
+++ b/internal/store/sqlitestore/schema.go
@@ -0,0 +1,116 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "database/sql"
+ _ "embed"
+ "fmt"
+ "log/slog"
+)
+
+//go:embed schema.sql
+var schemaSQL string
+
+// SchemaVersion is the current SQLite schema version.
+// Bump this when adding new migration steps below.
+const SchemaVersion = 1
+
+// migrations maps version → SQL to apply when upgrading FROM that version.
+// schema.sql always represents the LATEST full schema (for fresh DBs).
+// Existing DBs are patched incrementally via these steps.
+//
+// Example: to add a new column in the future:
+//
+// var migrations = map[int]string{
+// 1: `ALTER TABLE agents ADD COLUMN new_col TEXT DEFAULT '';`,
+// }
+//
+// Then bump SchemaVersion to 2.
+var migrations = map[int]string{
+ // Version 1 is the initial schema — no patch needed (schema.sql covers it).
+}
+
+// EnsureSchema creates tables if they don't exist and applies incremental migrations.
+//
+// Flow:
+// 1. Fresh DB (no schema_version row) → apply full schema.sql + set version = SchemaVersion
+// 2. Existing DB with version < SchemaVersion → apply patches sequentially
+// 3. Existing DB with version == SchemaVersion → no-op
+// 4. Always: seed master tenant (idempotent)
+func EnsureSchema(db *sql.DB) error {
+ if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (
+ version INTEGER NOT NULL PRIMARY KEY
+ )`); err != nil {
+ return fmt.Errorf("create schema_version: %w", err)
+ }
+
+ var current int
+ err := db.QueryRow("SELECT version FROM schema_version LIMIT 1").Scan(¤t)
+ if err == sql.ErrNoRows {
+ // Fresh database — apply full schema.
+ slog.Info("sqlite: applying initial schema", "version", SchemaVersion)
+ tx, txErr := db.Begin()
+ if txErr != nil {
+ return fmt.Errorf("begin schema tx: %w", txErr)
+ }
+ if _, err := tx.Exec(schemaSQL); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("apply schema: %w", err)
+ }
+ if _, err := tx.Exec("INSERT INTO schema_version (version) VALUES (?)", SchemaVersion); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("set schema version: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit schema tx: %w", err)
+ }
+ return seedMasterTenant(db)
+ }
+ if err != nil {
+ return fmt.Errorf("read schema version: %w", err)
+ }
+
+ // Apply incremental migrations for existing DBs.
+ if current < SchemaVersion {
+ slog.Info("sqlite: migrating schema", "from", current, "to", SchemaVersion)
+ for v := current; v < SchemaVersion; v++ {
+ patch, ok := migrations[v]
+ if !ok {
+ return fmt.Errorf("sqlite: missing migration for version %d → %d", v, v+1)
+ }
+ tx, txErr := db.Begin()
+ if txErr != nil {
+ return fmt.Errorf("begin migration tx v%d: %w", v, txErr)
+ }
+ if _, err := tx.Exec(patch); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("apply migration v%d: %w", v, err)
+ }
+ if _, err := tx.Exec(
+ "UPDATE schema_version SET version = ? WHERE version = ?", v+1, v,
+ ); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("update schema version v%d: %w", v, err)
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit migration v%d: %w", v, err)
+ }
+ slog.Info("sqlite: applied migration", "version", v+1)
+ }
+ }
+
+ return seedMasterTenant(db)
+}
+
+// seedMasterTenant ensures the master tenant row exists (idempotent).
+func seedMasterTenant(db *sql.DB) error {
+ _, err := db.Exec(
+ `INSERT OR IGNORE INTO tenants (id, name, slug, status) VALUES (?, 'Master', 'master', 'active')`,
+ "0193a5b0-7000-7000-8000-000000000001",
+ )
+ if err != nil {
+ slog.Warn("sqlite: seed master tenant failed", "error", err)
+ }
+ return nil
+}
diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql
new file mode 100644
index 00000000..420443aa
--- /dev/null
+++ b/internal/store/sqlitestore/schema.sql
@@ -0,0 +1,1307 @@
+-- GoClaw SQLite Schema (auto-translated from PG migrations 000001-000029)
+--
+-- Translation rules applied:
+-- UUID → TEXT (36-char string)
+-- TIMESTAMPTZ → TEXT (RFC3339)
+-- JSONB → TEXT (JSON string)
+-- BYTEA → BLOB
+-- SERIAL/BIGSERIAL → INTEGER PRIMARY KEY AUTOINCREMENT
+-- DEFAULT uuid_generate_v7() / gen_random_uuid() → removed (generated in Go)
+-- DEFAULT NOW() / CURRENT_TIMESTAMP → DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+-- text[] / uuid[] → TEXT (JSON array)
+-- vector(N) → OMITTED (sqlite-vec virtual tables added separately)
+-- tsvector → OMITTED (FTS5 virtual tables added separately)
+-- CREATE EXTENSION / CREATE OR REPLACE FUNCTION → OMITTED
+-- CREATE INDEX USING gin / hnsw → OMITTED
+-- USING btree → removed from CREATE INDEX
+--
+-- Tables dropped across migrations (not included):
+-- group_file_writers (migration 23: merged into agent_config_permissions)
+-- handoff_routes (migration 26: dropped)
+-- delegation_history (migration 26: dropped)
+-- team_messages (migration 24: dropped)
+-- team_workspace_files (migration 24: dropped)
+-- team_workspace_comments (migration 24: dropped)
+-- team_workspace_file_versions (migration 24: dropped)
+-- team_task_attachments (migration 24: old version dropped, new path-based version created)
+-- custom_tools (migration 27: dropped)
+--
+-- FK cascade constraints reflect final state after migration 23 alterations.
+
+PRAGMA foreign_keys = ON;
+
+-- ============================================================
+-- Table: tenants
+-- (created first — referenced by almost all other tables)
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS tenants (
+ id TEXT NOT NULL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ slug VARCHAR(100) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'active',
+ settings TEXT NOT NULL DEFAULT '{}',
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
+CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status) WHERE status = 'active';
+
+-- Seed master tenant (required by all FK references)
+INSERT OR IGNORE INTO tenants (id, name, slug, status)
+VALUES ('0193a5b0-7000-7000-8000-000000000001', 'Master', 'master', 'active');
+
+-- ============================================================
+-- Table: tenant_users
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS tenant_users (
+ id TEXT NOT NULL PRIMARY KEY,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL,
+ display_name VARCHAR(255),
+ role VARCHAR(20) NOT NULL DEFAULT 'member',
+ metadata TEXT NOT NULL DEFAULT '{}',
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(tenant_id, user_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_tenant_users_user ON tenant_users(user_id);
+CREATE INDEX IF NOT EXISTS idx_tenant_users_tenant ON tenant_users(tenant_id);
+
+-- ============================================================
+-- Table: llm_providers
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS llm_providers (
+ id TEXT NOT NULL PRIMARY KEY,
+ name VARCHAR(50) NOT NULL,
+ display_name VARCHAR(255),
+ provider_type VARCHAR(30) NOT NULL DEFAULT 'openai_compat',
+ api_base TEXT,
+ api_key TEXT,
+ enabled BOOLEAN NOT NULL DEFAULT 1,
+ settings TEXT NOT NULL DEFAULT '{}',
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+-- tenant-scoped unique name (migration 27 Phase I)
+CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_providers_tenant_name ON llm_providers(tenant_id, name);
+CREATE INDEX IF NOT EXISTS idx_llm_providers_tenant ON llm_providers(tenant_id);
+
+-- ============================================================
+-- Table: agents
+-- Note: tsv (tsvector) and embedding (vector) columns omitted
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS agents (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_key VARCHAR(100) NOT NULL,
+ display_name VARCHAR(255),
+ owner_id VARCHAR(255) NOT NULL,
+ provider VARCHAR(50) NOT NULL DEFAULT 'openrouter',
+ model VARCHAR(200) NOT NULL,
+ context_window INT NOT NULL DEFAULT 200000,
+ max_tool_iterations INT NOT NULL DEFAULT 20,
+ workspace TEXT NOT NULL DEFAULT '.',
+ restrict_to_workspace BOOLEAN NOT NULL DEFAULT 1,
+ tools_config TEXT NOT NULL DEFAULT '{}',
+ sandbox_config TEXT,
+ subagents_config TEXT,
+ memory_config TEXT,
+ compaction_config TEXT,
+ context_pruning TEXT,
+ other_config TEXT NOT NULL DEFAULT '{}',
+ is_default BOOLEAN NOT NULL DEFAULT 0,
+ agent_type VARCHAR(20) NOT NULL DEFAULT 'open',
+ status VARCHAR(20) DEFAULT 'active',
+ frontmatter TEXT,
+ budget_monthly_cents INTEGER,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ deleted_at TEXT
+);
+
+-- Final unique constraint: tenant-scoped agent_key for active agents (migration 27 Phase I)
+CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_tenant_agent_key_active ON agents(tenant_id, agent_key) WHERE deleted_at IS NULL;
+CREATE INDEX IF NOT EXISTS idx_agents_owner ON agents(owner_id) WHERE deleted_at IS NULL;
+CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status) WHERE deleted_at IS NULL;
+CREATE INDEX IF NOT EXISTS idx_agents_tenant ON agents(tenant_id);
+CREATE INDEX IF NOT EXISTS idx_agents_tenant_active ON agents(tenant_id) WHERE deleted_at IS NULL;
+
+-- ============================================================
+-- Table: agent_shares
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS agent_shares (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL,
+ role VARCHAR(20) NOT NULL DEFAULT 'user',
+ granted_by VARCHAR(255) NOT NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(agent_id, user_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_agent_shares_user ON agent_shares(user_id);
+CREATE INDEX IF NOT EXISTS idx_agent_shares_tenant ON agent_shares(tenant_id);
+
+-- ============================================================
+-- Table: agent_context_files
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS agent_context_files (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ file_name VARCHAR(255) NOT NULL,
+ content TEXT NOT NULL DEFAULT '',
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(agent_id, file_name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_agent_context_files_tenant ON agent_context_files(tenant_id);
+
+-- ============================================================
+-- Table: user_context_files
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS user_context_files (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL,
+ file_name VARCHAR(255) NOT NULL,
+ content TEXT NOT NULL DEFAULT '',
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(agent_id, user_id, file_name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_context_files_tenant ON user_context_files(tenant_id);
+
+-- ============================================================
+-- Table: user_agent_profiles
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS user_agent_profiles (
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL,
+ workspace TEXT,
+ metadata TEXT DEFAULT '{}',
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ first_seen_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ last_seen_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ PRIMARY KEY (agent_id, user_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_agent_profiles_tenant ON user_agent_profiles(tenant_id);
+
+-- ============================================================
+-- Table: user_agent_overrides
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS user_agent_overrides (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL,
+ provider VARCHAR(50),
+ model VARCHAR(200),
+ settings TEXT NOT NULL DEFAULT '{}',
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(agent_id, user_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_agent_overrides_tenant ON user_agent_overrides(tenant_id);
+
+-- ============================================================
+-- Table: agent_teams
+-- (defined before sessions/memory since they reference it)
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS agent_teams (
+ id TEXT NOT NULL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ lead_agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ description TEXT,
+ status VARCHAR(20) NOT NULL DEFAULT 'active',
+ settings TEXT NOT NULL DEFAULT '{}',
+ created_by VARCHAR(255) NOT NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_agent_teams_tenant ON agent_teams(tenant_id);
+
+-- ============================================================
+-- Table: sessions
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT NOT NULL PRIMARY KEY,
+ session_key VARCHAR(500) NOT NULL,
+ agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE,
+ user_id VARCHAR(255),
+ messages TEXT NOT NULL DEFAULT '[]',
+ summary TEXT,
+ model VARCHAR(200),
+ provider VARCHAR(50),
+ channel VARCHAR(50),
+ input_tokens BIGINT NOT NULL DEFAULT 0,
+ output_tokens BIGINT NOT NULL DEFAULT 0,
+ compaction_count INT NOT NULL DEFAULT 0,
+ memory_flush_compaction_count INT NOT NULL DEFAULT 0,
+ memory_flush_at BIGINT DEFAULT 0,
+ label VARCHAR(500),
+ spawned_by VARCHAR(200),
+ spawn_depth INT NOT NULL DEFAULT 0,
+ metadata TEXT DEFAULT '{}',
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+-- tenant-scoped unique session_key (migration 27 Phase I)
+CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_tenant_session_key ON sessions(tenant_id, session_key);
+CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_id);
+CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
+CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC);
+CREATE INDEX IF NOT EXISTS idx_sessions_tenant ON sessions(tenant_id);
+CREATE INDEX IF NOT EXISTS idx_sessions_tenant_user ON sessions(tenant_id, user_id);
+CREATE INDEX IF NOT EXISTS idx_sessions_team ON sessions(team_id) WHERE team_id IS NOT NULL;
+
+-- ============================================================
+-- Table: memory_documents
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS memory_documents (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ user_id VARCHAR(255),
+ path VARCHAR(500) NOT NULL,
+ content TEXT NOT NULL DEFAULT '',
+ hash VARCHAR(64) NOT NULL,
+ team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_memdoc_unique ON memory_documents(agent_id, COALESCE(user_id, ''), path);
+CREATE INDEX IF NOT EXISTS idx_memdoc_agent_user ON memory_documents(agent_id, user_id);
+CREATE INDEX IF NOT EXISTS idx_memdoc_team ON memory_documents(team_id) WHERE team_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_memory_documents_tenant ON memory_documents(tenant_id);
+
+-- ============================================================
+-- Table: memory_chunks
+-- Note: embedding (vector) and tsv (tsvector) columns omitted
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS memory_chunks (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ document_id TEXT REFERENCES memory_documents(id) ON DELETE CASCADE,
+ user_id VARCHAR(255),
+ path TEXT NOT NULL,
+ start_line INT NOT NULL DEFAULT 0,
+ end_line INT NOT NULL DEFAULT 0,
+ hash VARCHAR(64) NOT NULL,
+ text TEXT NOT NULL,
+ team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_mem_agent_user ON memory_chunks(agent_id, user_id);
+CREATE INDEX IF NOT EXISTS idx_mem_global ON memory_chunks(agent_id) WHERE user_id IS NULL;
+CREATE INDEX IF NOT EXISTS idx_mem_document ON memory_chunks(document_id);
+CREATE INDEX IF NOT EXISTS idx_memchunk_team ON memory_chunks(team_id) WHERE team_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_memory_chunks_tenant ON memory_chunks(tenant_id);
+
+-- ============================================================
+-- Table: embedding_cache
+-- Note: embedding (vector) column omitted
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS embedding_cache (
+ hash VARCHAR(64) NOT NULL,
+ provider VARCHAR(50) NOT NULL,
+ model VARCHAR(200) NOT NULL,
+ dims INT NOT NULL DEFAULT 0,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ PRIMARY KEY (hash, provider, model)
+);
+
+CREATE INDEX IF NOT EXISTS idx_embedding_cache_tenant ON embedding_cache(tenant_id);
+
+-- ============================================================
+-- Table: skills
+-- Note: embedding (vector) column omitted
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS skills (
+ id TEXT NOT NULL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ slug VARCHAR(255) NOT NULL,
+ description TEXT,
+ owner_id VARCHAR(255) NOT NULL,
+ visibility VARCHAR(10) NOT NULL DEFAULT 'private',
+ version INT NOT NULL DEFAULT 1,
+ status VARCHAR(20) NOT NULL DEFAULT 'active',
+ frontmatter TEXT NOT NULL DEFAULT '{}',
+ file_path TEXT NOT NULL,
+ file_size BIGINT NOT NULL DEFAULT 0,
+ file_hash VARCHAR(64),
+ tags TEXT,
+ is_system BOOLEAN NOT NULL DEFAULT 0,
+ deps TEXT NOT NULL DEFAULT '{}',
+ enabled BOOLEAN NOT NULL DEFAULT 1,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+-- tenant-scoped unique slug (migration 27 Phase I)
+CREATE UNIQUE INDEX IF NOT EXISTS idx_skills_tenant_slug ON skills(tenant_id, slug);
+CREATE INDEX IF NOT EXISTS idx_skills_owner ON skills(owner_id);
+CREATE INDEX IF NOT EXISTS idx_skills_visibility ON skills(visibility) WHERE status = 'active';
+CREATE INDEX IF NOT EXISTS idx_skills_system ON skills(is_system) WHERE is_system = 1;
+CREATE INDEX IF NOT EXISTS idx_skills_enabled ON skills(enabled) WHERE enabled = 0;
+CREATE INDEX IF NOT EXISTS idx_skills_tenant ON skills(tenant_id);
+
+-- ============================================================
+-- Table: skill_agent_grants
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS skill_agent_grants (
+ id TEXT NOT NULL PRIMARY KEY,
+ skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ pinned_version INT NOT NULL,
+ granted_by VARCHAR(255) NOT NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(skill_id, agent_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_skill_agent_grants_agent ON skill_agent_grants(agent_id);
+CREATE INDEX IF NOT EXISTS idx_skill_agent_grants_tenant ON skill_agent_grants(tenant_id);
+
+-- ============================================================
+-- Table: skill_user_grants
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS skill_user_grants (
+ id TEXT NOT NULL PRIMARY KEY,
+ skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL,
+ granted_by VARCHAR(255) NOT NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(skill_id, user_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_skill_user_grants_user ON skill_user_grants(user_id);
+CREATE INDEX IF NOT EXISTS idx_skill_user_grants_tenant ON skill_user_grants(tenant_id);
+
+-- ============================================================
+-- Table: skill_tenant_configs
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS skill_tenant_configs (
+ skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ enabled BOOLEAN NOT NULL DEFAULT 1,
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ PRIMARY KEY (skill_id, tenant_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_skill_tenant_configs_tenant ON skill_tenant_configs(tenant_id);
+
+-- ============================================================
+-- Table: cron_jobs
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS cron_jobs (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE,
+ user_id TEXT,
+ name VARCHAR(255) NOT NULL,
+ enabled BOOLEAN NOT NULL DEFAULT 1,
+ schedule_kind VARCHAR(10) NOT NULL,
+ cron_expression VARCHAR(100),
+ interval_ms BIGINT,
+ run_at TEXT,
+ timezone VARCHAR(50),
+ payload TEXT NOT NULL,
+ delete_after_run BOOLEAN NOT NULL DEFAULT 0,
+ next_run_at TEXT,
+ last_run_at TEXT,
+ last_status VARCHAR(20),
+ last_error TEXT,
+ team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_cron_jobs_user_id ON cron_jobs(user_id);
+CREATE INDEX IF NOT EXISTS idx_cron_jobs_agent_user ON cron_jobs(agent_id, user_id);
+CREATE INDEX IF NOT EXISTS idx_cron_jobs_team ON cron_jobs(team_id) WHERE team_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_cron_jobs_tenant ON cron_jobs(tenant_id);
+
+-- ============================================================
+-- Table: cron_run_logs
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS cron_run_logs (
+ id TEXT NOT NULL PRIMARY KEY,
+ job_id TEXT NOT NULL REFERENCES cron_jobs(id) ON DELETE CASCADE,
+ agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
+ status VARCHAR(20) NOT NULL,
+ summary TEXT,
+ error TEXT,
+ duration_ms INT,
+ input_tokens INT DEFAULT 0,
+ output_tokens INT DEFAULT 0,
+ team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
+ ran_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_cron_run_logs_job ON cron_run_logs(job_id, ran_at DESC);
+CREATE INDEX IF NOT EXISTS idx_cron_run_logs_team ON cron_run_logs(team_id) WHERE team_id IS NOT NULL;
+
+-- ============================================================
+-- Table: pairing_requests
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS pairing_requests (
+ id TEXT NOT NULL PRIMARY KEY,
+ code VARCHAR(8) NOT NULL UNIQUE,
+ sender_id VARCHAR(200) NOT NULL,
+ channel VARCHAR(255) NOT NULL,
+ chat_id VARCHAR(200) NOT NULL,
+ account_id VARCHAR(100) NOT NULL DEFAULT 'default',
+ metadata TEXT DEFAULT '{}',
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ expires_at TEXT NOT NULL,
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_pairing_requests_tenant ON pairing_requests(tenant_id);
+
+-- ============================================================
+-- Table: paired_devices
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS paired_devices (
+ id TEXT NOT NULL PRIMARY KEY,
+ sender_id VARCHAR(200) NOT NULL,
+ channel VARCHAR(255) NOT NULL,
+ chat_id VARCHAR(200) NOT NULL,
+ paired_by VARCHAR(100) NOT NULL DEFAULT 'operator',
+ metadata TEXT DEFAULT '{}',
+ expires_at TEXT,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ paired_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+-- tenant-scoped unique (migration 27 Phase I)
+CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_devices_tenant_sender_channel ON paired_devices(tenant_id, sender_id, channel);
+CREATE INDEX IF NOT EXISTS idx_paired_devices_tenant ON paired_devices(tenant_id);
+
+-- ============================================================
+-- Table: traces
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS traces (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_id TEXT,
+ user_id VARCHAR(255),
+ session_key TEXT,
+ run_id TEXT,
+ start_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ end_time TEXT,
+ duration_ms INT,
+ name TEXT,
+ channel VARCHAR(50),
+ input_preview TEXT,
+ output_preview TEXT,
+ total_input_tokens INT DEFAULT 0,
+ total_output_tokens INT DEFAULT 0,
+ total_cost NUMERIC(12,6) DEFAULT 0,
+ span_count INT DEFAULT 0,
+ llm_call_count INT DEFAULT 0,
+ tool_call_count INT DEFAULT 0,
+ status VARCHAR(20) DEFAULT 'running',
+ error TEXT,
+ metadata TEXT,
+ tags TEXT,
+ parent_trace_id TEXT,
+ team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_traces_agent_time ON traces(agent_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_traces_user_time ON traces(user_id, created_at DESC) WHERE user_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_traces_session ON traces(session_key, created_at DESC) WHERE session_key IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_traces_status ON traces(status) WHERE status = 'error';
+CREATE INDEX IF NOT EXISTS idx_traces_parent ON traces(parent_trace_id) WHERE parent_trace_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_traces_quota ON traces(user_id, created_at DESC) WHERE parent_trace_id IS NULL AND user_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_traces_start_root ON traces(start_time DESC) WHERE parent_trace_id IS NULL;
+CREATE INDEX IF NOT EXISTS idx_traces_team ON traces(team_id, created_at DESC) WHERE team_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_traces_tenant ON traces(tenant_id);
+CREATE INDEX IF NOT EXISTS idx_traces_tenant_time ON traces(tenant_id, created_at DESC);
+
+-- ============================================================
+-- Table: spans
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS spans (
+ id TEXT NOT NULL PRIMARY KEY,
+ trace_id TEXT NOT NULL,
+ parent_span_id TEXT,
+ agent_id TEXT,
+ span_type VARCHAR(20) NOT NULL,
+ name TEXT,
+ start_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ end_time TEXT,
+ duration_ms INT,
+ status VARCHAR(20) DEFAULT 'running',
+ error TEXT,
+ level VARCHAR(10) DEFAULT 'DEFAULT',
+ model VARCHAR(200),
+ provider VARCHAR(50),
+ input_tokens INT,
+ output_tokens INT,
+ total_cost NUMERIC(12,8),
+ finish_reason VARCHAR(50),
+ model_params TEXT,
+ tool_name VARCHAR(200),
+ tool_call_id VARCHAR(100),
+ input_preview TEXT,
+ output_preview TEXT,
+ metadata TEXT,
+ team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_spans_trace ON spans(trace_id, start_time);
+CREATE INDEX IF NOT EXISTS idx_spans_trace_type ON spans(trace_id, span_type);
+CREATE INDEX IF NOT EXISTS idx_spans_parent ON spans(parent_span_id) WHERE parent_span_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_spans_agent_time ON spans(agent_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_spans_type ON spans(span_type, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_spans_model ON spans(model, created_at DESC) WHERE model IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_spans_error ON spans(status) WHERE status = 'error';
+CREATE INDEX IF NOT EXISTS idx_spans_team ON spans(team_id) WHERE team_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_spans_tenant ON spans(tenant_id);
+
+-- ============================================================
+-- Table: mcp_servers
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS mcp_servers (
+ id TEXT NOT NULL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ display_name VARCHAR(255),
+ transport VARCHAR(50) NOT NULL,
+ command TEXT,
+ args TEXT DEFAULT '[]',
+ url TEXT,
+ headers TEXT DEFAULT '{}',
+ env TEXT DEFAULT '{}',
+ api_key TEXT,
+ tool_prefix VARCHAR(50),
+ timeout_sec INT DEFAULT 60,
+ settings TEXT NOT NULL DEFAULT '{}',
+ enabled BOOLEAN NOT NULL DEFAULT 1,
+ created_by VARCHAR(255) NOT NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+-- tenant-scoped unique name (migration 27 Phase I)
+CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_servers_tenant_name ON mcp_servers(tenant_id, name);
+CREATE INDEX IF NOT EXISTS idx_mcp_servers_tenant ON mcp_servers(tenant_id);
+
+-- ============================================================
+-- Table: mcp_agent_grants
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS mcp_agent_grants (
+ id TEXT NOT NULL PRIMARY KEY,
+ server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ enabled BOOLEAN NOT NULL DEFAULT 1,
+ tool_allow TEXT,
+ tool_deny TEXT,
+ config_overrides TEXT,
+ granted_by VARCHAR(255) NOT NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(server_id, agent_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_mcp_agent_grants_agent ON mcp_agent_grants(agent_id);
+CREATE INDEX IF NOT EXISTS idx_mcp_agent_grants_tenant ON mcp_agent_grants(tenant_id);
+
+-- ============================================================
+-- Table: mcp_user_grants
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS mcp_user_grants (
+ id TEXT NOT NULL PRIMARY KEY,
+ server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL,
+ enabled BOOLEAN NOT NULL DEFAULT 1,
+ tool_allow TEXT,
+ tool_deny TEXT,
+ granted_by VARCHAR(255) NOT NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(server_id, user_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_mcp_user_grants_user ON mcp_user_grants(user_id);
+CREATE INDEX IF NOT EXISTS idx_mcp_user_grants_tenant ON mcp_user_grants(tenant_id);
+
+-- ============================================================
+-- Table: mcp_access_requests
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS mcp_access_requests (
+ id TEXT NOT NULL PRIMARY KEY,
+ server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
+ agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE,
+ user_id VARCHAR(255),
+ scope VARCHAR(10) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending',
+ reason TEXT,
+ tool_allow TEXT,
+ requested_by VARCHAR(255) NOT NULL,
+ reviewed_by VARCHAR(255),
+ reviewed_at TEXT,
+ review_note TEXT,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_mcp_requests_status ON mcp_access_requests(status) WHERE status = 'pending';
+CREATE INDEX IF NOT EXISTS idx_mcp_requests_server ON mcp_access_requests(server_id);
+CREATE INDEX IF NOT EXISTS idx_mcp_access_requests_tenant ON mcp_access_requests(tenant_id);
+
+-- ============================================================
+-- Table: mcp_user_credentials
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS mcp_user_credentials (
+ id TEXT NOT NULL PRIMARY KEY,
+ server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL,
+ api_key TEXT,
+ headers BLOB,
+ env BLOB,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(server_id, user_id, tenant_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_mcp_user_credentials_tenant ON mcp_user_credentials(tenant_id);
+CREATE INDEX IF NOT EXISTS idx_mcp_user_credentials_server ON mcp_user_credentials(server_id);
+
+-- ============================================================
+-- Table: channel_instances
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS channel_instances (
+ id TEXT NOT NULL PRIMARY KEY,
+ name VARCHAR(100) NOT NULL,
+ display_name VARCHAR(255) DEFAULT '',
+ channel_type VARCHAR(50) NOT NULL,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ credentials BLOB,
+ config TEXT DEFAULT '{}',
+ enabled BOOLEAN DEFAULT 1,
+ created_by VARCHAR(255) DEFAULT '',
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+-- tenant-scoped unique name (migration 27 Phase I)
+CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_instances_tenant_name ON channel_instances(tenant_id, name);
+CREATE INDEX IF NOT EXISTS idx_channel_instances_type ON channel_instances(channel_type);
+CREATE INDEX IF NOT EXISTS idx_channel_instances_agent ON channel_instances(agent_id);
+CREATE INDEX IF NOT EXISTS idx_channel_instances_tenant ON channel_instances(tenant_id);
+
+-- ============================================================
+-- Table: config_secrets
+-- PK changed to (key, tenant_id) in migration 27 Phase I
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS config_secrets (
+ key VARCHAR(100) NOT NULL,
+ value BLOB NOT NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ PRIMARY KEY (key, tenant_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_config_secrets_tenant ON config_secrets(tenant_id);
+
+-- ============================================================
+-- Table: agent_links
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS agent_links (
+ id TEXT NOT NULL PRIMARY KEY,
+ source_agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ target_agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ direction VARCHAR(20) NOT NULL DEFAULT 'outbound',
+ description TEXT,
+ max_concurrent INT NOT NULL DEFAULT 3,
+ settings TEXT NOT NULL DEFAULT '{}',
+ status VARCHAR(20) NOT NULL DEFAULT 'active',
+ created_by VARCHAR(255) NOT NULL,
+ team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(source_agent_id, target_agent_id),
+ CHECK (source_agent_id != target_agent_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_agent_links_source ON agent_links(source_agent_id) WHERE status = 'active';
+CREATE INDEX IF NOT EXISTS idx_agent_links_target ON agent_links(target_agent_id) WHERE status = 'active';
+CREATE INDEX IF NOT EXISTS idx_agent_links_tenant ON agent_links(tenant_id);
+
+-- ============================================================
+-- Table: agent_team_members
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS agent_team_members (
+ team_id TEXT NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ role VARCHAR(20) NOT NULL DEFAULT 'member',
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ joined_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ PRIMARY KEY (team_id, agent_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_agent_team_members_tenant ON agent_team_members(tenant_id);
+
+-- ============================================================
+-- Table: team_tasks
+-- Note: tsv (tsvector) and embedding (vector) columns omitted
+-- blocked_by stored as TEXT (JSON array of UUIDs)
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS team_tasks (
+ id TEXT NOT NULL PRIMARY KEY,
+ team_id TEXT NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE,
+ subject VARCHAR(500) NOT NULL,
+ description TEXT,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending',
+ owner_agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
+ blocked_by TEXT NOT NULL DEFAULT '[]',
+ priority INT NOT NULL DEFAULT 0,
+ result TEXT,
+ metadata TEXT NOT NULL DEFAULT '{}',
+ user_id VARCHAR(255),
+ channel VARCHAR(50),
+ task_type VARCHAR(30) NOT NULL DEFAULT 'general',
+ task_number INT NOT NULL DEFAULT 0,
+ identifier VARCHAR(20),
+ created_by_agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
+ assignee_user_id VARCHAR(255),
+ parent_id TEXT REFERENCES team_tasks(id) ON DELETE SET NULL,
+ chat_id VARCHAR(255) DEFAULT '',
+ locked_at TEXT,
+ lock_expires_at TEXT,
+ progress_percent INT DEFAULT 0 CHECK (progress_percent BETWEEN 0 AND 100),
+ progress_step TEXT,
+ followup_at TEXT,
+ followup_count INT NOT NULL DEFAULT 0,
+ followup_max INT NOT NULL DEFAULT 0,
+ followup_message TEXT,
+ followup_channel VARCHAR(60),
+ followup_chat_id VARCHAR(255),
+ confidence_score REAL,
+ comment_count INT NOT NULL DEFAULT 0,
+ attachment_count INT NOT NULL DEFAULT 0,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_team_tasks_team ON team_tasks(team_id);
+CREATE INDEX IF NOT EXISTS idx_team_tasks_status ON team_tasks(team_id, status);
+CREATE INDEX IF NOT EXISTS idx_team_tasks_user_scope ON team_tasks(team_id, user_id) WHERE user_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_tt_parent ON team_tasks(parent_id) WHERE parent_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_tt_scope ON team_tasks(team_id, channel, chat_id);
+CREATE INDEX IF NOT EXISTS idx_tt_type ON team_tasks(team_id, task_type);
+CREATE INDEX IF NOT EXISTS idx_tt_lock ON team_tasks(lock_expires_at) WHERE lock_expires_at IS NOT NULL AND status = 'in_progress';
+CREATE UNIQUE INDEX IF NOT EXISTS idx_tt_identifier ON team_tasks(team_id, identifier) WHERE identifier IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_tt_followup ON team_tasks(followup_at) WHERE followup_at IS NOT NULL AND status = 'in_progress';
+-- idx_tt_blocked_by (GIN on array) omitted: Go code handles JSON array filtering
+CREATE INDEX IF NOT EXISTS idx_tt_owner_status ON team_tasks(team_id, owner_agent_id, status);
+CREATE INDEX IF NOT EXISTS idx_team_tasks_tenant ON team_tasks(tenant_id);
+
+-- ============================================================
+-- Table: team_task_comments
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS team_task_comments (
+ id TEXT NOT NULL PRIMARY KEY,
+ task_id TEXT NOT NULL REFERENCES team_tasks(id) ON DELETE CASCADE,
+ agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
+ user_id VARCHAR(255),
+ content TEXT NOT NULL,
+ metadata TEXT DEFAULT '{}',
+ comment_type VARCHAR(20) NOT NULL DEFAULT 'note',
+ confidence_score REAL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_ttc_task ON team_task_comments(task_id);
+CREATE INDEX IF NOT EXISTS idx_team_task_comments_tenant ON team_task_comments(tenant_id);
+
+-- ============================================================
+-- Table: team_task_events
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS team_task_events (
+ id TEXT NOT NULL PRIMARY KEY,
+ task_id TEXT NOT NULL REFERENCES team_tasks(id) ON DELETE CASCADE,
+ event_type VARCHAR(30) NOT NULL,
+ actor_type VARCHAR(10) NOT NULL,
+ actor_id VARCHAR(255) NOT NULL,
+ data TEXT,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_tte_task ON team_task_events(task_id);
+CREATE INDEX IF NOT EXISTS idx_team_task_events_tenant ON team_task_events(tenant_id);
+
+-- ============================================================
+-- Table: team_task_attachments
+-- (new path-based version from migration 24)
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS team_task_attachments (
+ id TEXT NOT NULL PRIMARY KEY,
+ task_id TEXT NOT NULL REFERENCES team_tasks(id) ON DELETE CASCADE,
+ team_id TEXT NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE,
+ chat_id VARCHAR(255) NOT NULL DEFAULT '',
+ path TEXT NOT NULL,
+ file_size BIGINT NOT NULL DEFAULT 0,
+ mime_type VARCHAR(100) DEFAULT '',
+ created_by_agent_id TEXT REFERENCES agents(id),
+ created_by_sender_id VARCHAR(255) DEFAULT '',
+ metadata TEXT NOT NULL DEFAULT '{}',
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(task_id, path)
+);
+
+CREATE INDEX IF NOT EXISTS idx_tta_task ON team_task_attachments(task_id);
+CREATE INDEX IF NOT EXISTS idx_tta_team ON team_task_attachments(team_id);
+CREATE INDEX IF NOT EXISTS idx_team_task_attachments_tenant ON team_task_attachments(tenant_id);
+
+-- ============================================================
+-- Table: team_user_grants
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS team_user_grants (
+ id TEXT NOT NULL PRIMARY KEY,
+ team_id TEXT NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL,
+ role VARCHAR(50) NOT NULL DEFAULT 'viewer',
+ granted_by VARCHAR(255),
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(team_id, user_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_team_user_grants_user ON team_user_grants(user_id);
+CREATE INDEX IF NOT EXISTS idx_team_user_grants_team ON team_user_grants(team_id);
+CREATE INDEX IF NOT EXISTS idx_team_user_grants_tenant ON team_user_grants(tenant_id);
+
+-- ============================================================
+-- Table: kg_entities
+-- Note: embedding (vector) column omitted
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS kg_entities (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL DEFAULT '',
+ external_id VARCHAR(255) NOT NULL,
+ name TEXT NOT NULL,
+ entity_type VARCHAR(100) NOT NULL,
+ description TEXT DEFAULT '',
+ properties TEXT DEFAULT '{}',
+ source_id VARCHAR(255) DEFAULT '',
+ confidence REAL NOT NULL DEFAULT 1.0,
+ team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(agent_id, user_id, external_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_kg_entities_scope ON kg_entities(agent_id, user_id);
+CREATE INDEX IF NOT EXISTS idx_kg_entities_type ON kg_entities(agent_id, user_id, entity_type);
+CREATE INDEX IF NOT EXISTS idx_kg_entities_team ON kg_entities(team_id) WHERE team_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_kg_entities_tenant ON kg_entities(tenant_id);
+
+-- ============================================================
+-- Table: kg_relations
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS kg_relations (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL DEFAULT '',
+ source_entity_id TEXT NOT NULL REFERENCES kg_entities(id) ON DELETE CASCADE,
+ relation_type VARCHAR(200) NOT NULL,
+ target_entity_id TEXT NOT NULL REFERENCES kg_entities(id) ON DELETE CASCADE,
+ confidence REAL NOT NULL DEFAULT 1.0,
+ properties TEXT DEFAULT '{}',
+ team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(agent_id, user_id, source_entity_id, relation_type, target_entity_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_kg_relations_source ON kg_relations(source_entity_id, relation_type);
+CREATE INDEX IF NOT EXISTS idx_kg_relations_target ON kg_relations(target_entity_id);
+CREATE INDEX IF NOT EXISTS idx_kg_relations_team ON kg_relations(team_id) WHERE team_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_kg_relations_tenant ON kg_relations(tenant_id);
+
+-- ============================================================
+-- Table: channel_pending_messages
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS channel_pending_messages (
+ id TEXT NOT NULL PRIMARY KEY,
+ channel_name VARCHAR(100) NOT NULL,
+ history_key VARCHAR(200) NOT NULL,
+ sender VARCHAR(255) NOT NULL,
+ sender_id VARCHAR(255) NOT NULL DEFAULT '',
+ body TEXT NOT NULL,
+ platform_msg_id VARCHAR(100) NOT NULL DEFAULT '',
+ is_summary BOOLEAN NOT NULL DEFAULT 0,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_channel_pending_messages_lookup ON channel_pending_messages(channel_name, history_key, created_at);
+CREATE INDEX IF NOT EXISTS idx_channel_pending_messages_tenant ON channel_pending_messages(tenant_id);
+
+-- ============================================================
+-- Table: channel_contacts
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS channel_contacts (
+ id TEXT NOT NULL PRIMARY KEY,
+ channel_type VARCHAR(50) NOT NULL,
+ channel_instance VARCHAR(255),
+ sender_id VARCHAR(255) NOT NULL,
+ user_id VARCHAR(255),
+ display_name VARCHAR(255),
+ username VARCHAR(255),
+ avatar_url TEXT,
+ peer_kind VARCHAR(20),
+ metadata TEXT DEFAULT '{}',
+ merged_id TEXT,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+-- tenant-scoped unique (migration 27 Phase I)
+CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_contacts_tenant_type_sender ON channel_contacts(tenant_id, channel_type, sender_id);
+CREATE INDEX IF NOT EXISTS idx_channel_contacts_instance ON channel_contacts(channel_instance) WHERE channel_instance IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_channel_contacts_merged ON channel_contacts(merged_id) WHERE merged_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_channel_contacts_search ON channel_contacts(display_name, username);
+CREATE INDEX IF NOT EXISTS idx_channel_contacts_tenant ON channel_contacts(tenant_id);
+
+-- ============================================================
+-- Table: activity_logs
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS activity_logs (
+ id TEXT NOT NULL PRIMARY KEY,
+ actor_type VARCHAR(20) NOT NULL,
+ actor_id VARCHAR(255) NOT NULL,
+ action VARCHAR(100) NOT NULL,
+ entity_type VARCHAR(50),
+ entity_id VARCHAR(255),
+ details TEXT,
+ ip_address VARCHAR(45),
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_activity_logs_actor ON activity_logs(actor_type, actor_id);
+CREATE INDEX IF NOT EXISTS idx_activity_logs_action ON activity_logs(action);
+CREATE INDEX IF NOT EXISTS idx_activity_logs_entity ON activity_logs(entity_type, entity_id);
+CREATE INDEX IF NOT EXISTS idx_activity_logs_created ON activity_logs(created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_activity_logs_tenant ON activity_logs(tenant_id);
+
+-- ============================================================
+-- Table: usage_snapshots
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS usage_snapshots (
+ id TEXT NOT NULL PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
+ bucket_hour TEXT NOT NULL,
+ agent_id TEXT,
+ provider VARCHAR(50) NOT NULL DEFAULT '',
+ model VARCHAR(200) NOT NULL DEFAULT '',
+ channel VARCHAR(50) NOT NULL DEFAULT '',
+ input_tokens BIGINT NOT NULL DEFAULT 0,
+ output_tokens BIGINT NOT NULL DEFAULT 0,
+ cache_read_tokens BIGINT NOT NULL DEFAULT 0,
+ cache_create_tokens BIGINT NOT NULL DEFAULT 0,
+ thinking_tokens BIGINT NOT NULL DEFAULT 0,
+ total_cost NUMERIC(12,6) NOT NULL DEFAULT 0,
+ request_count INTEGER NOT NULL DEFAULT 0,
+ llm_call_count INTEGER NOT NULL DEFAULT 0,
+ tool_call_count INTEGER NOT NULL DEFAULT 0,
+ error_count INTEGER NOT NULL DEFAULT 0,
+ unique_users INTEGER NOT NULL DEFAULT 0,
+ avg_duration_ms INTEGER NOT NULL DEFAULT 0,
+ memory_docs INTEGER NOT NULL DEFAULT 0,
+ memory_chunks INTEGER NOT NULL DEFAULT 0,
+ kg_entities INTEGER NOT NULL DEFAULT 0,
+ kg_relations INTEGER NOT NULL DEFAULT 0,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_usage_snapshots_bucket ON usage_snapshots(bucket_hour DESC);
+CREATE INDEX IF NOT EXISTS idx_usage_snapshots_agent_bucket ON usage_snapshots(agent_id, bucket_hour DESC);
+CREATE INDEX IF NOT EXISTS idx_usage_snapshots_provider_bucket ON usage_snapshots(provider, bucket_hour DESC) WHERE provider != '';
+CREATE INDEX IF NOT EXISTS idx_usage_snapshots_channel_bucket ON usage_snapshots(channel, bucket_hour DESC) WHERE channel != '';
+-- COALESCE NULLs to sentinel so upsert dedup works (SQLite treats NULL != NULL in unique indexes).
+CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_snapshots_unique ON usage_snapshots(
+ bucket_hour,
+ COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'),
+ COALESCE(provider, ''),
+ COALESCE(model, ''),
+ COALESCE(channel, ''),
+ tenant_id
+);
+CREATE INDEX IF NOT EXISTS idx_usage_snapshots_tenant ON usage_snapshots(tenant_id);
+
+-- ============================================================
+-- Table: builtin_tools
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS builtin_tools (
+ name VARCHAR(100) NOT NULL PRIMARY KEY,
+ display_name VARCHAR(255) NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ category VARCHAR(50) NOT NULL DEFAULT 'general',
+ enabled BOOLEAN NOT NULL DEFAULT 1,
+ settings TEXT NOT NULL DEFAULT '{}',
+ requires TEXT DEFAULT '[]',
+ metadata TEXT DEFAULT '{}',
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_builtin_tools_category ON builtin_tools(category);
+
+-- ============================================================
+-- Table: builtin_tool_tenant_configs
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS builtin_tool_tenant_configs (
+ tool_name VARCHAR(100) NOT NULL REFERENCES builtin_tools(name) ON DELETE CASCADE,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ enabled BOOLEAN,
+ settings TEXT,
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ PRIMARY KEY (tool_name, tenant_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_builtin_tool_tenant_configs_tenant ON builtin_tool_tenant_configs(tenant_id);
+
+-- ============================================================
+-- Table: secure_cli_binaries
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS secure_cli_binaries (
+ id TEXT NOT NULL PRIMARY KEY,
+ binary_name TEXT NOT NULL,
+ binary_path TEXT,
+ description TEXT NOT NULL DEFAULT '',
+ encrypted_env BLOB NOT NULL,
+ deny_args TEXT NOT NULL DEFAULT '[]',
+ deny_verbose TEXT NOT NULL DEFAULT '[]',
+ timeout_seconds INTEGER NOT NULL DEFAULT 30,
+ tips TEXT NOT NULL DEFAULT '',
+ agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE,
+ enabled BOOLEAN NOT NULL DEFAULT 1,
+ created_by TEXT NOT NULL DEFAULT '',
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_secure_cli_binary_name ON secure_cli_binaries(binary_name);
+CREATE INDEX IF NOT EXISTS idx_secure_cli_agent_id ON secure_cli_binaries(agent_id) WHERE agent_id IS NOT NULL;
+-- COALESCE(agent_id, '') simulates the PG COALESCE(agent_id, null-sentinel) unique constraint
+CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_unique_binary_agent ON secure_cli_binaries(binary_name, COALESCE(agent_id, ''));
+CREATE INDEX IF NOT EXISTS idx_secure_cli_binaries_tenant ON secure_cli_binaries(tenant_id);
+
+-- ============================================================
+-- Table: api_keys
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS api_keys (
+ id TEXT NOT NULL PRIMARY KEY,
+ name VARCHAR(100) NOT NULL,
+ prefix VARCHAR(8) NOT NULL,
+ key_hash VARCHAR(64) NOT NULL UNIQUE,
+ scopes TEXT NOT NULL DEFAULT '[]',
+ expires_at TEXT,
+ last_used_at TEXT,
+ revoked BOOLEAN NOT NULL DEFAULT 0,
+ created_by VARCHAR(255),
+ owner_id VARCHAR(255),
+ tenant_id TEXT REFERENCES tenants(id),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash) WHERE NOT revoked;
+CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(prefix);
+CREATE INDEX IF NOT EXISTS idx_api_keys_owner_id ON api_keys(owner_id) WHERE owner_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_api_keys_tenant ON api_keys(tenant_id) WHERE tenant_id IS NOT NULL;
+
+-- ============================================================
+-- Table: agent_heartbeats
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS agent_heartbeats (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_id TEXT NOT NULL UNIQUE REFERENCES agents(id) ON DELETE CASCADE,
+ enabled BOOLEAN NOT NULL DEFAULT 0,
+ interval_sec INT NOT NULL DEFAULT 1800,
+ prompt TEXT,
+ provider_id TEXT REFERENCES llm_providers(id),
+ model VARCHAR(200),
+ isolated_session BOOLEAN NOT NULL DEFAULT 1,
+ light_context BOOLEAN NOT NULL DEFAULT 0,
+ ack_max_chars INT NOT NULL DEFAULT 300,
+ max_retries INT NOT NULL DEFAULT 2,
+ active_hours_start VARCHAR(5),
+ active_hours_end VARCHAR(5),
+ timezone TEXT,
+ channel VARCHAR(50),
+ chat_id TEXT,
+ next_run_at TEXT,
+ last_run_at TEXT,
+ last_status VARCHAR(20),
+ last_error TEXT,
+ run_count INT NOT NULL DEFAULT 0,
+ suppress_count INT NOT NULL DEFAULT 0,
+ metadata TEXT DEFAULT '{}',
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_heartbeats_due ON agent_heartbeats(next_run_at) WHERE enabled = 1 AND next_run_at IS NOT NULL;
+
+-- ============================================================
+-- Table: heartbeat_run_logs
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS heartbeat_run_logs (
+ id TEXT NOT NULL PRIMARY KEY,
+ heartbeat_id TEXT NOT NULL REFERENCES agent_heartbeats(id) ON DELETE CASCADE,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ status VARCHAR(20) NOT NULL,
+ summary TEXT,
+ error TEXT,
+ duration_ms INT,
+ input_tokens INT DEFAULT 0,
+ output_tokens INT DEFAULT 0,
+ skip_reason VARCHAR(50),
+ metadata TEXT DEFAULT '{}',
+ ran_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_hb_logs_heartbeat ON heartbeat_run_logs(heartbeat_id, ran_at DESC);
+CREATE INDEX IF NOT EXISTS idx_hb_logs_agent ON heartbeat_run_logs(agent_id, ran_at DESC);
+
+-- ============================================================
+-- Table: agent_config_permissions
+-- (scope widened to VARCHAR(255) in migration 23;
+-- includes migrated group_file_writers rows)
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS agent_config_permissions (
+ id TEXT NOT NULL PRIMARY KEY,
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
+ scope VARCHAR(255) NOT NULL,
+ config_type VARCHAR(50) NOT NULL,
+ user_id VARCHAR(255) NOT NULL,
+ permission VARCHAR(10) NOT NULL,
+ granted_by VARCHAR(255),
+ metadata TEXT DEFAULT '{}',
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE(agent_id, scope, config_type, user_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_acp_lookup ON agent_config_permissions(agent_id, scope, config_type);
+CREATE INDEX IF NOT EXISTS idx_agent_config_permissions_tenant ON agent_config_permissions(tenant_id);
+
+-- ============================================================
+-- Table: skill_tenant_configs (already defined above)
+-- Table: builtin_tool_tenant_configs (already defined above)
+-- ============================================================
+
+-- ============================================================
+-- Table: system_configs
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS system_configs (
+ key VARCHAR(100) NOT NULL,
+ value TEXT NOT NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ PRIMARY KEY (key, tenant_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_system_configs_tenant ON system_configs(tenant_id);
diff --git a/internal/store/sqlitestore/scope.go b/internal/store/sqlitestore/scope.go
new file mode 100644
index 00000000..324090a1
--- /dev/null
+++ b/internal/store/sqlitestore/scope.go
@@ -0,0 +1,49 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// scopeClause extracts QueryScope from context and generates SQLite WHERE conditions
+// using ? placeholders (instead of PG's $N positional params).
+func scopeClause(ctx context.Context) (clause string, args []any, err error) {
+ scope, err := store.ScopeFromContext(ctx)
+ if err != nil {
+ return "", nil, err
+ }
+ clause = " AND tenant_id = ?"
+ args = []any{scope.TenantID}
+
+ if scope.ProjectID != nil {
+ clause += " AND project_id = ?"
+ args = append(args, *scope.ProjectID)
+ }
+ return clause, args, nil
+}
+
+// scopeClauseAlias is like scopeClause but qualifies columns with a table alias.
+// SECURITY: alias is interpolated — callers MUST pass hardcoded string literals only.
+func scopeClauseAlias(ctx context.Context, alias string) (clause string, args []any, err error) {
+ for _, c := range alias {
+ if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
+ return "", nil, fmt.Errorf("invalid table alias: %q", alias)
+ }
+ }
+ scope, err := store.ScopeFromContext(ctx)
+ if err != nil {
+ return "", nil, err
+ }
+ clause = " AND " + alias + ".tenant_id = ?"
+ args = []any{scope.TenantID}
+
+ if scope.ProjectID != nil {
+ clause += " AND " + alias + ".project_id = ?"
+ args = append(args, *scope.ProjectID)
+ }
+ return clause, args, nil
+}
diff --git a/internal/store/sqlitestore/sessions.go b/internal/store/sqlitestore/sessions.go
new file mode 100644
index 00000000..23bde44d
--- /dev/null
+++ b/internal/store/sqlitestore/sessions.go
@@ -0,0 +1,333 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "maps"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/providers"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteSessionStore implements store.SessionStore backed by SQLite.
+type SQLiteSessionStore struct {
+ db *sql.DB
+ mu sync.RWMutex
+ cache map[string]*store.SessionData
+ // OnDelete is called with the session key when a session is deleted.
+ OnDelete func(sessionKey string)
+}
+
+func NewSQLiteSessionStore(db *sql.DB) *SQLiteSessionStore {
+ // No migrateLegacyWSKeys — SQLite has no regexp_replace.
+ return &SQLiteSessionStore{db: db, cache: make(map[string]*store.SessionData)}
+}
+
+// sessionCacheKey prefixes session key with tenant UUID to prevent cross-tenant cache collisions.
+func sessionCacheKey(ctx context.Context, key string) string {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ return tid.String() + ":" + key
+}
+
+func (s *SQLiteSessionStore) GetOrCreate(ctx context.Context, key string) *store.SessionData {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ return cached
+ }
+
+ data := s.loadFromDB(ctx, key)
+ if data != nil {
+ s.cache[sessionCacheKey(ctx, key)] = data
+ return data
+ }
+
+ now := time.Now()
+ data = &store.SessionData{
+ Key: key,
+ Messages: []providers.Message{},
+ Created: now,
+ Updated: now,
+ }
+
+ // Extract team_id from team session keys (agent:{agentId}:team:{teamId}:{chatId}).
+ var teamID *uuid.UUID
+ if parts := strings.SplitN(key, ":", 5); len(parts) >= 4 && parts[2] == "team" {
+ if tid, err := uuid.Parse(parts[3]); err == nil {
+ teamID = &tid
+ data.TeamID = teamID
+ }
+ }
+ s.cache[sessionCacheKey(ctx, key)] = data
+
+ msgsJSON, _ := json.Marshal([]providers.Message{})
+ s.db.ExecContext(ctx,
+ `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, team_id, tenant_id)
+ VALUES (?,?,?,?,?,?,?) ON CONFLICT (tenant_id, session_key) DO NOTHING`,
+ uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, teamID, tenantIDForInsert(ctx),
+ )
+
+ return data
+}
+
+// Get returns the session if it exists (cache or DB), nil otherwise. Never creates.
+func (s *SQLiteSessionStore) Get(ctx context.Context, key string) *store.SessionData {
+ s.mu.RLock()
+ if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ s.mu.RUnlock()
+ return cached
+ }
+ s.mu.RUnlock()
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ return cached
+ }
+
+ data := s.loadFromDB(ctx, key)
+ if data != nil {
+ s.cache[sessionCacheKey(ctx, key)] = data
+ }
+ return data
+}
+
+func (s *SQLiteSessionStore) AddMessage(ctx context.Context, key string, msg providers.Message) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if msg.CreatedAt == nil {
+ now := time.Now().UTC()
+ msg.CreatedAt = &now
+ }
+
+ data := s.getOrInit(ctx, key)
+ data.Messages = append(data.Messages, msg)
+ data.Updated = time.Now()
+}
+
+func (s *SQLiteSessionStore) GetHistory(ctx context.Context, key string) []providers.Message {
+ s.mu.RLock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ msgs := make([]providers.Message, len(data.Messages))
+ copy(msgs, data.Messages)
+ s.mu.RUnlock()
+ return msgs
+ }
+ s.mu.RUnlock()
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ msgs := make([]providers.Message, len(data.Messages))
+ copy(msgs, data.Messages)
+ return msgs
+ }
+
+ data := s.loadFromDB(ctx, key)
+ if data == nil {
+ return nil
+ }
+ s.cache[sessionCacheKey(ctx, key)] = data
+ msgs := make([]providers.Message, len(data.Messages))
+ copy(msgs, data.Messages)
+ return msgs
+}
+
+func (s *SQLiteSessionStore) GetSummary(ctx context.Context, key string) string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ return data.Summary
+ }
+ return ""
+}
+
+func (s *SQLiteSessionStore) SetSummary(ctx context.Context, key, summary string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ data.Summary = summary
+ data.Updated = time.Now()
+ }
+}
+
+func (s *SQLiteSessionStore) GetLabel(ctx context.Context, key string) string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ return data.Label
+ }
+ return ""
+}
+
+func (s *SQLiteSessionStore) SetLabel(ctx context.Context, key, label string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ data.Label = label
+ data.Updated = time.Now()
+ }
+}
+
+func (s *SQLiteSessionStore) GetSessionMetadata(ctx context.Context, key string) map[string]string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok && data.Metadata != nil {
+ out := make(map[string]string, len(data.Metadata))
+ maps.Copy(out, data.Metadata)
+ return out
+ }
+ return nil
+}
+
+func (s *SQLiteSessionStore) SetSessionMetadata(ctx context.Context, key string, metadata map[string]string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ data := s.getOrInit(ctx, key)
+ if data.Metadata == nil {
+ data.Metadata = make(map[string]string)
+ }
+ maps.Copy(data.Metadata, metadata)
+ data.Updated = time.Now()
+}
+
+func (s *SQLiteSessionStore) SetAgentInfo(ctx context.Context, key string, agentUUID uuid.UUID, userID string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ data := s.getOrInit(ctx, key)
+ if agentUUID != uuid.Nil {
+ data.AgentUUID = agentUUID
+ }
+ if userID != "" {
+ data.UserID = userID
+ }
+}
+
+func (s *SQLiteSessionStore) UpdateMetadata(ctx context.Context, key, model, provider, channel string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ if model != "" {
+ data.Model = model
+ }
+ if provider != "" {
+ data.Provider = provider
+ }
+ if channel != "" {
+ data.Channel = channel
+ }
+ }
+}
+
+// --- helpers ---
+
+func (s *SQLiteSessionStore) getOrInit(ctx context.Context, key string) *store.SessionData {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ return data
+ }
+
+ data := s.loadFromDB(ctx, key)
+ if data != nil {
+ s.cache[sessionCacheKey(ctx, key)] = data
+ return data
+ }
+
+ now := time.Now()
+ data = &store.SessionData{
+ Key: key,
+ Messages: []providers.Message{},
+ Created: now,
+ Updated: now,
+ }
+ s.cache[sessionCacheKey(ctx, key)] = data
+
+ msgsJSON, _ := json.Marshal([]providers.Message{})
+ s.db.ExecContext(ctx,
+ `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, tenant_id)
+ VALUES (?,?,?,?,?,?) ON CONFLICT (tenant_id, session_key) DO NOTHING`,
+ uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, tenantIDForInsert(ctx),
+ )
+ return data
+}
+
+func (s *SQLiteSessionStore) loadFromDB(ctx context.Context, key string) *store.SessionData {
+ var sessionKey string
+ var msgsJSON []byte
+ var summary, model, provider, channel, label, spawnedBy, userID *string
+ var agentID, teamID *uuid.UUID
+ var inputTokens, outputTokens int64
+ var compactionCount, memoryFlushCompactionCount, spawnDepth int
+ var memoryFlushAt int64
+ createdAt, updatedAt := scanTimePair()
+ var metaJSON *[]byte
+
+ tid := tenantIDForInsert(ctx)
+ err := s.db.QueryRowContext(ctx,
+ `SELECT session_key, messages, summary, model, provider, channel,
+ input_tokens, output_tokens, compaction_count,
+ memory_flush_compaction_count, memory_flush_at,
+ label, spawned_by, spawn_depth, agent_id, user_id,
+ COALESCE(metadata, '{}'), created_at, updated_at, team_id
+ FROM sessions WHERE session_key = ? AND tenant_id = ?`, key, tid,
+ ).Scan(&sessionKey, &msgsJSON, &summary, &model, &provider, &channel,
+ &inputTokens, &outputTokens, &compactionCount,
+ &memoryFlushCompactionCount, &memoryFlushAt,
+ &label, &spawnedBy, &spawnDepth, &agentID, &userID,
+ &metaJSON, createdAt, updatedAt, &teamID)
+ if err != nil {
+ return nil
+ }
+
+ var msgs []providers.Message
+ json.Unmarshal(msgsJSON, &msgs)
+
+ var meta map[string]string
+ if metaJSON != nil {
+ json.Unmarshal(*metaJSON, &meta)
+ }
+
+ return &store.SessionData{
+ Key: sessionKey,
+ Messages: msgs,
+ Summary: derefStr(summary),
+ Created: createdAt.Time,
+ Updated: updatedAt.Time,
+ AgentUUID: derefUUID(agentID),
+ UserID: derefStr(userID),
+ TeamID: teamID,
+ Model: derefStr(model),
+ Provider: derefStr(provider),
+ Channel: derefStr(channel),
+ InputTokens: inputTokens,
+ OutputTokens: outputTokens,
+ CompactionCount: compactionCount,
+ MemoryFlushCompactionCount: memoryFlushCompactionCount,
+ MemoryFlushAt: memoryFlushAt,
+ Label: derefStr(label),
+ SpawnedBy: derefStr(spawnedBy),
+ SpawnDepth: spawnDepth,
+ Metadata: meta,
+ }
+}
+
+func nilSessionUUID(u uuid.UUID) *uuid.UUID {
+ if u == uuid.Nil {
+ return nil
+ }
+ return &u
+}
diff --git a/internal/store/sqlitestore/sessions_list.go b/internal/store/sqlitestore/sessions_list.go
new file mode 100644
index 00000000..c8b0445c
--- /dev/null
+++ b/internal/store/sqlitestore/sessions_list.go
@@ -0,0 +1,246 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/providers"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// buildSessionFilter builds a dynamic WHERE clause from SessionListOpts using ? placeholders.
+func buildSessionFilter(opts store.SessionListOpts, tableAlias string) (string, []any) {
+ prefix := ""
+ if tableAlias != "" {
+ prefix = tableAlias + "."
+ }
+ var conditions []string
+ var args []any
+
+ if opts.AgentID != "" {
+ conditions = append(conditions, prefix+"session_key LIKE ?")
+ args = append(args, "agent:"+opts.AgentID+":%")
+ }
+ if opts.Channel != "" {
+ conditions = append(conditions, prefix+"session_key LIKE ?")
+ args = append(args, "agent:%:"+opts.Channel+":%")
+ }
+ if opts.UserID != "" {
+ conditions = append(conditions, prefix+"user_id = ?")
+ args = append(args, opts.UserID)
+ }
+ if opts.TenantID != uuid.Nil {
+ conditions = append(conditions, prefix+"tenant_id = ?")
+ args = append(args, opts.TenantID)
+ }
+
+ if len(conditions) == 0 {
+ return "", nil
+ }
+ return " WHERE " + strings.Join(conditions, " AND "), args
+}
+
+func (s *SQLiteSessionStore) List(ctx context.Context, agentID string) []store.SessionInfo {
+ var conditions []string
+ var args []any
+
+ if agentID != "" {
+ conditions = append(conditions, "session_key LIKE ?")
+ args = append(args, "agent:"+agentID+":%")
+ }
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ conditions = append(conditions, "tenant_id = ?")
+ args = append(args, tid)
+ }
+ }
+
+ where := ""
+ if len(conditions) > 0 {
+ where = " WHERE " + strings.Join(conditions, " AND ")
+ }
+
+ rows, err := s.db.QueryContext(ctx,
+ "SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions"+where+" ORDER BY updated_at DESC",
+ args...)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+
+ var result []store.SessionInfo
+ for rows.Next() {
+ var key string
+ var msgsJSON []byte
+ stCreated, stUpdated := scanTimePair()
+ var label, channel, userID *string
+ var metaJSON []byte
+ if err := rows.Scan(&key, &msgsJSON, stCreated, stUpdated, &label, &channel, &userID, &metaJSON); err != nil {
+ continue
+ }
+ var msgs []providers.Message
+ json.Unmarshal(msgsJSON, &msgs)
+ var meta map[string]string
+ if len(metaJSON) > 0 {
+ json.Unmarshal(metaJSON, &meta)
+ }
+ result = append(result, store.SessionInfo{
+ Key: key,
+ MessageCount: len(msgs),
+ Created: stCreated.Time,
+ Updated: stUpdated.Time,
+ Label: derefStr(label),
+ Channel: derefStr(channel),
+ UserID: derefStr(userID),
+ Metadata: meta,
+ })
+ }
+ return result
+}
+
+func (s *SQLiteSessionStore) ListPaged(ctx context.Context, opts store.SessionListOpts) store.SessionListResult {
+ limit := opts.Limit
+ if limit <= 0 {
+ limit = 20
+ }
+ offset := max(opts.Offset, 0)
+
+ where, whereArgs := buildSessionFilter(opts, "")
+
+ var total int
+ countQ := "SELECT COUNT(*) FROM sessions" + where
+ if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil {
+ return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: 0}
+ }
+
+ // Use json_array_length (SQLite built-in) instead of jsonb_array_length.
+ selectQ := fmt.Sprintf(`SELECT session_key, json_array_length(messages), created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}')
+ FROM sessions%s ORDER BY updated_at DESC LIMIT ? OFFSET ?`, where)
+ selectArgs := append(append([]any{}, whereArgs...), limit, offset)
+
+ rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...)
+ if err != nil {
+ return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: total}
+ }
+ defer rows.Close()
+
+ var result []store.SessionInfo
+ for rows.Next() {
+ var key string
+ var msgCount int
+ stCreated, stUpdated := scanTimePair()
+ var label, channel, userID *string
+ var metaJSON []byte
+ if err := rows.Scan(&key, &msgCount, stCreated, stUpdated, &label, &channel, &userID, &metaJSON); err != nil {
+ continue
+ }
+ var meta map[string]string
+ if len(metaJSON) > 0 {
+ json.Unmarshal(metaJSON, &meta)
+ }
+ result = append(result, store.SessionInfo{
+ Key: key,
+ MessageCount: msgCount,
+ Created: stCreated.Time,
+ Updated: stUpdated.Time,
+ Label: derefStr(label),
+ Channel: derefStr(channel),
+ UserID: derefStr(userID),
+ Metadata: meta,
+ })
+ }
+ if result == nil {
+ result = []store.SessionInfo{}
+ }
+ return store.SessionListResult{Sessions: result, Total: total}
+}
+
+// ListPagedRich returns enriched session info for API responses (includes model, tokens, agent name).
+func (s *SQLiteSessionStore) ListPagedRich(ctx context.Context, opts store.SessionListOpts) store.SessionListRichResult {
+ limit := opts.Limit
+ if limit <= 0 {
+ limit = 20
+ }
+ offset := max(opts.Offset, 0)
+
+ where, whereArgs := buildSessionFilter(opts, "s")
+
+ var total int
+ countQ := "SELECT COUNT(*) FROM sessions s" + where
+ if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil {
+ return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: 0}
+ }
+
+ // Use json_array_length and length() instead of PG-specific functions.
+ const richCols = `s.session_key, json_array_length(s.messages), s.created_at, s.updated_at,
+ s.label, s.channel, s.user_id, COALESCE(s.metadata, '{}'),
+ s.model, s.provider, s.input_tokens, s.output_tokens,
+ COALESCE(a.display_name, ''),
+ length(s.messages) / 4 + 12000,
+ COALESCE(a.context_window, 200000),
+ s.compaction_count`
+
+ selectQ := fmt.Sprintf(`SELECT %s
+ FROM sessions s LEFT JOIN agents a ON s.agent_id = a.id
+ %s ORDER BY s.updated_at DESC LIMIT ? OFFSET ?`, richCols, where)
+ selectArgs := append(append([]any{}, whereArgs...), limit, offset)
+
+ rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...)
+ if err != nil {
+ return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: total}
+ }
+ defer rows.Close()
+
+ var result []store.SessionInfoRich
+ for rows.Next() {
+ var key string
+ var msgCount int
+ stCreated, stUpdated := scanTimePair()
+ var label, channel, userID *string
+ var metaJSON []byte
+ var model, provider *string
+ var inputTokens, outputTokens int64
+ var agentName string
+ var estimatedTokens, contextWindow, compactionCount int
+ if err := rows.Scan(&key, &msgCount, stCreated, stUpdated, &label, &channel, &userID, &metaJSON,
+ &model, &provider, &inputTokens, &outputTokens, &agentName,
+ &estimatedTokens, &contextWindow, &compactionCount); err != nil {
+ continue
+ }
+ var meta map[string]string
+ if len(metaJSON) > 0 {
+ json.Unmarshal(metaJSON, &meta)
+ }
+ result = append(result, store.SessionInfoRich{
+ SessionInfo: store.SessionInfo{
+ Key: key,
+ MessageCount: msgCount,
+ Created: stCreated.Time,
+ Updated: stUpdated.Time,
+ Label: derefStr(label),
+ Channel: derefStr(channel),
+ UserID: derefStr(userID),
+ Metadata: meta,
+ },
+ Model: derefStr(model),
+ Provider: derefStr(provider),
+ InputTokens: inputTokens,
+ OutputTokens: outputTokens,
+ AgentName: agentName,
+ EstimatedTokens: estimatedTokens,
+ ContextWindow: contextWindow,
+ CompactionCount: compactionCount,
+ })
+ }
+ if result == nil {
+ result = []store.SessionInfoRich{}
+ }
+ return store.SessionListRichResult{Sessions: result, Total: total}
+}
diff --git a/internal/store/sqlitestore/sessions_ops.go b/internal/store/sqlitestore/sessions_ops.go
new file mode 100644
index 00000000..ee98e581
--- /dev/null
+++ b/internal/store/sqlitestore/sessions_ops.go
@@ -0,0 +1,136 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "strings"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/providers"
+)
+
+func (s *SQLiteSessionStore) Save(ctx context.Context, key string) error {
+ s.mu.RLock()
+ data, ok := s.cache[sessionCacheKey(ctx, key)]
+ if !ok {
+ s.mu.RUnlock()
+ return nil
+ }
+ snapshot := *data
+ msgs := make([]providers.Message, len(data.Messages))
+ copy(msgs, data.Messages)
+ snapshot.Messages = msgs
+ s.mu.RUnlock()
+
+ msgsJSON, _ := json.Marshal(snapshot.Messages)
+ metaJSON := []byte("{}")
+ if len(snapshot.Metadata) > 0 {
+ metaJSON, _ = json.Marshal(snapshot.Metadata)
+ }
+
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE sessions SET
+ messages = ?, summary = ?, model = ?, provider = ?, channel = ?,
+ input_tokens = ?, output_tokens = ?, compaction_count = ?,
+ memory_flush_compaction_count = ?, memory_flush_at = ?,
+ label = ?, spawned_by = ?, spawn_depth = ?,
+ agent_id = ?, user_id = ?, metadata = ?, updated_at = ?,
+ team_id = ?
+ WHERE session_key = ? AND tenant_id = ?`,
+ msgsJSON, nilStr(snapshot.Summary), nilStr(snapshot.Model), nilStr(snapshot.Provider), nilStr(snapshot.Channel),
+ snapshot.InputTokens, snapshot.OutputTokens, snapshot.CompactionCount,
+ snapshot.MemoryFlushCompactionCount, snapshot.MemoryFlushAt,
+ nilStr(snapshot.Label), nilStr(snapshot.SpawnedBy), snapshot.SpawnDepth,
+ nilSessionUUID(snapshot.AgentUUID), nilStr(snapshot.UserID), metaJSON, snapshot.Updated,
+ snapshot.TeamID,
+ key, tenantIDForInsert(ctx),
+ )
+ return err
+}
+
+func (s *SQLiteSessionStore) TruncateHistory(ctx context.Context, key string, keepLast int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ if keepLast <= 0 {
+ data.Messages = []providers.Message{}
+ } else if len(data.Messages) > keepLast {
+ data.Messages = data.Messages[len(data.Messages)-keepLast:]
+ }
+ data.Updated = time.Now()
+ }
+}
+
+func (s *SQLiteSessionStore) SetHistory(ctx context.Context, key string, msgs []providers.Message) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ data.Messages = msgs
+ data.Updated = time.Now()
+ }
+}
+
+func (s *SQLiteSessionStore) Reset(ctx context.Context, key string) {
+ s.mu.Lock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ data.Messages = []providers.Message{}
+ data.Summary = ""
+ data.Updated = time.Now()
+ s.mu.Unlock()
+ return
+ }
+ s.mu.Unlock()
+
+ // Session not in cache — clear directly in DB.
+ tid := tenantIDForInsert(ctx)
+ if _, err := s.db.ExecContext(ctx,
+ `UPDATE sessions SET messages = '[]', summary = '', updated_at = ?
+ WHERE session_key = ? AND tenant_id = ?`,
+ time.Now(), key, tid,
+ ); err != nil {
+ slog.Warn("sessions.reset_db_fallback_failed", "key", key, "error", err)
+ }
+}
+
+func (s *SQLiteSessionStore) Delete(ctx context.Context, key string) error {
+ s.mu.Lock()
+ delete(s.cache, sessionCacheKey(ctx, key))
+ s.mu.Unlock()
+
+ if s.OnDelete != nil {
+ s.OnDelete(key)
+ }
+
+ tid := tenantIDForInsert(ctx)
+ _, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE session_key = ? AND tenant_id = ?", key, tid)
+ return err
+}
+
+func (s *SQLiteSessionStore) LastUsedChannel(ctx context.Context, agentID string) (string, string) {
+ prefix := "agent:" + agentID + ":%"
+ tid := tenantIDForInsert(ctx)
+ var sessionKey string
+ err := s.db.QueryRowContext(ctx,
+ `SELECT session_key FROM sessions
+ WHERE session_key LIKE ?
+ AND session_key NOT LIKE ?
+ AND session_key NOT LIKE ?
+ AND tenant_id = ?
+ ORDER BY updated_at DESC LIMIT 1`,
+ prefix,
+ "agent:"+agentID+":cron:%",
+ "agent:"+agentID+":subagent:%",
+ tid,
+ ).Scan(&sessionKey)
+ if err != nil {
+ return "", ""
+ }
+ parts := strings.SplitN(sessionKey, ":", 5)
+ if len(parts) >= 5 {
+ return parts[2], parts[4]
+ }
+ return "", ""
+}
diff --git a/internal/store/sqlitestore/sessions_tokens.go b/internal/store/sqlitestore/sessions_tokens.go
new file mode 100644
index 00000000..81230c47
--- /dev/null
+++ b/internal/store/sqlitestore/sessions_tokens.go
@@ -0,0 +1,96 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "time"
+)
+
+func (s *SQLiteSessionStore) AccumulateTokens(ctx context.Context, key string, input, output int64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ data.InputTokens += input
+ data.OutputTokens += output
+ }
+}
+
+func (s *SQLiteSessionStore) IncrementCompaction(ctx context.Context, key string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ data.CompactionCount++
+ }
+}
+
+func (s *SQLiteSessionStore) GetCompactionCount(ctx context.Context, key string) int {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ return data.CompactionCount
+ }
+ return 0
+}
+
+func (s *SQLiteSessionStore) GetMemoryFlushCompactionCount(ctx context.Context, key string) int {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ return data.MemoryFlushCompactionCount
+ }
+ return -1
+}
+
+func (s *SQLiteSessionStore) SetMemoryFlushDone(ctx context.Context, key string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ data.MemoryFlushCompactionCount = data.CompactionCount
+ data.MemoryFlushAt = time.Now().UnixMilli()
+ }
+}
+
+func (s *SQLiteSessionStore) SetSpawnInfo(ctx context.Context, key, spawnedBy string, depth int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ data.SpawnedBy = spawnedBy
+ data.SpawnDepth = depth
+ }
+}
+
+func (s *SQLiteSessionStore) SetContextWindow(ctx context.Context, key string, cw int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ data.ContextWindow = cw
+ }
+}
+
+func (s *SQLiteSessionStore) GetContextWindow(ctx context.Context, key string) int {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ return data.ContextWindow
+ }
+ return 0
+}
+
+func (s *SQLiteSessionStore) SetLastPromptTokens(ctx context.Context, key string, tokens, msgCount int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ data.LastPromptTokens = tokens
+ data.LastMessageCount = msgCount
+ }
+}
+
+func (s *SQLiteSessionStore) GetLastPromptTokens(ctx context.Context, key string) (int, int) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ return data.LastPromptTokens, data.LastMessageCount
+ }
+ return 0, 0
+}
diff --git a/internal/store/sqlitestore/skills.go b/internal/store/sqlitestore/skills.go
new file mode 100644
index 00000000..c0327209
--- /dev/null
+++ b/internal/store/sqlitestore/skills.go
@@ -0,0 +1,264 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "os"
+ "regexp"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+const defaultSkillsCacheTTL = 5 * time.Minute
+
+// skillFrontmatterRe matches YAML frontmatter (--- delimited) at the start of a file.
+var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---\n?`)
+
+// SQLiteSkillStore implements store.SkillStore backed by SQLite.
+type SQLiteSkillStore struct {
+ db *sql.DB
+ baseDir string
+ mu sync.RWMutex
+ version atomic.Int64
+
+ listCache map[uuid.UUID]*skillListCacheEntry
+ ttl time.Duration
+}
+
+type skillListCacheEntry struct {
+ skills []store.SkillInfo
+ ver int64
+ time time.Time
+}
+
+func NewSQLiteSkillStore(db *sql.DB, baseDir string) *SQLiteSkillStore {
+ return &SQLiteSkillStore{
+ db: db,
+ baseDir: baseDir,
+ listCache: make(map[uuid.UUID]*skillListCacheEntry),
+ ttl: defaultSkillsCacheTTL,
+ }
+}
+
+func (s *SQLiteSkillStore) Version() int64 { return s.version.Load() }
+func (s *SQLiteSkillStore) BumpVersion() { s.version.Store(time.Now().UnixMilli()) }
+func (s *SQLiteSkillStore) Dirs() []string { return []string{s.baseDir} }
+
+func (s *SQLiteSkillStore) ListSkills(ctx context.Context) []store.SkillInfo {
+ currentVer := s.version.Load()
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+
+ s.mu.RLock()
+ if entry := s.listCache[tid]; entry != nil && entry.ver == currentVer && time.Since(entry.time) < s.ttl {
+ result := entry.skills
+ s.mu.RUnlock()
+ return result
+ }
+ s.mu.RUnlock()
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, frontmatter, file_path
+ FROM skills WHERE (status IN ('active', 'archived') OR is_system = 1) AND (is_system = 1 OR tenant_id = ?)
+ ORDER BY name`, tid)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+
+ var result []store.SkillInfo
+ for rows.Next() {
+ var id uuid.UUID
+ var name, slug, visibility, status string
+ var desc *string
+ var tagsJSON []byte
+ var version int
+ var isSystem, enabled bool
+ var depsRaw, fmRaw []byte
+ var filePath *string
+ if err := rows.Scan(&id, &name, &slug, &desc, &visibility, &tagsJSON, &version,
+ &isSystem, &status, &enabled, &depsRaw, &fmRaw, &filePath); err != nil {
+ continue
+ }
+ info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath)
+ info.Visibility = visibility
+ scanJSONStringArray(tagsJSON, &info.Tags)
+ info.IsSystem = isSystem
+ info.Status = status
+ info.Enabled = enabled
+ info.MissingDeps = parseDepsColumn(depsRaw)
+ info.Author = parseFrontmatterAuthor(fmRaw)
+ result = append(result, info)
+ }
+ if err := rows.Err(); err != nil {
+ slog.Warn("ListSkills: rows iteration error", "error", err)
+ return nil
+ }
+
+ s.mu.Lock()
+ s.listCache[tid] = &skillListCacheEntry{skills: result, ver: currentVer, time: time.Now()}
+ s.mu.Unlock()
+
+ return result
+}
+
+func (s *SQLiteSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
+ FROM skills WHERE enabled = 1 AND status != 'deleted' AND (is_system = 1 OR tenant_id = ?)
+ ORDER BY name`, tid)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+ return s.scanSkillInfoList(rows)
+}
+
+func (s *SQLiteSkillStore) ListAllSystemSkills(ctx context.Context) []store.SkillInfo {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
+ FROM skills WHERE is_system = 1 AND enabled = 1 AND status != 'deleted'
+ ORDER BY name`)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+ return s.scanSkillInfoList(rows)
+}
+
+func (s *SQLiteSkillStore) scanSkillInfoList(rows *sql.Rows) []store.SkillInfo {
+ var result []store.SkillInfo
+ for rows.Next() {
+ var id uuid.UUID
+ var name, slug, visibility, status string
+ var desc *string
+ var tagsJSON []byte
+ var version int
+ var isSystem, enabled bool
+ var depsRaw []byte
+ var filePath *string
+ if err := rows.Scan(&id, &name, &slug, &desc, &visibility, &tagsJSON, &version,
+ &isSystem, &status, &enabled, &depsRaw, &filePath); err != nil {
+ continue
+ }
+ info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath)
+ info.Visibility = visibility
+ scanJSONStringArray(tagsJSON, &info.Tags)
+ info.IsSystem = isSystem
+ info.Status = status
+ info.Enabled = enabled
+ info.MissingDeps = parseDepsColumn(depsRaw)
+ result = append(result, info)
+ }
+ if err := rows.Err(); err != nil {
+ slog.Warn("scanSkillInfoList: rows iteration error", "error", err)
+ }
+ return result
+}
+
+func (s *SQLiteSkillStore) StoreMissingDeps(ctx context.Context, id uuid.UUID, missing []string) error {
+ if missing == nil {
+ missing = []string{}
+ }
+ encoded, err := json.Marshal(map[string]any{"missing": missing})
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ `UPDATE skills SET deps = ?, updated_at = ? WHERE id = ? AND is_system = 1`,
+ encoded, time.Now().UTC(), id,
+ )
+ if err == nil {
+ s.BumpVersion()
+ }
+ return err
+}
+
+// --- helpers shared with other skill files ---
+
+func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir string, filePath *string) store.SkillInfo {
+ d := ""
+ if desc != nil {
+ d = *desc
+ }
+ skillDir := fmt.Sprintf("%s/%s/%d", baseDir, slug, version)
+ if filePath != nil && *filePath != "" {
+ skillDir = *filePath
+ }
+ return store.SkillInfo{
+ ID: id,
+ Name: name,
+ Slug: slug,
+ Path: skillDir + "/SKILL.md",
+ BaseDir: skillDir,
+ Source: "managed",
+ Description: d,
+ Version: version,
+ }
+}
+
+func readSkillFile(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ content := strings.ReplaceAll(string(data), "\r\n", "\n")
+ content = strings.ReplaceAll(content, "\r", "\n")
+ content = skillFrontmatterRe.ReplaceAllString(content, "")
+ return content, nil
+}
+
+func parseDepsColumn(raw []byte) []string {
+ if len(raw) == 0 {
+ return nil
+ }
+ var d struct {
+ Missing []string `json:"missing"`
+ }
+ if err := json.Unmarshal(raw, &d); err != nil {
+ return nil
+ }
+ if len(d.Missing) == 0 {
+ return nil
+ }
+ return d.Missing
+}
+
+func parseFrontmatterAuthor(raw []byte) string {
+ if len(raw) == 0 {
+ return ""
+ }
+ var fm map[string]string
+ if err := json.Unmarshal(raw, &fm); err != nil {
+ return ""
+ }
+ return fm["author"]
+}
+
+func marshalFrontmatter(fm map[string]string) []byte {
+ if len(fm) == 0 {
+ return []byte("{}")
+ }
+ b, err := json.Marshal(fm)
+ if err != nil {
+ return []byte("{}")
+ }
+ return b
+}
diff --git a/internal/store/sqlitestore/skills_content.go b/internal/store/sqlitestore/skills_content.go
new file mode 100644
index 00000000..8fc7df91
--- /dev/null
+++ b/internal/store/sqlitestore/skills_content.go
@@ -0,0 +1,291 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+func (s *SQLiteSkillStore) LoadSkill(ctx context.Context, name string) (string, bool) {
+ var slug string
+ var version int
+ var filePath *string
+ q := "SELECT slug, version, file_path FROM skills WHERE slug = ? AND status = 'active'"
+ args := []any{name}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ q += " AND (is_system = 1 OR tenant_id = ?)"
+ args = append(args, tid)
+ }
+ if err := s.db.QueryRowContext(ctx, q, args...).Scan(&slug, &version, &filePath); err != nil {
+ return "", false
+ }
+ info := buildSkillInfo("", "", slug, nil, version, s.baseDir, filePath)
+ content, err := readSkillFile(info.Path)
+ if err != nil {
+ return "", false
+ }
+ return content, true
+}
+
+func (s *SQLiteSkillStore) LoadForContext(ctx context.Context, allowList []string) string {
+ skills := s.FilterSkills(ctx, allowList)
+ if len(skills) == 0 {
+ return ""
+ }
+ var parts []string
+ for _, sk := range skills {
+ content, ok := s.LoadSkill(ctx, sk.Name)
+ if !ok {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("### Skill: %s\n\n%s", sk.Name, content))
+ }
+ if len(parts) == 0 {
+ return ""
+ }
+ var result strings.Builder
+ result.WriteString("## Available Skills\n\n")
+ for i, p := range parts {
+ if i > 0 {
+ result.WriteString("\n\n---\n\n")
+ }
+ result.WriteString(p)
+ }
+ return result.String()
+}
+
+func (s *SQLiteSkillStore) BuildSummary(ctx context.Context, allowList []string) string {
+ skills := s.FilterSkills(ctx, allowList)
+ if len(skills) == 0 {
+ return ""
+ }
+ var result strings.Builder
+ result.WriteString("\n")
+ for _, sk := range skills {
+ result.WriteString(" \n")
+ result.WriteString(fmt.Sprintf(" %s\n", sk.Name))
+ result.WriteString(fmt.Sprintf(" %s\n", sk.Description))
+ result.WriteString(fmt.Sprintf(" %s\n", sk.Path))
+ result.WriteString(" \n")
+ }
+ result.WriteString("")
+ return result.String()
+}
+
+func (s *SQLiteSkillStore) GetSkill(ctx context.Context, name string) (*store.SkillInfo, bool) {
+ var id uuid.UUID
+ var skillName, slug, visibility string
+ var desc *string
+ var tagsJSON []byte
+ var version int
+ var isSystem bool
+ var filePath *string
+ q := "SELECT id, name, slug, description, visibility, tags, version, is_system, file_path FROM skills WHERE slug = ? AND status = 'active'"
+ args := []any{name}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ q += " AND (is_system = 1 OR tenant_id = ?)"
+ args = append(args, tid)
+ }
+ if err := s.db.QueryRowContext(ctx, q, args...).Scan(&id, &skillName, &slug, &desc, &visibility, &tagsJSON, &version, &isSystem, &filePath); err != nil {
+ return nil, false
+ }
+ info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir, filePath)
+ info.Visibility = visibility
+ scanJSONStringArray(tagsJSON, &info.Tags)
+ info.IsSystem = isSystem
+ return &info, true
+}
+
+func (s *SQLiteSkillStore) FilterSkills(ctx context.Context, allowList []string) []store.SkillInfo {
+ all := s.ListSkills(ctx)
+ var filtered []store.SkillInfo
+ if allowList == nil {
+ for _, sk := range all {
+ if sk.Enabled {
+ filtered = append(filtered, sk)
+ }
+ }
+ return filtered
+ }
+ if len(allowList) == 0 {
+ return nil
+ }
+ allowed := make(map[string]bool, len(allowList))
+ for _, name := range allowList {
+ allowed[name] = true
+ }
+ for _, sk := range all {
+ if sk.Enabled && allowed[sk.Slug] {
+ filtered = append(filtered, sk)
+ }
+ }
+ return filtered
+}
+
+// GetSkillByID returns a SkillInfo for any skill by UUID regardless of status.
+func (s *SQLiteSkillStore) GetSkillByID(ctx context.Context, id uuid.UUID) (store.SkillInfo, bool) {
+ var name, slug, visibility, status string
+ var desc *string
+ var tagsJSON, depsRaw []byte
+ var version int
+ var isSystem, enabled bool
+ var filePath *string
+ q := `SELECT name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
+ FROM skills WHERE id = ?`
+ args := []any{id}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ q += " AND (is_system = 1 OR tenant_id = ?)"
+ args = append(args, tid)
+ }
+ if err := s.db.QueryRowContext(ctx, q, args...).Scan(&name, &slug, &desc, &visibility, &tagsJSON,
+ &version, &isSystem, &status, &enabled, &depsRaw, &filePath); err != nil {
+ return store.SkillInfo{}, false
+ }
+ info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath)
+ info.Visibility = visibility
+ scanJSONStringArray(tagsJSON, &info.Tags)
+ info.IsSystem = isSystem
+ info.Status = status
+ info.Enabled = enabled
+ info.MissingDeps = parseDepsColumn(depsRaw)
+ return info, true
+}
+
+func (s *SQLiteSkillStore) GetSkillOwnerID(ctx context.Context, id uuid.UUID) (string, bool) {
+ q := "SELECT owner_id FROM skills WHERE id = ?"
+ args := []any{id}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ q += " AND (is_system = 1 OR tenant_id = ?)"
+ args = append(args, tid)
+ }
+ var ownerID string
+ if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil {
+ return "", false
+ }
+ return ownerID, true
+}
+
+func (s *SQLiteSkillStore) GetSkillOwnerIDBySlug(ctx context.Context, slug string) (string, bool) {
+ q := "SELECT owner_id FROM skills WHERE slug = ? AND status = 'active'"
+ args := []any{slug}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ q += " AND (is_system = 1 OR tenant_id = ?)"
+ args = append(args, tid)
+ }
+ var ownerID string
+ if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil {
+ return "", false
+ }
+ return ownerID, true
+}
+
+// UpsertSystemSkill creates or updates a system skill.
+// Returns (id, changed, actualFilePath, error).
+func (s *SQLiteSkillStore) UpsertSystemSkill(ctx context.Context, p store.SkillCreateParams) (uuid.UUID, bool, string, error) {
+ var existingID uuid.UUID
+ var existingHash *string
+ var existingFilePath string
+ err := s.db.QueryRowContext(ctx,
+ "SELECT id, file_hash, file_path FROM skills WHERE slug = ?", p.Slug,
+ ).Scan(&existingID, &existingHash, &existingFilePath)
+
+ if err == nil {
+ if existingHash != nil && p.FileHash != nil && *existingHash == *p.FileHash {
+ return existingID, false, existingFilePath, nil
+ }
+ if existingHash == nil && p.FileHash != nil {
+ _, _ = s.db.ExecContext(ctx,
+ `UPDATE skills SET file_hash = ?, updated_at = ? WHERE id = ?`,
+ p.FileHash, time.Now().UTC(), existingID,
+ )
+ return existingID, false, existingFilePath, nil
+ }
+ fmJSON := marshalFrontmatter(p.Frontmatter)
+ _, err = s.db.ExecContext(ctx,
+ `UPDATE skills SET name = ?, description = ?, version = ?, frontmatter = ?,
+ file_path = ?, file_size = ?, file_hash = ?, is_system = 1,
+ visibility = 'public', status = ?, updated_at = ?
+ WHERE id = ?`,
+ p.Name, p.Description, p.Version, fmJSON,
+ p.FilePath, p.FileSize, p.FileHash, p.Status, time.Now().UTC(), existingID,
+ )
+ if err != nil {
+ return uuid.Nil, false, "", fmt.Errorf("update system skill: %w", err)
+ }
+ s.BumpVersion()
+ return existingID, true, p.FilePath, nil
+ }
+
+ id := store.GenNewID()
+ fmJSON := marshalFrontmatter(p.Frontmatter)
+ now := time.Now().UTC()
+ _, err = s.db.ExecContext(ctx,
+ `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status,
+ is_system, frontmatter, file_path, file_size, file_hash, tenant_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 'system', 'public', ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)`,
+ id, p.Name, p.Slug, p.Description, p.Version, p.Status,
+ fmJSON, p.FilePath, p.FileSize, p.FileHash, store.MasterTenantID, now, now,
+ )
+ if err != nil {
+ return uuid.Nil, false, "", fmt.Errorf("insert system skill: %w", err)
+ }
+ s.BumpVersion()
+ return id, true, p.FilePath, nil
+}
+
+// ListSystemSkillDirs returns slug->file_path map for all enabled system skills.
+func (s *SQLiteSkillStore) ListSystemSkillDirs(ctx context.Context) map[string]string {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT slug, file_path FROM skills WHERE is_system = 1 AND enabled = 1`)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+ dirs := make(map[string]string)
+ for rows.Next() {
+ var slug, path string
+ if err := rows.Scan(&slug, &path); err != nil {
+ continue
+ }
+ dirs[slug] = path
+ }
+ if err := rows.Err(); err != nil {
+ return nil
+ }
+ return dirs
+}
+
+// IsSystemSkill checks if a skill slug belongs to a system skill.
+func (s *SQLiteSkillStore) IsSystemSkill(slug string) bool {
+ var isSystem bool
+ err := s.db.QueryRow("SELECT is_system FROM skills WHERE slug = ?", slug).Scan(&isSystem)
+ return err == nil && isSystem
+}
+
diff --git a/internal/store/sqlitestore/skills_crud.go b/internal/store/sqlitestore/skills_crud.go
new file mode 100644
index 00000000..ba46eb2c
--- /dev/null
+++ b/internal/store/sqlitestore/skills_crud.go
@@ -0,0 +1,212 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+func (s *SQLiteSkillStore) CreateSkill(name, slug string, description *string, ownerID, visibility string, version int, filePath string, fileSize int64, fileHash *string) error {
+ id := store.GenNewID()
+ now := time.Now().UTC()
+ _, err := s.db.Exec(
+ `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, file_path, file_size, file_hash, tenant_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)`,
+ id, name, slug, description, ownerID, visibility, version, filePath, fileSize, fileHash, store.MasterTenantID, now, now,
+ )
+ if err == nil {
+ s.BumpVersion()
+ }
+ return err
+}
+
+func (s *SQLiteSkillStore) UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error {
+ var err error
+ if store.IsCrossTenant(ctx) {
+ err = execMapUpdate(ctx, s.db, "skills", id, updates)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ err = execMapUpdateWhereTenant(ctx, s.db, "skills", updates, id, tid)
+ }
+ if err != nil {
+ return err
+ }
+ s.BumpVersion()
+ return nil
+}
+
+func (s *SQLiteSkillStore) DeleteSkill(ctx context.Context, id uuid.UUID) error {
+ var isSystem bool
+ if err := s.db.QueryRowContext(ctx, "SELECT is_system FROM skills WHERE id = ?", id).Scan(&isSystem); err != nil {
+ return fmt.Errorf("check skill: %w", err)
+ }
+ if isSystem {
+ return fmt.Errorf("cannot delete system skill")
+ }
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ var skillTenantID uuid.UUID
+ if err := s.db.QueryRowContext(ctx, "SELECT tenant_id FROM skills WHERE id = ?", id).Scan(&skillTenantID); err != nil {
+ return fmt.Errorf("skill not found")
+ }
+ if skillTenantID != tid {
+ return fmt.Errorf("skill not found")
+ }
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ if _, err := tx.ExecContext(ctx, "DELETE FROM skill_agent_grants WHERE skill_id = ?", id); err != nil {
+ return fmt.Errorf("delete skill grants: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx, "DELETE FROM skill_user_grants WHERE skill_id = ?", id); err != nil {
+ return fmt.Errorf("delete skill user grants: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx, "UPDATE skills SET status = 'deleted' WHERE id = ?", id); err != nil {
+ return fmt.Errorf("delete skill: %w", err)
+ }
+
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+ s.BumpVersion()
+ return nil
+}
+
+// CreateSkillManaged creates or updates a skill from upload parameters.
+// SQLite uses a serializable transaction to avoid the race on version calculation.
+func (s *SQLiteSkillStore) CreateSkillManaged(ctx context.Context, p store.SkillCreateParams) (uuid.UUID, error) {
+ if err := store.ValidateUserID(p.OwnerID); err != nil {
+ return uuid.Nil, err
+ }
+
+ fmJSON := marshalFrontmatter(p.Frontmatter)
+
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return uuid.Nil, fmt.Errorf("begin tx: %w", err)
+ }
+ defer tx.Rollback()
+
+ // Compute next version atomically under the transaction lock.
+ var version int
+ if err := tx.QueryRowContext(ctx,
+ "SELECT COALESCE(MAX(version), 0) + 1 FROM skills WHERE slug = ?", p.Slug,
+ ).Scan(&version); err != nil {
+ return uuid.Nil, fmt.Errorf("get next version: %w", err)
+ }
+
+ now := time.Now().UTC()
+
+ // Check for existing skill with same tenant+slug (for upsert).
+ var existingID uuid.UUID
+ err = tx.QueryRowContext(ctx,
+ "SELECT id FROM skills WHERE tenant_id = ? AND slug = ?", tenantID, p.Slug,
+ ).Scan(&existingID)
+
+ var returnedID uuid.UUID
+ if err == nil {
+ // Update existing.
+ _, err = tx.ExecContext(ctx,
+ `UPDATE skills SET name = ?, description = ?, version = ?, frontmatter = ?,
+ file_path = ?, file_size = ?, file_hash = ?,
+ visibility = CASE WHEN status IN ('archived', 'deleted') THEN 'private' ELSE visibility END,
+ status = 'active', updated_at = ? WHERE id = ?`,
+ p.Name, p.Description, version, fmJSON,
+ p.FilePath, p.FileSize, p.FileHash, now, existingID,
+ )
+ if err != nil {
+ return uuid.Nil, fmt.Errorf("update skill: %w", err)
+ }
+ returnedID = existingID
+ } else {
+ // Insert new.
+ newID := store.GenNewID()
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO skills (id, name, slug, description, owner_id, tenant_id, visibility, version, status, frontmatter, file_path, file_size, file_hash, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)`,
+ newID, p.Name, p.Slug, p.Description, p.OwnerID, tenantID, p.Visibility, version,
+ fmJSON, p.FilePath, p.FileSize, p.FileHash, now, now,
+ )
+ if err != nil {
+ return uuid.Nil, fmt.Errorf("insert skill: %w", err)
+ }
+ returnedID = newID
+ }
+
+ if err := tx.Commit(); err != nil {
+ return uuid.Nil, fmt.Errorf("commit: %w", err)
+ }
+
+ s.BumpVersion()
+ return returnedID, nil
+}
+
+func (s *SQLiteSkillStore) GetSkillFilePath(ctx context.Context, id uuid.UUID) (filePath string, slug string, version int, isSystem bool, ok bool) {
+ q := "SELECT file_path, slug, version, is_system FROM skills WHERE id = ? AND status = 'active'"
+ args := []any{id}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ q += " AND (is_system = 1 OR tenant_id = ?)"
+ args = append(args, tid)
+ }
+ err := s.db.QueryRowContext(ctx, q, args...).Scan(&filePath, &slug, &version, &isSystem)
+ return filePath, slug, version, isSystem, err == nil
+}
+
+func (s *SQLiteSkillStore) GetNextVersion(ctx context.Context, slug string) int {
+ var maxVersion int
+ s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM skills WHERE slug = ?", slug).Scan(&maxVersion)
+ return maxVersion + 1
+}
+
+// GetNextVersionLocked returns the next version for a slug.
+// SQLite has no advisory locks; returns a no-op closer.
+func (s *SQLiteSkillStore) GetNextVersionLocked(ctx context.Context, slug string) (int, func() error, error) {
+ return s.GetNextVersion(ctx, slug), func() error { return nil }, nil
+}
+
+func (s *SQLiteSkillStore) ToggleSkill(ctx context.Context, id uuid.UUID, enabled bool) error {
+ now := time.Now().UTC()
+ q := `UPDATE skills SET enabled = ?, updated_at = ? WHERE id = ?`
+ args := []any{enabled, now, id}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += " AND tenant_id = ?"
+ args = append(args, tid)
+ }
+ }
+
+ _, err := s.db.ExecContext(ctx, q, args...)
+ if err == nil {
+ s.BumpVersion()
+ }
+ return err
+}
diff --git a/internal/store/sqlitestore/skills_grants.go b/internal/store/sqlitestore/skills_grants.go
new file mode 100644
index 00000000..7ed634a2
--- /dev/null
+++ b/internal/store/sqlitestore/skills_grants.go
@@ -0,0 +1,234 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SkillGrantInfo is a simplified grant record for API responses.
+type SkillGrantInfo struct {
+ SkillID uuid.UUID `json:"skill_id"`
+ PinnedVersion int `json:"pinned_version"`
+ GrantedBy string `json:"granted_by"`
+}
+
+// GrantToAgent grants a skill to an agent with version pinning.
+func (s *SQLiteSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.UUID, version int, grantedBy string) error {
+ if err := store.ValidateUserID(grantedBy); err != nil {
+ return err
+ }
+
+ // Upsert grant.
+ id := store.GenNewID()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (skill_id, agent_id) DO UPDATE SET pinned_version = excluded.pinned_version`,
+ id, skillID, agentID, version, grantedBy, time.Now().UTC(), tenantIDForInsert(ctx),
+ )
+ if err != nil {
+ return err
+ }
+
+ // Auto-promote: private → internal.
+ _, err = s.db.ExecContext(ctx,
+ `UPDATE skills SET visibility = 'internal', updated_at = ? WHERE id = ? AND visibility = 'private'`,
+ time.Now().UTC(), skillID)
+ if err != nil {
+ slog.Warn("skill_grants: failed to auto-promote visibility", "skill_id", skillID, "error", err)
+ }
+
+ s.BumpVersion()
+ return nil
+}
+
+// RevokeFromAgent revokes a skill grant from an agent.
+func (s *SQLiteSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID uuid.UUID) error {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM skill_agent_grants WHERE skill_id = ? AND agent_id = ?"+tClause,
+ append([]any{skillID, agentID}, tArgs...)...)
+ if err != nil {
+ return err
+ }
+
+ // Auto-demote: internal → private when no grants remain.
+ _, err = s.db.ExecContext(ctx,
+ `UPDATE skills SET visibility = 'private', updated_at = ?
+ WHERE id = ? AND visibility = 'internal'
+ AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = ?)`,
+ time.Now().UTC(), skillID, skillID)
+ if err != nil {
+ slog.Warn("skill_grants: failed to auto-demote visibility", "skill_id", skillID, "error", err)
+ }
+
+ s.BumpVersion()
+ return nil
+}
+
+// ListAgentGrants returns all skill grants for an agent.
+func (s *SQLiteSkillStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]SkillGrantInfo, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ "SELECT skill_id, pinned_version, granted_by FROM skill_agent_grants WHERE agent_id = ?"+tClause,
+ append([]any{agentID}, tArgs...)...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []SkillGrantInfo
+ for rows.Next() {
+ var g SkillGrantInfo
+ if err := rows.Scan(&g.SkillID, &g.PinnedVersion, &g.GrantedBy); err != nil {
+ slog.Warn("skill_grants: scan error in ListAgentGrants", "error", err)
+ continue
+ }
+ result = append(result, g)
+ }
+ return result, rows.Err()
+}
+
+// GrantToUser grants a skill to a user.
+func (s *SQLiteSkillStore) GrantToUser(ctx context.Context, skillID uuid.UUID, userID, grantedBy string) error {
+ if err := store.ValidateUserID(userID); err != nil {
+ return err
+ }
+ if err := store.ValidateUserID(grantedBy); err != nil {
+ return err
+ }
+ id := store.GenNewID()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO skill_user_grants (id, skill_id, user_id, granted_by, created_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT (skill_id, user_id) DO NOTHING`,
+ id, skillID, userID, grantedBy, time.Now().UTC(), tenantIDForInsert(ctx),
+ )
+ return err
+}
+
+// RevokeFromUser revokes a skill grant from a user.
+func (s *SQLiteSkillStore) RevokeFromUser(ctx context.Context, skillID uuid.UUID, userID string) error {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM skill_user_grants WHERE skill_id = ? AND user_id = ?"+tClause,
+ append([]any{skillID, userID}, tArgs...)...)
+ return err
+}
+
+// ListAccessible returns skills accessible to a given agent+user combination.
+func (s *SQLiteSkillStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.SkillInfo, error) {
+ tClause, tArgs, err := scopeClauseAlias(ctx, "s")
+ if err != nil {
+ return nil, err
+ }
+ tenantCond := ""
+ stcJoin := ""
+ stcFilter := ""
+ if len(tArgs) > 0 {
+ tenantCond = " AND (s.is_system = 1 OR s.tenant_id = ?)"
+ stcJoin = " LEFT JOIN skill_tenant_configs stc ON s.id = stc.skill_id AND stc.tenant_id = ?"
+ stcFilter = " AND (stc.enabled IS NULL OR stc.enabled = 1)"
+ }
+
+ // Build args: agentID, userID, [tenantID for tenant cond, tenantID for stc join]
+ queryArgs := []any{agentID, userID}
+ if len(tArgs) > 0 {
+ queryArgs = append(queryArgs, tArgs...) // tenant cond
+ queryArgs = append(queryArgs, tArgs...) // stc join
+ }
+ // Remove tClause (aliased scope) — we handle it manually above.
+ _ = tClause
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT DISTINCT s.name, s.slug, s.description, s.version, s.file_path FROM skills s
+ LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = ?
+ LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND sug.user_id = ?`+stcJoin+`
+ WHERE s.status = 'active'`+tenantCond+stcFilter+` AND (
+ s.is_system = 1
+ OR s.visibility = 'public'
+ OR (s.visibility = 'private' AND s.owner_id = ?)
+ OR (s.visibility = 'internal' AND (sag.id IS NOT NULL OR sug.id IS NOT NULL))
+ )
+ ORDER BY s.name`,
+ append(queryArgs, userID)...,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.SkillInfo
+ for rows.Next() {
+ var name, slug string
+ var desc *string
+ var version int
+ var filePath *string
+ if err := rows.Scan(&name, &slug, &desc, &version, &filePath); err != nil {
+ slog.Warn("skill_grants: scan error in ListAccessible", "error", err)
+ continue
+ }
+ result = append(result, buildSkillInfo("", name, slug, desc, version, s.baseDir, filePath))
+ }
+ return result, rows.Err()
+}
+
+// ListWithGrantStatus returns all active skills with grant status for a specific agent.
+func (s *SQLiteSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid.UUID) ([]store.SkillWithGrantStatus, error) {
+ tClause, tArgs, err := scopeClauseAlias(ctx, "s")
+ if err != nil {
+ return nil, err
+ }
+ tenantCond := ""
+ if len(tArgs) > 0 {
+ tenantCond = " AND (s.is_system = 1 OR s.tenant_id = ?)"
+ }
+ _ = tClause
+
+ queryArgs := []any{agentID}
+ if len(tArgs) > 0 {
+ queryArgs = append(queryArgs, tArgs...)
+ }
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT s.id, s.name, s.slug, COALESCE(s.description, ''), s.visibility, s.version,
+ (sag.id IS NOT NULL) AS granted,
+ sag.pinned_version,
+ s.is_system
+ FROM skills s
+ LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = ?
+ WHERE s.status = 'active'`+tenantCond+`
+ ORDER BY s.name`, queryArgs...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.SkillWithGrantStatus
+ for rows.Next() {
+ var r store.SkillWithGrantStatus
+ if err := rows.Scan(&r.ID, &r.Name, &r.Slug, &r.Description, &r.Visibility,
+ &r.Version, &r.Granted, &r.PinnedVer, &r.IsSystem); err != nil {
+ slog.Warn("skill_grants: scan error in ListWithGrantStatus", "error", err)
+ continue
+ }
+ result = append(result, r)
+ }
+ return result, rows.Err()
+}
diff --git a/internal/store/sqlitestore/snapshots.go b/internal/store/sqlitestore/snapshots.go
new file mode 100644
index 00000000..9f633f5f
--- /dev/null
+++ b/internal/store/sqlitestore/snapshots.go
@@ -0,0 +1,304 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteSnapshotStore implements store.SnapshotStore backed by SQLite.
+type SQLiteSnapshotStore struct {
+ db *sql.DB
+}
+
+func NewSQLiteSnapshotStore(db *sql.DB) *SQLiteSnapshotStore {
+ return &SQLiteSnapshotStore{db: db}
+}
+
+const snapshotFieldCount = 22
+
+// sqliteSnapshotBatchSize limits each INSERT to stay under SQLite's 999-variable limit (999 / 22 ≈ 45 → use 40).
+const sqliteSnapshotBatchSize = 40
+
+func (s *SQLiteSnapshotStore) UpsertSnapshots(ctx context.Context, snapshots []store.UsageSnapshot) error {
+ if len(snapshots) == 0 {
+ return nil
+ }
+ for start := 0; start < len(snapshots); start += sqliteSnapshotBatchSize {
+ end := start + sqliteSnapshotBatchSize
+ if end > len(snapshots) {
+ end = len(snapshots)
+ }
+ if err := s.upsertBatch(ctx, snapshots[start:end]); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *SQLiteSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.UsageSnapshot) error {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+
+ placeholderRow := "(" + strings.Repeat("?, ", snapshotFieldCount-1) + "?)"
+ vals := make([]string, len(snapshots))
+ var args []any
+
+ for i, snap := range snapshots {
+ vals[i] = placeholderRow
+ args = append(args,
+ snap.BucketHour, nilUUID(snap.AgentID), snap.Provider, snap.Model, snap.Channel,
+ snap.InputTokens, snap.OutputTokens, snap.CacheReadTokens, snap.CacheCreateTokens, snap.ThinkingTokens,
+ snap.TotalCost, snap.RequestCount, snap.LLMCallCount, snap.ToolCallCount,
+ snap.ErrorCount, snap.UniqueUsers, snap.AvgDurationMS,
+ snap.MemoryDocs, snap.MemoryChunks, snap.KGEntities, snap.KGRelations,
+ tenantID,
+ )
+ }
+
+ query := `INSERT INTO usage_snapshots (
+ bucket_hour, agent_id, provider, model, channel,
+ input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, thinking_tokens,
+ total_cost, request_count, llm_call_count, tool_call_count,
+ error_count, unique_users, avg_duration_ms,
+ memory_docs, memory_chunks, kg_entities, kg_relations,
+ tenant_id
+ ) VALUES ` + strings.Join(vals, ", ") + `
+ ON CONFLICT (bucket_hour, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), COALESCE(provider, ''), COALESCE(model, ''), COALESCE(channel, ''), tenant_id)
+ DO UPDATE SET
+ input_tokens = excluded.input_tokens,
+ output_tokens = excluded.output_tokens,
+ cache_read_tokens = excluded.cache_read_tokens,
+ cache_create_tokens = excluded.cache_create_tokens,
+ thinking_tokens = excluded.thinking_tokens,
+ total_cost = excluded.total_cost,
+ request_count = excluded.request_count,
+ llm_call_count = excluded.llm_call_count,
+ tool_call_count = excluded.tool_call_count,
+ error_count = excluded.error_count,
+ unique_users = excluded.unique_users,
+ avg_duration_ms = excluded.avg_duration_ms,
+ memory_docs = excluded.memory_docs,
+ memory_chunks = excluded.memory_chunks,
+ kg_entities = excluded.kg_entities,
+ kg_relations = excluded.kg_relations`
+
+ _, err := s.db.ExecContext(ctx, query, args...)
+ return err
+}
+
+func (s *SQLiteSnapshotStore) GetTimeSeries(ctx context.Context, q store.SnapshotQuery) ([]store.SnapshotTimeSeries, error) {
+ bucketExpr := "bucket_hour"
+ if q.GroupBy == "day" {
+ bucketExpr = "strftime('%Y-%m-%d 00:00:00', bucket_hour)"
+ }
+
+ where, args := buildSnapshotWhere(ctx, q)
+
+ query := fmt.Sprintf(`SELECT
+ bucket_time,
+ SUM(input_tokens), SUM(output_tokens),
+ SUM(cache_read_tokens), SUM(cache_create_tokens), SUM(thinking_tokens),
+ SUM(total_cost),
+ SUM(request_count), SUM(llm_call_count), SUM(tool_call_count),
+ SUM(error_count), SUM(unique_users),
+ CASE WHEN SUM(request_count) > 0
+ THEN SUM(avg_duration_ms * request_count) / SUM(request_count)
+ ELSE 0 END,
+ SUM(memory_docs), SUM(memory_chunks),
+ SUM(kg_entities), SUM(kg_relations)
+ FROM (
+ SELECT
+ %s as bucket_time,
+ CASE WHEN provider != '' THEN input_tokens ELSE 0 END as input_tokens,
+ CASE WHEN provider != '' THEN output_tokens ELSE 0 END as output_tokens,
+ CASE WHEN provider != '' THEN cache_read_tokens ELSE 0 END as cache_read_tokens,
+ CASE WHEN provider != '' THEN cache_create_tokens ELSE 0 END as cache_create_tokens,
+ CASE WHEN provider != '' THEN thinking_tokens ELSE 0 END as thinking_tokens,
+ CASE WHEN provider != '' THEN total_cost ELSE 0 END as total_cost,
+ CASE WHEN provider != '' THEN llm_call_count ELSE 0 END as llm_call_count,
+ CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END as request_count,
+ CASE WHEN provider = '' AND model = '' THEN tool_call_count ELSE 0 END as tool_call_count,
+ CASE WHEN provider = '' AND model = '' THEN error_count ELSE 0 END as error_count,
+ CASE WHEN provider = '' AND model = '' THEN unique_users ELSE 0 END as unique_users,
+ CASE WHEN provider = '' AND model = '' THEN avg_duration_ms ELSE 0 END as avg_duration_ms,
+ CASE WHEN provider = '' AND model = '' THEN memory_docs ELSE 0 END as memory_docs,
+ CASE WHEN provider = '' AND model = '' THEN memory_chunks ELSE 0 END as memory_chunks,
+ CASE WHEN provider = '' AND model = '' THEN kg_entities ELSE 0 END as kg_entities,
+ CASE WHEN provider = '' AND model = '' THEN kg_relations ELSE 0 END as kg_relations
+ FROM usage_snapshots
+ %s
+ ) sub
+ GROUP BY bucket_time
+ ORDER BY bucket_time`, bucketExpr, where)
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("get timeseries: %w", err)
+ }
+ defer rows.Close()
+
+ var result []store.SnapshotTimeSeries
+ for rows.Next() {
+ var ts store.SnapshotTimeSeries
+ if err := rows.Scan(
+ &ts.BucketTime,
+ &ts.InputTokens, &ts.OutputTokens,
+ &ts.CacheReadTokens, &ts.CacheCreateTokens, &ts.ThinkingTokens,
+ &ts.TotalCost,
+ &ts.RequestCount, &ts.LLMCallCount, &ts.ToolCallCount,
+ &ts.ErrorCount, &ts.UniqueUsers, &ts.AvgDurationMS,
+ &ts.MemoryDocs, &ts.MemoryChunks,
+ &ts.KGEntities, &ts.KGRelations,
+ ); err != nil {
+ return nil, fmt.Errorf("scan timeseries: %w", err)
+ }
+ result = append(result, ts)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteSnapshotStore) GetBreakdown(ctx context.Context, q store.SnapshotQuery) ([]store.SnapshotBreakdown, error) {
+ groupBy := q.GroupBy
+ if groupBy == "" {
+ groupBy = "provider"
+ }
+
+ var groupCol, orderExpr, extraFilter string
+ switch groupBy {
+ case "provider":
+ groupCol = "provider"
+ orderExpr = "SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END) DESC"
+ extraFilter = " AND provider != '' AND model != ''"
+ case "model":
+ groupCol = "model"
+ orderExpr = "SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END) DESC"
+ extraFilter = " AND provider != '' AND model != ''"
+ case "channel":
+ groupCol = "channel"
+ orderExpr = "SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END) DESC"
+ extraFilter = " AND channel != ''"
+ case "agent":
+ groupCol = "agent_id" // already TEXT in SQLite — no ::TEXT cast needed
+ orderExpr = "SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END) DESC"
+ extraFilter = ""
+ default:
+ groupCol = "provider"
+ orderExpr = "SUM(input_tokens) DESC"
+ extraFilter = " AND provider != '' AND model != ''"
+ }
+
+ where, args := buildSnapshotWhere(ctx, q)
+ if where == "" {
+ where = " WHERE 1=1"
+ }
+ where += extraFilter
+
+ query := fmt.Sprintf(`SELECT
+ %s as key,
+ SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END),
+ SUM(CASE WHEN provider != '' THEN output_tokens ELSE 0 END),
+ SUM(CASE WHEN provider != '' THEN cache_read_tokens ELSE 0 END),
+ SUM(CASE WHEN provider != '' THEN cache_create_tokens ELSE 0 END),
+ SUM(CASE WHEN provider != '' THEN total_cost ELSE 0 END),
+ SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END),
+ SUM(CASE WHEN provider != '' THEN llm_call_count ELSE 0 END),
+ SUM(CASE WHEN provider = '' AND model = '' THEN tool_call_count ELSE 0 END),
+ SUM(CASE WHEN provider = '' AND model = '' THEN error_count ELSE 0 END),
+ CASE WHEN SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END) > 0
+ THEN SUM(CASE WHEN provider = '' AND model = '' THEN avg_duration_ms * request_count ELSE 0 END) /
+ SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END)
+ ELSE 0 END
+ FROM usage_snapshots
+ %s
+ GROUP BY %s
+ ORDER BY %s`, groupCol, where, groupCol, orderExpr)
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("get breakdown: %w", err)
+ }
+ defer rows.Close()
+
+ var result []store.SnapshotBreakdown
+ for rows.Next() {
+ var b store.SnapshotBreakdown
+ if err := rows.Scan(
+ &b.Key,
+ &b.InputTokens, &b.OutputTokens,
+ &b.CacheReadTokens, &b.CacheCreateTokens,
+ &b.TotalCost,
+ &b.RequestCount, &b.LLMCallCount, &b.ToolCallCount,
+ &b.ErrorCount, &b.AvgDurationMS,
+ ); err != nil {
+ return nil, fmt.Errorf("scan breakdown: %w", err)
+ }
+ result = append(result, b)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteSnapshotStore) GetLatestBucket(ctx context.Context) (*time.Time, error) {
+ var nt nullSqliteTime
+ err := s.db.QueryRowContext(ctx, `SELECT MAX(bucket_hour) FROM usage_snapshots`).Scan(&nt)
+ if err != nil {
+ return nil, fmt.Errorf("get latest bucket: %w", err)
+ }
+ if !nt.Valid {
+ return nil, nil
+ }
+ return &nt.Time, nil
+}
+
+// buildSnapshotWhere builds a WHERE clause with ? placeholders for SQLite.
+func buildSnapshotWhere(ctx context.Context, q store.SnapshotQuery) (string, []any) {
+ var conds []string
+ var args []any
+
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID != uuid.Nil {
+ conds = append(conds, "tenant_id = ?")
+ args = append(args, tenantID)
+ }
+ }
+ if !q.From.IsZero() {
+ conds = append(conds, "bucket_hour >= ?")
+ args = append(args, q.From)
+ }
+ if !q.To.IsZero() {
+ conds = append(conds, "bucket_hour < ?")
+ args = append(args, q.To)
+ }
+ if q.AgentID != nil {
+ conds = append(conds, "agent_id = ?")
+ args = append(args, *q.AgentID)
+ }
+ if q.Provider != "" {
+ conds = append(conds, "provider = ?")
+ args = append(args, q.Provider)
+ }
+ if q.Model != "" {
+ conds = append(conds, "model = ?")
+ args = append(args, q.Model)
+ }
+ if q.Channel != "" {
+ conds = append(conds, "channel = ?")
+ args = append(args, q.Channel)
+ }
+
+ if len(conds) == 0 {
+ return "", nil
+ }
+ return " WHERE " + strings.Join(conds, " AND "), args
+}
diff --git a/internal/store/sqlitestore/system-configs.go b/internal/store/sqlitestore/system-configs.go
new file mode 100644
index 00000000..774ab19e
--- /dev/null
+++ b/internal/store/sqlitestore/system-configs.go
@@ -0,0 +1,100 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+)
+
+// SQLiteSystemConfigStore implements store.SystemConfigStore backed by SQLite.
+// Strict tenant isolation — all operations require tenant_id in context (no fallback).
+type SQLiteSystemConfigStore struct {
+ db *sql.DB
+}
+
+func NewSQLiteSystemConfigStore(db *sql.DB) *SQLiteSystemConfigStore {
+ return &SQLiteSystemConfigStore{db: db}
+}
+
+func (s *SQLiteSystemConfigStore) Get(ctx context.Context, key string) (string, error) {
+ tid, err := requireTenantID(ctx)
+ if err != nil {
+ return "", fmt.Errorf("system config get: %w", err)
+ }
+
+ var val string
+ err = s.db.QueryRowContext(ctx,
+ "SELECT value FROM system_configs WHERE key = ? AND tenant_id = ?",
+ key, tid,
+ ).Scan(&val)
+ if err == nil {
+ return val, nil
+ }
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", fmt.Errorf("system config not found: %s", key)
+ }
+ return "", fmt.Errorf("system config get: %w", err)
+}
+
+func (s *SQLiteSystemConfigStore) Set(ctx context.Context, key, value string) error {
+ tid, err := requireTenantID(ctx)
+ if err != nil {
+ slog.Warn("system_config.set: no tenant in context", "key", key)
+ return fmt.Errorf("system config set: %w", err)
+ }
+
+ _, err = s.db.ExecContext(ctx,
+ `INSERT INTO system_configs (key, value, tenant_id, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT (key, tenant_id) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
+ key, value, tid, time.Now(),
+ )
+ return err
+}
+
+func (s *SQLiteSystemConfigStore) Delete(ctx context.Context, key string) error {
+ tid, err := requireTenantID(ctx)
+ if err != nil {
+ return fmt.Errorf("system config delete: %w", err)
+ }
+
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM system_configs WHERE key = ? AND tenant_id = ?",
+ key, tid,
+ )
+ return err
+}
+
+func (s *SQLiteSystemConfigStore) List(ctx context.Context) (map[string]string, error) {
+ tid, err := requireTenantID(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("system config list: %w", err)
+ }
+
+ rows, err := s.db.QueryContext(ctx,
+ "SELECT key, value FROM system_configs WHERE tenant_id = ? ORDER BY key",
+ tid,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := make(map[string]string)
+ for rows.Next() {
+ var k, v string
+ if err := rows.Scan(&k, &v); err != nil {
+ return nil, fmt.Errorf("system config scan: %w", err)
+ }
+ result[k] = v
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("system config list: %w", err)
+ }
+ return result, nil
+}
diff --git a/internal/store/sqlitestore/teams.go b/internal/store/sqlitestore/teams.go
new file mode 100644
index 00000000..126a7909
--- /dev/null
+++ b/internal/store/sqlitestore/teams.go
@@ -0,0 +1,505 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteTeamStore implements store.TeamStore backed by SQLite.
+type SQLiteTeamStore struct {
+ db *sql.DB
+}
+
+// SetEmbeddingProvider is a no-op for SQLite — vector search not supported.
+func (s *SQLiteTeamStore) SetEmbeddingProvider(_ store.EmbeddingProvider) {}
+
+func NewSQLiteTeamStore(db *sql.DB) *SQLiteTeamStore {
+ return &SQLiteTeamStore{db: db}
+}
+
+const teamSelectCols = `id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at`
+
+// ============================================================
+// Team CRUD
+// ============================================================
+
+func (s *SQLiteTeamStore) CreateTeam(ctx context.Context, team *store.TeamData) error {
+ if team.ID == uuid.Nil {
+ team.ID = store.GenNewID()
+ }
+ now := time.Now()
+ team.CreatedAt = now
+ team.UpdatedAt = now
+
+ settings := team.Settings
+ if len(settings) == 0 {
+ settings = json.RawMessage(`{}`)
+ }
+
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO agent_teams (id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ team.ID, team.Name, team.LeadAgentID, team.Description,
+ team.Status, settings, team.CreatedBy, now, now, tenantID,
+ )
+ return err
+}
+
+func (s *SQLiteTeamStore) GetTeam(ctx context.Context, teamID uuid.UUID) (*store.TeamData, error) {
+ if store.IsCrossTenant(ctx) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = ?`, teamID)
+ return scanTeamRow(row)
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, nil
+ }
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = ? AND tenant_id = ?`, teamID, tenantID)
+ d, err := scanTeamRow(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return d, err
+}
+
+func (s *SQLiteTeamStore) UpdateTeam(ctx context.Context, teamID uuid.UUID, updates map[string]any) error {
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "agent_teams", teamID, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "agent_teams", updates, teamID, tid)
+}
+
+func (s *SQLiteTeamStore) DeleteTeam(ctx context.Context, teamID uuid.UUID) error {
+ if store.IsCrossTenant(ctx) {
+ _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = ?`, teamID)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for delete")
+ }
+ _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = ? AND tenant_id = ?`, teamID, tid)
+ return err
+}
+
+func (s *SQLiteTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) {
+ var tenantFilter string
+ var queryArgs []any
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, nil
+ }
+ tenantFilter = " WHERE t.tenant_id = ?"
+ queryArgs = append(queryArgs, tenantID)
+ }
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at,
+ COALESCE(a.agent_key, '') AS lead_agent_key,
+ COALESCE(a.display_name, '') AS lead_display_name
+ FROM agent_teams t
+ LEFT JOIN agents a ON a.id = t.lead_agent_id`+tenantFilter+`
+ ORDER BY t.created_at`, queryArgs...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var teams []store.TeamData
+ teamIndex := map[uuid.UUID]int{}
+ for rows.Next() {
+ var d store.TeamData
+ var desc sql.NullString
+ createdAt, updatedAt := scanTimePair()
+ if err := rows.Scan(
+ &d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status,
+ &d.Settings, &d.CreatedBy, createdAt, updatedAt,
+ &d.LeadAgentKey, &d.LeadDisplayName,
+ ); err != nil {
+ return nil, err
+ }
+ d.CreatedAt = createdAt.Time
+ d.UpdatedAt = updatedAt.Time
+ if desc.Valid {
+ d.Description = desc.String
+ }
+ teamIndex[d.ID] = len(teams)
+ teams = append(teams, d)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ if len(teams) > 0 {
+ mRows, err := s.db.QueryContext(ctx,
+ `SELECT m.team_id, m.agent_id, m.role, m.joined_at,
+ COALESCE(a.agent_key, '') AS agent_key,
+ COALESCE(a.display_name, '') AS display_name,
+ COALESCE(a.frontmatter, '') AS frontmatter,
+ COALESCE(json_extract(a.other_config, '$.emoji'), '') AS emoji
+ FROM agent_team_members m
+ JOIN agents a ON a.id = m.agent_id
+ WHERE a.status = 'active'
+ ORDER BY m.joined_at`)
+ if err != nil {
+ return nil, err
+ }
+ defer mRows.Close()
+
+ for mRows.Next() {
+ var m store.TeamMemberData
+ var joinedAt sqliteTime
+ if err := mRows.Scan(&m.TeamID, &m.AgentID, &m.Role, &joinedAt, &m.AgentKey, &m.DisplayName, &m.Frontmatter, &m.Emoji); err != nil {
+ return nil, err
+ }
+ m.JoinedAt = joinedAt.Time
+ if idx, ok := teamIndex[m.TeamID]; ok {
+ teams[idx].Members = append(teams[idx].Members, m)
+ teams[idx].MemberCount++
+ }
+ }
+ if err := mRows.Err(); err != nil {
+ return nil, err
+ }
+ }
+
+ return teams, nil
+}
+
+// ============================================================
+// Members
+// ============================================================
+
+func (s *SQLiteTeamStore) AddMember(ctx context.Context, teamID, agentID uuid.UUID, role string) error {
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO agent_team_members (team_id, agent_id, role, joined_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT (team_id, agent_id) DO UPDATE SET role = excluded.role`,
+ teamID, agentID, role, time.Now(), tenantIDForInsert(ctx),
+ )
+ return err
+}
+
+func (s *SQLiteTeamStore) RemoveMember(ctx context.Context, teamID, agentID uuid.UUID) error {
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM agent_team_members WHERE team_id = ? AND agent_id = ?`,
+ teamID, agentID,
+ )
+ return err
+}
+
+func (s *SQLiteTeamStore) ListMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) {
+ q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at,
+ COALESCE(a.agent_key, '') AS agent_key,
+ COALESCE(a.display_name, '') AS display_name,
+ COALESCE(a.frontmatter, '') AS frontmatter,
+ COALESCE(json_extract(a.other_config, '$.emoji'), '') AS emoji
+ FROM agent_team_members m
+ JOIN agents a ON a.id = m.agent_id
+ JOIN agent_teams at2 ON at2.id = m.team_id
+ WHERE m.team_id = ? AND a.status = 'active'`
+ args := []any{teamID}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += " AND at2.tenant_id = ?"
+ args = append(args, tid)
+ }
+ }
+ q += ` ORDER BY m.joined_at`
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var members []store.TeamMemberData
+ for rows.Next() {
+ var d store.TeamMemberData
+ var joinedAt sqliteTime
+ if err := rows.Scan(
+ &d.TeamID, &d.AgentID, &d.Role, &joinedAt,
+ &d.AgentKey, &d.DisplayName, &d.Frontmatter, &d.Emoji,
+ ); err != nil {
+ return nil, err
+ }
+ d.JoinedAt = joinedAt.Time
+ members = append(members, d)
+ }
+ return members, rows.Err()
+}
+
+func (s *SQLiteTeamStore) ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) {
+ q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at,
+ COALESCE(a.agent_key, '') AS agent_key,
+ COALESCE(a.display_name, '') AS display_name,
+ COALESCE(a.frontmatter, '') AS frontmatter,
+ COALESCE(json_extract(a.other_config, '$.emoji'), '') AS emoji
+ FROM agent_team_members m
+ JOIN agents a ON a.id = m.agent_id
+ JOIN agent_teams at2 ON at2.id = m.team_id
+ WHERE m.team_id = ? AND a.status = 'active' AND m.role != ?
+ AND NOT EXISTS (
+ SELECT 1 FROM team_tasks tt
+ WHERE tt.owner_agent_id = m.agent_id AND tt.team_id = ? AND tt.status = ?
+ )`
+ args := []any{teamID, store.TeamRoleLead, teamID, store.TeamTaskStatusInProgress}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += " AND at2.tenant_id = ?"
+ args = append(args, tid)
+ }
+ }
+ q += ` ORDER BY m.joined_at`
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var members []store.TeamMemberData
+ for rows.Next() {
+ var d store.TeamMemberData
+ var joinedAt sqliteTime
+ if err := rows.Scan(
+ &d.TeamID, &d.AgentID, &d.Role, &joinedAt,
+ &d.AgentKey, &d.DisplayName, &d.Frontmatter, &d.Emoji,
+ ); err != nil {
+ return nil, err
+ }
+ d.JoinedAt = joinedAt.Time
+ members = append(members, d)
+ }
+ return members, rows.Err()
+}
+
+func (s *SQLiteTeamStore) GetTeamForAgent(ctx context.Context, agentID uuid.UUID) (*store.TeamData, error) {
+ q := `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at
+ FROM agent_teams t
+ WHERE (
+ t.lead_agent_id = ?
+ OR EXISTS (SELECT 1 FROM agent_team_members m WHERE m.team_id = t.id AND m.agent_id = ?)
+ ) AND t.status = ?`
+ args := []any{agentID, agentID, store.TeamStatusActive}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += " AND t.tenant_id = ?"
+ args = append(args, tid)
+ }
+ }
+ q += ` ORDER BY (t.lead_agent_id = ?) DESC LIMIT 1`
+ args = append(args, agentID)
+
+ row := s.db.QueryRowContext(ctx, q, args...)
+ d, err := scanTeamRow(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return d, err
+}
+
+func (s *SQLiteTeamStore) KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit int) ([]string, error) {
+ if limit <= 0 {
+ limit = 100
+ }
+ q := `SELECT DISTINCT s.user_id
+ FROM sessions s
+ JOIN agent_team_members m ON m.agent_id = s.agent_id
+ JOIN agent_teams at2 ON at2.id = m.team_id
+ WHERE m.team_id = ? AND s.user_id != ''`
+ args := []any{teamID}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += " AND at2.tenant_id = ?"
+ args = append(args, tid)
+ }
+ }
+ q += ` ORDER BY s.user_id LIMIT ?`
+ args = append(args, limit)
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var users []string
+ for rows.Next() {
+ var uid string
+ if err := rows.Scan(&uid); err != nil {
+ return nil, err
+ }
+ users = append(users, uid)
+ }
+ return users, rows.Err()
+}
+
+// ============================================================
+// Team user grants
+// ============================================================
+
+func (s *SQLiteTeamStore) GrantTeamAccess(ctx context.Context, teamID uuid.UUID, userID, role, grantedBy string) error {
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO team_user_grants (id, team_id, user_id, role, granted_by, created_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by`,
+ store.GenNewID(), teamID, userID, role, grantedBy, time.Now(), tenantIDForInsert(ctx),
+ )
+ return err
+}
+
+func (s *SQLiteTeamStore) RevokeTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) error {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ `DELETE FROM team_user_grants WHERE team_id = ? AND user_id = ?`+tClause,
+ append([]any{teamID, userID}, tArgs...)...)
+ return err
+}
+
+func (s *SQLiteTeamStore) ListTeamGrants(ctx context.Context, teamID uuid.UUID) ([]store.TeamUserGrant, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, team_id, user_id, role, COALESCE(granted_by, ''), created_at
+ FROM team_user_grants WHERE team_id = ?`+tClause+` ORDER BY created_at DESC`,
+ append([]any{teamID}, tArgs...)...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.TeamUserGrant
+ for rows.Next() {
+ var g store.TeamUserGrant
+ var createdAt sqliteTime
+ if err := rows.Scan(&g.ID, &g.TeamID, &g.UserID, &g.Role, &g.GrantedBy, &createdAt); err != nil {
+ return nil, err
+ }
+ g.CreatedAt = createdAt.Time
+ result = append(result, g)
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteTeamStore) ListUserTeams(ctx context.Context, userID string) ([]store.TeamData, error) {
+ baseQuery := `SELECT ` + teamSelectCols + `
+ FROM agent_teams t
+ WHERE t.status = ?
+ AND EXISTS (SELECT 1 FROM team_user_grants g WHERE g.team_id = t.id AND g.user_id = ?)`
+ args := []any{store.TeamStatusActive, userID}
+
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, nil
+ }
+ baseQuery += ` AND t.tenant_id = ?`
+ args = append(args, tenantID)
+ }
+ baseQuery += ` ORDER BY t.created_at DESC`
+
+ rows, err := s.db.QueryContext(ctx, baseQuery, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var teams []store.TeamData
+ for rows.Next() {
+ var d store.TeamData
+ var desc sql.NullString
+ createdAt, updatedAt := scanTimePair()
+ if err := rows.Scan(
+ &d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status,
+ &d.Settings, &d.CreatedBy, createdAt, updatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ d.CreatedAt = createdAt.Time
+ d.UpdatedAt = updatedAt.Time
+ if desc.Valid {
+ d.Description = desc.String
+ }
+ teams = append(teams, d)
+ }
+ return teams, rows.Err()
+}
+
+func (s *SQLiteTeamStore) HasTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, error) {
+ tClause, tArgs, err := scopeClause(ctx)
+ if err != nil {
+ return false, err
+ }
+ var exists bool
+ err = s.db.QueryRowContext(ctx,
+ `SELECT EXISTS(SELECT 1 FROM team_user_grants WHERE team_id = ? AND user_id = ?`+tClause+`)`,
+ append([]any{teamID, userID}, tArgs...)...,
+ ).Scan(&exists)
+ return exists, err
+}
+
+// GetTeamUnscoped returns a team by ID without tenant filtering. Server-internal only.
+func (s *SQLiteTeamStore) GetTeamUnscoped(ctx context.Context, id uuid.UUID) (*store.TeamData, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = ?`, id)
+ return scanTeamRow(row)
+}
+
+// ============================================================
+// Scan helpers
+// ============================================================
+
+func scanTeamRow(row *sql.Row) (*store.TeamData, error) {
+ var d store.TeamData
+ var desc sql.NullString
+ createdAt, updatedAt := scanTimePair()
+ err := row.Scan(
+ &d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status,
+ &d.Settings, &d.CreatedBy, createdAt, updatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ d.CreatedAt = createdAt.Time
+ d.UpdatedAt = updatedAt.Time
+ if desc.Valid {
+ d.Description = desc.String
+ }
+ return &d, nil
+}
diff --git a/internal/store/sqlitestore/teams_tasks.go b/internal/store/sqlitestore/teams_tasks.go
new file mode 100644
index 00000000..b0e0a86a
--- /dev/null
+++ b/internal/store/sqlitestore/teams_tasks.go
@@ -0,0 +1,558 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// taskLockDuration is how long a claimed task stays locked before stale recovery resets it.
+const taskLockDuration = 30 * time.Minute
+
+// maxListTasksRows caps ListTasks results to prevent unbounded queries.
+const maxListTasksRows = 30
+
+// taskSelectCols is the shared SELECT column list for task queries.
+const taskSelectCols = `t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.user_id, t.channel,
+ t.task_type, t.task_number, COALESCE(t.identifier,''), t.created_by_agent_id, COALESCE(t.assignee_user_id,''), t.parent_id,
+ COALESCE(t.chat_id,''), t.metadata, t.locked_at, t.lock_expires_at, COALESCE(t.progress_percent,0), COALESCE(t.progress_step,''),
+ t.followup_at, COALESCE(t.followup_count,0), COALESCE(t.followup_max,0), COALESCE(t.followup_message,''), COALESCE(t.followup_channel,''), COALESCE(t.followup_chat_id,''),
+ COALESCE(t.comment_count,0), COALESCE(t.attachment_count,0),
+ t.created_at, t.updated_at,
+ COALESCE(a.agent_key, '') AS owner_agent_key,
+ COALESCE(ca.agent_key, '') AS created_by_agent_key`
+
+// taskJoinClause is the shared JOIN clause for task queries.
+const taskJoinClause = `FROM team_tasks t
+ LEFT JOIN agents a ON a.id = t.owner_agent_id
+ LEFT JOIN agents ca ON ca.id = t.created_by_agent_id`
+
+// ============================================================
+// Scopes
+// ============================================================
+
+func (s *SQLiteTeamStore) ListTaskScopes(ctx context.Context, teamID uuid.UUID) ([]store.ScopeEntry, error) {
+ args := []any{teamID}
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = " AND tenant_id = ?"
+ args = append(args, tid)
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT DISTINCT channel, chat_id FROM team_tasks
+ WHERE team_id = ? AND channel IS NOT NULL AND channel != ''`+tenantWhere+`
+ ORDER BY channel, chat_id`, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var scopes []store.ScopeEntry
+ for rows.Next() {
+ var sc store.ScopeEntry
+ if err := rows.Scan(&sc.Channel, &sc.ChatID); err != nil {
+ return nil, err
+ }
+ scopes = append(scopes, sc)
+ }
+ return scopes, rows.Err()
+}
+
+// ============================================================
+// Tasks CRUD
+// ============================================================
+
+func (s *SQLiteTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData) error {
+ if task.ID == uuid.Nil {
+ task.ID = store.GenNewID()
+ }
+ now := time.Now()
+ task.CreatedAt = now
+ task.UpdatedAt = now
+
+ if task.TaskType == "" {
+ task.TaskType = "general"
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Scope task_number per (team_id, chat_id).
+ var taskNumber int
+ err = tx.QueryRowContext(ctx,
+ `SELECT COALESCE(MAX(task_number), 0) + 1 FROM team_tasks WHERE team_id = ? AND COALESCE(chat_id, '') = ?`,
+ task.TeamID, task.ChatID,
+ ).Scan(&taskNumber)
+ if err != nil {
+ return fmt.Errorf("compute task_number: %w", err)
+ }
+ task.TaskNumber = taskNumber
+
+ hex := strings.ReplaceAll(task.ID.String(), "-", "")
+ task.Identifier = fmt.Sprintf("T-%03d-%s", taskNumber, hex[len(hex)-4:])
+
+ var metaJSON []byte
+ if len(task.Metadata) > 0 {
+ metaJSON, _ = json.Marshal(task.Metadata)
+ }
+
+ blockedByJSON := jsonStringArray(uuidSliceToStrings(task.BlockedBy))
+
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO team_tasks (id, team_id, subject, description, status, owner_agent_id, blocked_by, priority, result, user_id, channel,
+ task_type, task_number, identifier, created_by_agent_id, parent_id, chat_id, metadata, locked_at, lock_expires_at, created_at, updated_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ task.ID, task.TeamID, task.Subject, task.Description,
+ task.Status, task.OwnerAgentID, blockedByJSON,
+ task.Priority, task.Result,
+ nilStr(task.UserID), nilStr(task.Channel),
+ task.TaskType, taskNumber, task.Identifier,
+ task.CreatedByAgentID, task.ParentID,
+ nilStr(task.ChatID),
+ metaJSON,
+ task.LockedAt, task.LockExpiresAt,
+ now, now, tenantIDForInsert(ctx),
+ )
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// allowedTaskUpdateCols is the whitelist of columns that UpdateTask accepts.
+var allowedTaskUpdateCols = map[string]bool{
+ "subject": true,
+ "description": true,
+ "priority": true,
+ "assignee_user_id": true,
+ "metadata": true,
+ "blocked_by": true,
+ "updated_at": true,
+}
+
+func (s *SQLiteTeamStore) UpdateTask(ctx context.Context, taskID uuid.UUID, updates map[string]any) error {
+ if len(updates) == 0 {
+ return nil
+ }
+ for col := range updates {
+ if !allowedTaskUpdateCols[col] {
+ return fmt.Errorf("column %q is not allowed in task updates", col)
+ }
+ }
+ // Convert blocked_by []uuid.UUID to JSON string array for SQLite.
+ if v, ok := updates["blocked_by"]; ok {
+ switch tv := v.(type) {
+ case []uuid.UUID:
+ updates["blocked_by"] = jsonStringArray(uuidSliceToStrings(tv))
+ case []string:
+ updates["blocked_by"] = jsonStringArray(tv)
+ }
+ }
+ updates["updated_at"] = time.Now()
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "team_tasks", taskID, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "team_tasks", updates, taskID, tid)
+}
+
+func (s *SQLiteTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string, channel string, chatID string, limit int, offset int) ([]store.TeamTaskData, error) {
+ orderClause := "t.priority DESC, t.created_at"
+ if orderBy == "newest" {
+ orderClause = "t.created_at DESC"
+ }
+
+ statusWhere := ""
+ switch statusFilter {
+ case store.TeamTaskFilterActive:
+ statusWhere = "AND t.status NOT IN ('completed','cancelled')"
+ case store.TeamTaskFilterInReview:
+ statusWhere = "AND t.status = 'in_review'"
+ case store.TeamTaskFilterCompleted:
+ statusWhere = "AND t.status IN ('completed','cancelled')"
+ }
+
+ if limit <= 0 {
+ limit = maxListTasksRows
+ }
+
+ // Scope filter using COALESCE for optional channel/chatID.
+ scopeWhere := "AND (? = '' OR COALESCE(t.channel,'') = ?) AND (? = '' OR COALESCE(t.chat_id,'') = ?)"
+
+ args := []any{teamID, userID, userID, channel, channel, chatID, chatID}
+
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = " AND t.tenant_id = ?"
+ args = append(args, tid)
+ }
+ args = append(args, limit+1, offset)
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+taskSelectCols+`
+ `+taskJoinClause+`
+ WHERE t.team_id = ? AND (? = '' OR t.user_id = ?) `+statusWhere+` `+scopeWhere+tenantWhere+`
+ ORDER BY `+orderClause+`
+ LIMIT ? OFFSET ?`, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTaskRowsJoined(rows)
+}
+
+func (s *SQLiteTeamStore) GetTask(ctx context.Context, taskID uuid.UUID) (*store.TeamTaskData, error) {
+ args := []any{taskID}
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = " AND t.tenant_id = ?"
+ args = append(args, tid)
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+taskSelectCols+`
+ `+taskJoinClause+`
+ WHERE t.id = ?`+tenantWhere, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ tasks, err := scanTaskRowsJoined(rows)
+ if err != nil {
+ return nil, err
+ }
+ if len(tasks) == 0 {
+ return nil, store.ErrTaskNotFound
+ }
+ return &tasks[0], nil
+}
+
+func (s *SQLiteTeamStore) GetTasksByIDs(ctx context.Context, ids []uuid.UUID) ([]store.TeamTaskData, error) {
+ if len(ids) == 0 {
+ return nil, nil
+ }
+ placeholders := strings.Repeat("?,", len(ids))
+ placeholders = placeholders[:len(placeholders)-1]
+ args := make([]any, len(ids))
+ for i, id := range ids {
+ args[i] = id
+ }
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = " AND t.tenant_id = ?"
+ args = append(args, tid)
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+taskSelectCols+`
+ `+taskJoinClause+`
+ WHERE t.id IN (`+placeholders+`)`+tenantWhere, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTaskRowsJoined(rows)
+}
+
+// SearchTasks uses LIKE-based search (SQLite has no tsvector/FTS without extension).
+func (s *SQLiteTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query string, limit int, userID string) ([]store.TeamTaskData, error) {
+ if limit <= 0 {
+ limit = 20
+ }
+ words := strings.Fields(query)
+ if len(words) == 0 {
+ return nil, nil
+ }
+ // Sanitize words.
+ var sanitized []string
+ for _, w := range words {
+ w = strings.Map(func(r rune) rune {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
+ return r
+ }
+ return -1
+ }, w)
+ w = strings.TrimSpace(w)
+ if w != "" {
+ sanitized = append(sanitized, "%"+w+"%")
+ }
+ }
+ if len(sanitized) == 0 {
+ return nil, nil
+ }
+
+ // Build AND-chained LIKE conditions.
+ likeClauses := make([]string, len(sanitized))
+ args := []any{teamID}
+ for i, pat := range sanitized {
+ likeClauses[i] = "(t.subject LIKE ? OR t.description LIKE ?)"
+ args = append(args, pat, pat)
+ }
+ args = append(args, userID, userID)
+
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = " AND t.tenant_id = ?"
+ args = append(args, tid)
+ }
+ args = append(args, limit)
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+taskSelectCols+`
+ `+taskJoinClause+`
+ WHERE t.team_id = ? AND `+strings.Join(likeClauses, " AND ")+`
+ AND (? = '' OR t.user_id = ?)`+tenantWhere+`
+ ORDER BY t.created_at DESC
+ LIMIT ?`, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTaskRowsJoined(rows)
+}
+
+func (s *SQLiteTeamStore) DeleteTask(ctx context.Context, taskID, teamID uuid.UUID) error {
+ args := []any{taskID, teamID}
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = " AND tenant_id = ?"
+ args = append(args, tid)
+ }
+ res, err := s.db.ExecContext(ctx,
+ `DELETE FROM team_tasks WHERE id = ? AND team_id = ? AND status IN ('completed','failed','cancelled')`+tenantWhere,
+ args...)
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return store.ErrTaskNotFound
+ }
+ return nil
+}
+
+func (s *SQLiteTeamStore) DeleteTasks(ctx context.Context, taskIDs []uuid.UUID, teamID uuid.UUID) ([]uuid.UUID, error) {
+ if len(taskIDs) == 0 {
+ return nil, nil
+ }
+ // SQLite doesn't support RETURNING in older versions — do select then delete.
+ placeholders := strings.Repeat("?,", len(taskIDs))
+ placeholders = placeholders[:len(placeholders)-1]
+ args := make([]any, len(taskIDs))
+ for i, id := range taskIDs {
+ args[i] = id
+ }
+ args = append(args, teamID)
+
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = " AND tenant_id = ?"
+ args = append(args, tid)
+ }
+
+ cond := `id IN (` + placeholders + `) AND team_id = ? AND status IN ('completed','failed','cancelled')` + tenantWhere
+
+ // Fetch IDs to delete first.
+ selectRows, err := s.db.QueryContext(ctx, `SELECT id FROM team_tasks WHERE `+cond, args...)
+ if err != nil {
+ return nil, err
+ }
+ var deleted []uuid.UUID
+ for selectRows.Next() {
+ var id uuid.UUID
+ if err := selectRows.Scan(&id); err != nil {
+ selectRows.Close()
+ return deleted, err
+ }
+ deleted = append(deleted, id)
+ }
+ selectRows.Close()
+ if err := selectRows.Err(); err != nil {
+ return nil, err
+ }
+
+ if len(deleted) > 0 {
+ _, err = s.db.ExecContext(ctx, `DELETE FROM team_tasks WHERE `+cond, args...)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return deleted, nil
+}
+
+func (s *SQLiteTeamStore) ListActiveTasksByChatID(ctx context.Context, chatID string) ([]store.TeamTaskData, error) {
+ if chatID == "" {
+ return nil, nil
+ }
+ args := []any{chatID}
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = " AND t.tenant_id = ?"
+ args = append(args, tid)
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+taskSelectCols+`
+ `+taskJoinClause+`
+ WHERE COALESCE(t.chat_id,'') = ?
+ AND t.status IN ('pending','in_progress','blocked','in_review')`+tenantWhere+`
+ ORDER BY t.task_number ASC
+ LIMIT 50`, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTaskRowsJoined(rows)
+}
+
+// ============================================================
+// Scan helpers
+// ============================================================
+
+func scanTaskRowsJoined(rows *sql.Rows) ([]store.TeamTaskData, error) {
+ var tasks []store.TeamTaskData
+ for rows.Next() {
+ var d store.TeamTaskData
+ var desc, result, userID, channel sql.NullString
+ var ownerID, createdByAgentID, parentID *uuid.UUID
+ var blockedByJSON []byte
+ var assigneeUserID, chatID, progressStep, identifier string
+ var metadataJSON []byte
+ var lockedAt, lockExpiresAt, followupAt nullSqliteTime
+ var followupCount, followupMax int
+ var followupMessage, followupChannel, followupChatID string
+ createdAt, updatedAt := scanTimePair()
+ if err := rows.Scan(
+ &d.ID, &d.TeamID, &d.Subject, &desc, &d.Status,
+ &ownerID, &blockedByJSON, &d.Priority, &result,
+ &userID, &channel,
+ &d.TaskType, &d.TaskNumber, &identifier, &createdByAgentID, &assigneeUserID, &parentID,
+ &chatID, &metadataJSON, &lockedAt, &lockExpiresAt, &d.ProgressPercent, &progressStep,
+ &followupAt, &followupCount, &followupMax, &followupMessage, &followupChannel, &followupChatID,
+ &d.CommentCount, &d.AttachmentCount,
+ createdAt, updatedAt,
+ &d.OwnerAgentKey,
+ &d.CreatedByAgentKey,
+ ); err != nil {
+ return nil, err
+ }
+ d.CreatedAt = createdAt.Time
+ d.UpdatedAt = updatedAt.Time
+ if desc.Valid {
+ d.Description = desc.String
+ }
+ if result.Valid {
+ d.Result = &result.String
+ }
+ if userID.Valid {
+ d.UserID = userID.String
+ }
+ if channel.Valid {
+ d.Channel = channel.String
+ }
+ d.OwnerAgentID = ownerID
+ d.Identifier = identifier
+ d.CreatedByAgentID = createdByAgentID
+ d.AssigneeUserID = assigneeUserID
+ d.ParentID = parentID
+ d.ChatID = chatID
+
+ // Decode blocked_by JSON string array → []uuid.UUID.
+ var blockedByStrs []string
+ scanJSONStringArray(blockedByJSON, &blockedByStrs)
+ d.BlockedBy = stringsToUUIDSlice(blockedByStrs)
+
+ if len(metadataJSON) > 0 {
+ _ = json.Unmarshal(metadataJSON, &d.Metadata)
+ }
+ if lockedAt.Valid {
+ d.LockedAt = &lockedAt.Time
+ }
+ if lockExpiresAt.Valid {
+ d.LockExpiresAt = &lockExpiresAt.Time
+ }
+ d.ProgressStep = progressStep
+ if followupAt.Valid {
+ d.FollowupAt = &followupAt.Time
+ }
+ d.FollowupCount = followupCount
+ d.FollowupMax = followupMax
+ d.FollowupMessage = followupMessage
+ d.FollowupChannel = followupChannel
+ d.FollowupChatID = followupChatID
+ tasks = append(tasks, d)
+ }
+ return tasks, rows.Err()
+}
+
+// uuidSliceToStrings converts []uuid.UUID to []string for JSON storage.
+func uuidSliceToStrings(ids []uuid.UUID) []string {
+ if ids == nil {
+ return nil
+ }
+ out := make([]string, len(ids))
+ for i, id := range ids {
+ out[i] = id.String()
+ }
+ return out
+}
+
+// stringsToUUIDSlice converts []string back to []uuid.UUID.
+func stringsToUUIDSlice(strs []string) []uuid.UUID {
+ if strs == nil {
+ return nil
+ }
+ out := make([]uuid.UUID, 0, len(strs))
+ for _, s := range strs {
+ if id, err := uuid.Parse(s); err == nil {
+ out = append(out, id)
+ }
+ }
+ return out
+}
diff --git a/internal/store/sqlitestore/teams_tasks_activity.go b/internal/store/sqlitestore/teams_tasks_activity.go
new file mode 100644
index 00000000..e5ac69a5
--- /dev/null
+++ b/internal/store/sqlitestore/teams_tasks_activity.go
@@ -0,0 +1,679 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// ============================================================
+// Task comments
+// ============================================================
+
+func (s *SQLiteTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTaskCommentData) error {
+ if comment.ID == uuid.Nil {
+ comment.ID = store.GenNewID()
+ }
+ comment.CreatedAt = time.Now()
+ commentType := comment.CommentType
+ if commentType == "" {
+ commentType = "note"
+ }
+ tid := tenantIDForInsert(ctx)
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, comment_type, created_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ comment.ID, comment.TaskID, comment.AgentID,
+ nilStr(comment.UserID),
+ comment.Content, commentType, comment.CreatedAt, tid,
+ )
+ if err != nil {
+ return err
+ }
+ _, _ = s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET comment_count = comment_count + 1 WHERE id = ? AND tenant_id = ?`, comment.TaskID, tid)
+ return nil
+}
+
+func (s *SQLiteTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) {
+ tid := tenantIDForInsert(ctx)
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, c.created_at,
+ COALESCE(a.agent_key, '') AS agent_key
+ FROM team_task_comments c
+ LEFT JOIN agents a ON a.id = c.agent_id
+ WHERE c.task_id = ? AND c.tenant_id = ?
+ ORDER BY c.created_at ASC`, taskID, tid)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTaskCommentRows(rows)
+}
+
+func (s *SQLiteTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UUID, limit int) ([]store.TeamTaskCommentData, error) {
+ tid := tenantIDForInsert(ctx)
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, c.created_at,
+ COALESCE(a.agent_key, '') AS agent_key
+ FROM team_task_comments c
+ LEFT JOIN agents a ON a.id = c.agent_id
+ WHERE c.task_id = ? AND c.tenant_id = ?
+ ORDER BY c.created_at DESC
+ LIMIT ?`, taskID, tid, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ comments, err := scanTaskCommentRows(rows)
+ if err != nil {
+ return nil, err
+ }
+ // Reverse to chronological order (ASC) for display.
+ for i, j := 0, len(comments)-1; i < j; i, j = i+1, j-1 {
+ comments[i], comments[j] = comments[j], comments[i]
+ }
+ return comments, nil
+}
+
+func scanTaskCommentRows(rows *sql.Rows) ([]store.TeamTaskCommentData, error) {
+ var comments []store.TeamTaskCommentData
+ for rows.Next() {
+ var c store.TeamTaskCommentData
+ var agentID *uuid.UUID
+ var userID sql.NullString
+ var createdAt sqliteTime
+ if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CommentType, &createdAt, &c.AgentKey); err != nil {
+ return nil, err
+ }
+ c.CreatedAt = createdAt.Time
+ c.AgentID = agentID
+ if userID.Valid {
+ c.UserID = userID.String
+ }
+ comments = append(comments, c)
+ }
+ return comments, rows.Err()
+}
+
+// ============================================================
+// Audit events
+// ============================================================
+
+func (s *SQLiteTeamStore) RecordTaskEvent(ctx context.Context, event *store.TeamTaskEventData) error {
+ if event.ID == uuid.Nil {
+ event.ID = store.GenNewID()
+ }
+ event.CreatedAt = time.Now()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt, tenantIDForInsert(ctx),
+ )
+ return err
+}
+
+func (s *SQLiteTeamStore) ListTaskEvents(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskEventData, error) {
+ tid := tenantIDForInsert(ctx)
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, task_id, event_type, actor_type, actor_id, data, created_at
+ FROM team_task_events
+ WHERE task_id = ? AND tenant_id = ?
+ ORDER BY created_at ASC`, taskID, tid)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTaskEventRows(rows)
+}
+
+func (s *SQLiteTeamStore) ListTeamEvents(ctx context.Context, teamID uuid.UUID, limit, offset int) ([]store.TeamTaskEventData, error) {
+ if limit <= 0 || limit > 100 {
+ limit = 50
+ }
+ tid := tenantIDForInsert(ctx)
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT e.id, e.task_id, e.event_type, e.actor_type, e.actor_id, e.data, e.created_at
+ FROM team_task_events e
+ JOIN team_tasks t ON t.id = e.task_id
+ WHERE t.team_id = ? AND t.tenant_id = ?
+ ORDER BY e.created_at DESC
+ LIMIT ? OFFSET ?`,
+ teamID, tid, limit, offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTaskEventRows(rows)
+}
+
+func scanTaskEventRows(rows *sql.Rows) ([]store.TeamTaskEventData, error) {
+ var events []store.TeamTaskEventData
+ for rows.Next() {
+ var e store.TeamTaskEventData
+ var data json.RawMessage
+ var createdAt sqliteTime
+ if err := rows.Scan(&e.ID, &e.TaskID, &e.EventType, &e.ActorType, &e.ActorID, &data, &createdAt); err != nil {
+ return nil, err
+ }
+ e.CreatedAt = createdAt.Time
+ e.Data = data
+ events = append(events, e)
+ }
+ return events, rows.Err()
+}
+
+// ============================================================
+// Attachments
+// ============================================================
+
+func (s *SQLiteTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskAttachmentData) error {
+ if att.ID == uuid.Nil {
+ att.ID = store.GenNewID()
+ }
+ att.CreatedAt = time.Now()
+ if len(att.Metadata) == 0 {
+ att.Metadata = json.RawMessage(`{}`)
+ }
+ tid := tenantIDForInsert(ctx)
+ res, err := s.db.ExecContext(ctx,
+ `INSERT INTO team_task_attachments (id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (task_id, path) DO NOTHING`,
+ att.ID, att.TaskID, att.TeamID, att.ChatID, att.Path,
+ att.FileSize, att.MimeType, att.CreatedByAgentID,
+ nilStr(att.CreatedBySenderID),
+ att.Metadata, att.CreatedAt, tid,
+ )
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n > 0 {
+ _, _ = s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET attachment_count = attachment_count + 1 WHERE id = ? AND tenant_id = ?`, att.TaskID, tid)
+ }
+ return nil
+}
+
+func (s *SQLiteTeamStore) GetAttachment(ctx context.Context, attachmentID uuid.UUID) (*store.TeamTaskAttachmentData, error) {
+ var a store.TeamTaskAttachmentData
+ var agentID *uuid.UUID
+ var senderID sql.NullString
+ var metadata json.RawMessage
+ var createdAt sqliteTime
+ tid := tenantIDForInsert(ctx)
+ err := s.db.QueryRowContext(ctx,
+ `SELECT id, task_id, team_id, chat_id, path, file_size, mime_type,
+ created_by_agent_id, created_by_sender_id, metadata, created_at
+ FROM team_task_attachments WHERE id = ? AND tenant_id = ?`, attachmentID, tid,
+ ).Scan(&a.ID, &a.TaskID, &a.TeamID, &a.ChatID, &a.Path, &a.FileSize, &a.MimeType,
+ &agentID, &senderID, &metadata, &createdAt)
+ if err != nil {
+ return nil, err
+ }
+ a.CreatedAt = createdAt.Time
+ a.CreatedByAgentID = agentID
+ if senderID.Valid {
+ a.CreatedBySenderID = senderID.String
+ }
+ a.Metadata = metadata
+ return &a, nil
+}
+
+func (s *SQLiteTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskAttachmentData, error) {
+ tid := tenantIDForInsert(ctx)
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, task_id, team_id, chat_id, path, file_size, mime_type,
+ created_by_agent_id, created_by_sender_id, metadata, created_at
+ FROM team_task_attachments
+ WHERE task_id = ? AND tenant_id = ?
+ ORDER BY created_at ASC`, taskID, tid)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var atts []store.TeamTaskAttachmentData
+ for rows.Next() {
+ var a store.TeamTaskAttachmentData
+ var agentID *uuid.UUID
+ var senderID sql.NullString
+ var metadata json.RawMessage
+ var createdAt sqliteTime
+ if err := rows.Scan(&a.ID, &a.TaskID, &a.TeamID, &a.ChatID, &a.Path, &a.FileSize, &a.MimeType,
+ &agentID, &senderID, &metadata, &createdAt); err != nil {
+ return nil, err
+ }
+ a.CreatedAt = createdAt.Time
+ a.CreatedByAgentID = agentID
+ if senderID.Valid {
+ a.CreatedBySenderID = senderID.String
+ }
+ a.Metadata = metadata
+ atts = append(atts, a)
+ }
+ return atts, rows.Err()
+}
+
+func (s *SQLiteTeamStore) DetachFileFromTask(ctx context.Context, taskID uuid.UUID, path string) error {
+ tid := tenantIDForInsert(ctx)
+ res, err := s.db.ExecContext(ctx,
+ `DELETE FROM team_task_attachments WHERE task_id = ? AND path = ? AND tenant_id = ?`,
+ taskID, path, tid,
+ )
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n > 0 {
+ _, _ = s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET attachment_count = MAX(attachment_count - 1, 0) WHERE id = ? AND tenant_id = ?`, taskID, tid)
+ }
+ return nil
+}
+
+// ============================================================
+// Progress
+// ============================================================
+
+func (s *SQLiteTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uuid.UUID, percent int, step string) error {
+ if percent < 0 || percent > 100 {
+ return fmt.Errorf("progress percent must be 0-100, got %d", percent)
+ }
+ now := time.Now()
+ lockExpires := now.Add(taskLockDuration)
+ tid := tenantIDForInsert(ctx)
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET progress_percent = ?, progress_step = ?, lock_expires_at = ?, updated_at = ?
+ WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
+ percent, step, lockExpires, now,
+ taskID, store.TeamTaskStatusInProgress, teamID, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("task not in progress or not found")
+ }
+ return nil
+}
+
+func (s *SQLiteTeamStore) RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUID) error {
+ now := time.Now()
+ lockExpires := now.Add(taskLockDuration)
+ tid := tenantIDForInsert(ctx)
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET lock_expires_at = ?, updated_at = ?
+ WHERE id = ? AND team_id = ? AND status = ? AND tenant_id = ?`,
+ lockExpires, now,
+ taskID, teamID, store.TeamTaskStatusInProgress, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return fmt.Errorf("task not in progress or not found")
+ }
+ return nil
+}
+
+// ============================================================
+// Stale recovery
+// ============================================================
+
+// v2ActiveTeamJoin is the JOIN that filters to v2 active teams.
+// SQLite uses json_extract instead of PG's ->> operator.
+const v2ActiveTeamJoin = `JOIN agent_teams tm ON tm.id = t.team_id
+ AND tm.status = 'active'
+ AND COALESCE(CAST(json_extract(tm.settings, '$.version') AS INTEGER), 0) >= 2`
+
+func (s *SQLiteTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
+ now := time.Now()
+ // SQLite: UPDATE ... FROM not supported in older versions; use subquery in WHERE.
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL,
+ followup_at = NULL, followup_count = 0, followup_message = NULL,
+ followup_channel = NULL, followup_chat_id = NULL, updated_at = ?
+ WHERE status = ? AND lock_expires_at IS NOT NULL AND lock_expires_at < ?
+ AND team_id IN (
+ SELECT id FROM agent_teams WHERE status = 'active'
+ AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
+ )`,
+ store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress, now,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return s.queryRecoveredTasks(ctx, store.TeamTaskStatusPending)
+}
+
+func (s *SQLiteTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
+ now := time.Now()
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL,
+ followup_at = NULL, followup_count = 0, followup_message = NULL,
+ followup_channel = NULL, followup_chat_id = NULL, updated_at = ?
+ WHERE status = ?
+ AND team_id IN (
+ SELECT id FROM agent_teams WHERE status = 'active'
+ AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
+ )`,
+ store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return s.queryRecoveredTasks(ctx, store.TeamTaskStatusPending)
+}
+
+func (s *SQLiteTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) {
+ now := time.Now()
+ tid := tenantIDForInsert(ctx)
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+taskSelectCols+`
+ `+taskJoinClause+`
+ WHERE t.team_id = ? AND t.tenant_id = ?
+ AND (
+ t.status = ?
+ OR (t.status = ? AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < ?)
+ )
+ ORDER BY t.priority DESC, t.created_at
+ LIMIT ?`,
+ teamID, tid,
+ store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTaskRowsJoined(rows)
+}
+
+func (s *SQLiteTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) {
+ now := time.Now()
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, updated_at = ?
+ WHERE status = ? AND updated_at < ?
+ AND team_id IN (
+ SELECT id FROM agent_teams WHERE status = 'active'
+ AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
+ )`,
+ store.TeamTaskStatusStale, now, store.TeamTaskStatusPending, olderThan,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return s.queryRecoveredTasks(ctx, store.TeamTaskStatusStale)
+}
+
+func (s *SQLiteTeamStore) MarkInReviewStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) {
+ now := time.Now()
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, updated_at = ?
+ WHERE status = ? AND updated_at < ?
+ AND team_id IN (
+ SELECT id FROM agent_teams WHERE status = 'active'
+ AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
+ )`,
+ store.TeamTaskStatusStale, now, store.TeamTaskStatusInReview, olderThan,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return s.queryRecoveredTasks(ctx, store.TeamTaskStatusStale)
+}
+
+// FixOrphanedBlockedTasks unblocks blocked tasks where all blockers have reached terminal status.
+func (s *SQLiteTeamStore) FixOrphanedBlockedTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
+ now := time.Now()
+ // Find blocked tasks where every entry in blocked_by is in terminal status.
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT t.id, t.team_id, t.tenant_id, t.task_number, t.subject,
+ COALESCE(t.channel, ''), COALESCE(t.chat_id, '')
+ FROM team_tasks t
+ JOIN agent_teams tm ON tm.id = t.team_id AND tm.status = 'active'
+ AND COALESCE(CAST(json_extract(tm.settings, '$.version') AS INTEGER), 0) >= 2
+ WHERE t.status = 'blocked'
+ AND t.blocked_by IS NOT NULL AND t.blocked_by != '[]'
+ AND NOT EXISTS (
+ SELECT 1 FROM json_each(t.blocked_by) AS je
+ JOIN team_tasks bt ON bt.id = je.value AND bt.tenant_id = t.tenant_id
+ WHERE bt.status NOT IN (?, ?, ?)
+ )`,
+ store.TeamTaskStatusCompleted, store.TeamTaskStatusFailed, store.TeamTaskStatusCancelled,
+ )
+ if err != nil {
+ return nil, err
+ }
+ infos, err := scanRecoveredTaskInfoRows(rows)
+ rows.Close()
+ if err != nil {
+ return nil, err
+ }
+
+ // Unblock each — clear blocked_by and set pending.
+ for _, info := range infos {
+ _, _ = s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET blocked_by = '[]', status = ?, updated_at = ? WHERE id = ? AND tenant_id = ?`,
+ store.TeamTaskStatusPending, now, info.ID, info.TenantID,
+ )
+ }
+ return infos, nil
+}
+
+// queryRecoveredTasks fetches recently-updated tasks matching the given status for recovery reporting.
+func (s *SQLiteTeamStore) queryRecoveredTasks(ctx context.Context, status string) ([]store.RecoveredTaskInfo, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT t.id, t.team_id, t.tenant_id, t.task_number, t.subject,
+ COALESCE(t.channel, ''), COALESCE(t.chat_id, '')
+ FROM team_tasks t
+ WHERE t.status = ?
+ AND t.updated_at >= ?
+ AND t.team_id IN (
+ SELECT id FROM agent_teams WHERE status = 'active'
+ AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
+ )
+ ORDER BY t.updated_at DESC
+ LIMIT 200`,
+ status, time.Now().Add(-5*time.Second),
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanRecoveredTaskInfoRows(rows)
+}
+
+func scanRecoveredTaskInfoRows(rows interface {
+ Next() bool
+ Scan(...any) error
+ Err() error
+}) ([]store.RecoveredTaskInfo, error) {
+ var out []store.RecoveredTaskInfo
+ for rows.Next() {
+ var info store.RecoveredTaskInfo
+ if err := rows.Scan(&info.ID, &info.TeamID, &info.TenantID, &info.TaskNumber, &info.Subject, &info.Channel, &info.ChatID); err != nil {
+ return nil, err
+ }
+ out = append(out, info)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (s *SQLiteTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error {
+ now := time.Now()
+ tid := tenantIDForInsert(ctx)
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL, result = NULL,
+ progress_percent = NULL, progress_step = NULL, updated_at = ?
+ WHERE id = ? AND team_id = ? AND status IN (?, ?, ?, ?) AND tenant_id = ?`,
+ store.TeamTaskStatusPending, now,
+ taskID, teamID,
+ store.TeamTaskStatusStale, store.TeamTaskStatusFailed,
+ store.TeamTaskStatusCancelled, store.TeamTaskStatusInReview, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("task not available for reset (not stale/failed/cancelled/in_review or wrong team)")
+ }
+ return nil
+}
+
+// ============================================================
+// Follow-up reminders
+// ============================================================
+
+func (s *SQLiteTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error {
+ now := time.Now()
+ tid := tenantIDForInsert(ctx)
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET followup_at = ?, followup_max = ?, followup_message = ?, followup_channel = ?, followup_chat_id = ?, updated_at = ?
+ WHERE id = ? AND team_id = ? AND status = ? AND tenant_id = ?`,
+ followupAt, max, message, channel, chatID, now,
+ taskID, teamID, store.TeamTaskStatusInProgress, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("task not in progress or not found")
+ }
+ return nil
+}
+
+func (s *SQLiteTeamStore) ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error {
+ tid := tenantIDForInsert(ctx)
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, updated_at = ?
+ WHERE id = ? AND tenant_id = ?`,
+ time.Now(), taskID, tid,
+ )
+ return err
+}
+
+func (s *SQLiteTeamStore) ListAllFollowupDueTasks(ctx context.Context) ([]store.TeamTaskData, error) {
+ now := time.Now()
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+taskSelectCols+`
+ `+taskJoinClause+`
+ `+v2ActiveTeamJoin+`
+ WHERE t.followup_at IS NOT NULL
+ AND t.followup_at <= ?
+ AND t.status = ?
+ AND (t.followup_max = 0 OR t.followup_count < t.followup_max)
+ ORDER BY t.followup_at
+ LIMIT 100`,
+ now, store.TeamTaskStatusInProgress,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTaskRowsJoined(rows)
+}
+
+func (s *SQLiteTeamStore) IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error {
+ tid := tenantIDForInsert(ctx)
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET followup_count = followup_count + 1, followup_at = ?, updated_at = ?
+ WHERE id = ? AND tenant_id = ?`,
+ nextAt, time.Now(), taskID, tid,
+ )
+ return err
+}
+
+func (s *SQLiteTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID string) (int, error) {
+ tid := tenantIDForInsert(ctx)
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks
+ SET followup_at = NULL, followup_count = 0, followup_message = NULL,
+ followup_channel = NULL, followup_chat_id = NULL, updated_at = ?
+ WHERE followup_channel = ? AND followup_chat_id = ?
+ AND followup_at IS NOT NULL AND status = ? AND tenant_id = ?`,
+ time.Now(), channel, chatID, store.TeamTaskStatusInProgress, tid,
+ )
+ if err != nil {
+ return 0, err
+ }
+ n, err := res.RowsAffected()
+ return int(n), err
+}
+
+func (s *SQLiteTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid.UUID, channel, chatID string, followupAt time.Time, max int, message string) (int, error) {
+ tid := tenantIDForInsert(ctx)
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks
+ SET followup_at = ?, followup_max = ?, followup_message = ?,
+ followup_channel = ?, followup_chat_id = ?, updated_at = ?
+ WHERE team_id = ?
+ AND status = ?
+ AND followup_at IS NULL
+ AND tenant_id = ?
+ AND (
+ (COALESCE(channel,'') = ? AND COALESCE(chat_id,'') = ?)
+ OR followup_channel = ?
+ OR (COALESCE(channel,'') IN ('', 'system', 'delegate') AND COALESCE(chat_id,'') = '')
+ )`,
+ followupAt, max, message, channel, chatID, time.Now(),
+ teamID, store.TeamTaskStatusInProgress, tid,
+ channel, chatID, channel,
+ )
+ if err != nil {
+ return 0, err
+ }
+ n, err := res.RowsAffected()
+ return int(n), err
+}
+
+func (s *SQLiteTeamStore) HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID, excludeAgentID uuid.UUID) (bool, error) {
+ tid := tenantIDForInsert(ctx)
+ var exists bool
+ err := s.db.QueryRowContext(ctx,
+ `SELECT EXISTS(
+ SELECT 1 FROM team_tasks
+ WHERE team_id = ?
+ AND owner_agent_id IS NOT NULL
+ AND owner_agent_id != ?
+ AND status IN (?, ?, ?)
+ AND tenant_id = ?
+ )`,
+ teamID, excludeAgentID,
+ store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, store.TeamTaskStatusBlocked, tid,
+ ).Scan(&exists)
+ return exists, err
+}
+
+// BackfillTaskEmbeddings is a no-op for SQLite — vector search not supported.
+func (s *SQLiteTeamStore) BackfillTaskEmbeddings(_ context.Context) (int, error) {
+ return 0, nil
+}
+
+// SearchTasksByEmbedding is a no-op for SQLite — falls back to LIKE search.
+func (s *SQLiteTeamStore) SearchTasksByEmbedding(_ context.Context, _ uuid.UUID, _ []float32, _ int, _ string) ([]store.TeamTaskData, error) {
+ return nil, nil
+}
diff --git a/internal/store/sqlitestore/teams_tasks_lifecycle.go b/internal/store/sqlitestore/teams_tasks_lifecycle.go
new file mode 100644
index 00000000..c53051e8
--- /dev/null
+++ b/internal/store/sqlitestore/teams_tasks_lifecycle.go
@@ -0,0 +1,349 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+func (s *SQLiteTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error {
+ now := time.Now()
+ lockExpires := now.Add(taskLockDuration)
+ tid := tenantIDForInsert(ctx)
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, owner_agent_id = ?, locked_at = ?, lock_expires_at = ?, updated_at = ?
+ WHERE id = ? AND status = ? AND owner_agent_id IS NULL AND team_id = ? AND tenant_id = ?`,
+ store.TeamTaskStatusInProgress, agentID, now, lockExpires, now,
+ taskID, store.TeamTaskStatusPending, teamID, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("task not available for claiming (already claimed or not pending)")
+ }
+ return nil
+}
+
+func (s *SQLiteTeamStore) AssignTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error {
+ now := time.Now()
+ lockExpires := now.Add(taskLockDuration)
+ tid := tenantIDForInsert(ctx)
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, owner_agent_id = ?, locked_at = ?, lock_expires_at = ?, updated_at = ?
+ WHERE id = ? AND team_id = ? AND status = ? AND tenant_id = ?`,
+ store.TeamTaskStatusInProgress, agentID, now, lockExpires, now,
+ taskID, teamID, store.TeamTaskStatusPending, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("task not available for assignment (not pending or wrong team)")
+ }
+ return nil
+}
+
+func (s *SQLiteTeamStore) CompleteTask(ctx context.Context, taskID, teamID uuid.UUID, result string) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ tid := tenantIDForInsert(ctx)
+ res, err := tx.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL,
+ followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
+ progress_percent = NULL, updated_at = ?
+ WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
+ store.TeamTaskStatusCompleted, result, time.Now(),
+ taskID, store.TeamTaskStatusInProgress, teamID, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("task not in progress or not found")
+ }
+
+ if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *SQLiteTeamStore) CancelTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ now := time.Now()
+ tid := tenantIDForInsert(ctx)
+ res, err := tx.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL,
+ followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
+ progress_percent = NULL, updated_at = ?
+ WHERE id = ? AND status NOT IN (?, ?) AND team_id = ? AND tenant_id = ?`,
+ store.TeamTaskStatusCancelled, reason, now,
+ taskID, store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, teamID, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("task not found, already completed/cancelled, or wrong team")
+ }
+
+ if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *SQLiteTeamStore) FailTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ now := time.Now()
+ tid := tenantIDForInsert(ctx)
+ res, err := tx.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL,
+ followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
+ progress_percent = NULL, updated_at = ?
+ WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
+ store.TeamTaskStatusFailed, "FAILED: "+errMsg, now,
+ taskID, store.TeamTaskStatusInProgress, teamID, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("task not in progress or not found")
+ }
+
+ if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *SQLiteTeamStore) FailPendingTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ now := time.Now()
+ tid := tenantIDForInsert(ctx)
+ res, err := tx.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL,
+ progress_percent = NULL, updated_at = ?
+ WHERE id = ? AND status IN (?, ?) AND team_id = ? AND tenant_id = ?`,
+ store.TeamTaskStatusFailed, "FAILED: "+errMsg, now,
+ taskID, store.TeamTaskStatusPending, store.TeamTaskStatusBlocked, teamID, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("task not pending/blocked or not found")
+ }
+
+ if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *SQLiteTeamStore) ReviewTask(ctx context.Context, taskID, teamID uuid.UUID) error {
+ tid := tenantIDForInsert(ctx)
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, updated_at = ?
+ WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
+ store.TeamTaskStatusInReview, time.Now(),
+ taskID, store.TeamTaskStatusInProgress, teamID, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("task not in progress or not found")
+ }
+ return nil
+}
+
+func (s *SQLiteTeamStore) ApproveTask(ctx context.Context, taskID, teamID uuid.UUID, comment string) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ now := time.Now()
+ tid := tenantIDForInsert(ctx)
+ res, err := tx.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL,
+ followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
+ progress_percent = NULL, updated_at = ?
+ WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
+ store.TeamTaskStatusCompleted, now,
+ taskID, store.TeamTaskStatusInReview, teamID, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("task not in review or not found")
+ }
+
+ if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *SQLiteTeamStore) RejectTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ now := time.Now()
+ tid := tenantIDForInsert(ctx)
+ res, err := tx.ExecContext(ctx,
+ `UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL,
+ followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
+ progress_percent = NULL, updated_at = ?
+ WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
+ store.TeamTaskStatusCancelled, reason, now,
+ taskID, store.TeamTaskStatusInReview, teamID, tid,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("task not in review or not found")
+ }
+
+ if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// unblockDependentTasksSQLite removes taskID from blocked_by JSON arrays and
+// transitions blocked→pending when all blockers are resolved.
+// SQLite has no array_remove — we do it in Go: fetch affected rows, update in-process.
+func unblockDependentTasksSQLite(ctx context.Context, tx *sql.Tx, taskID uuid.UUID) error {
+ tid := tenantIDForInsert(ctx)
+ taskIDStr := taskID.String()
+
+ // Find all blocked tasks that reference this taskID in their blocked_by JSON array.
+ rows, err := tx.QueryContext(ctx,
+ `SELECT id, blocked_by FROM team_tasks
+ WHERE status IN ('blocked', 'pending') AND tenant_id = ?
+ AND EXISTS (SELECT 1 FROM json_each(blocked_by) WHERE json_each.value = ?)`,
+ tid, taskIDStr,
+ )
+ if err != nil {
+ return err
+ }
+
+ type toUpdate struct {
+ id uuid.UUID
+ blockedBy []string
+ }
+ var pending []toUpdate
+ for rows.Next() {
+ var id uuid.UUID
+ var rawJSON []byte
+ if err := rows.Scan(&id, &rawJSON); err != nil {
+ rows.Close()
+ return err
+ }
+ var blockedBy []string
+ if len(rawJSON) > 0 {
+ _ = json.Unmarshal(rawJSON, &blockedBy)
+ }
+ // Remove taskID from the slice.
+ filtered := blockedBy[:0]
+ for _, s := range blockedBy {
+ if s != taskIDStr {
+ filtered = append(filtered, s)
+ }
+ }
+ pending = append(pending, toUpdate{id: id, blockedBy: filtered})
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return err
+ }
+
+ now := time.Now()
+ for _, u := range pending {
+ newJSON := jsonStringArray(u.blockedBy)
+ newStatus := "pending"
+ if len(u.blockedBy) > 0 {
+ newStatus = "blocked"
+ }
+ if _, err := tx.ExecContext(ctx,
+ `UPDATE team_tasks SET blocked_by = ?, status = ?, updated_at = ? WHERE id = ? AND tenant_id = ?`,
+ newJSON, newStatus, now, u.id, tid,
+ ); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/store/sqlitestore/tenant-configs.go b/internal/store/sqlitestore/tenant-configs.go
new file mode 100644
index 00000000..38715d04
--- /dev/null
+++ b/internal/store/sqlitestore/tenant-configs.go
@@ -0,0 +1,151 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// SQLiteBuiltinToolTenantConfigStore implements store.BuiltinToolTenantConfigStore.
+type SQLiteBuiltinToolTenantConfigStore struct {
+ db *sql.DB
+}
+
+func NewSQLiteBuiltinToolTenantConfigStore(db *sql.DB) *SQLiteBuiltinToolTenantConfigStore {
+ return &SQLiteBuiltinToolTenantConfigStore{db: db}
+}
+
+func (s *SQLiteBuiltinToolTenantConfigStore) ListDisabled(ctx context.Context, tenantID uuid.UUID) ([]string, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT tool_name FROM builtin_tool_tenant_configs WHERE tenant_id = ? AND enabled = 0`,
+ tenantID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var names []string
+ for rows.Next() {
+ var name string
+ if err := rows.Scan(&name); err != nil {
+ return nil, err
+ }
+ names = append(names, name)
+ }
+ return names, rows.Err()
+}
+
+func (s *SQLiteBuiltinToolTenantConfigStore) ListAll(ctx context.Context, tenantID uuid.UUID) (map[string]bool, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT tool_name, enabled FROM builtin_tool_tenant_configs WHERE tenant_id = ?`,
+ tenantID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := make(map[string]bool)
+ for rows.Next() {
+ var name string
+ var enabled bool
+ if err := rows.Scan(&name, &enabled); err != nil {
+ return nil, err
+ }
+ result[name] = enabled
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteBuiltinToolTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, toolName string, enabled bool) error {
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO builtin_tool_tenant_configs (tool_name, tenant_id, enabled, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT (tool_name, tenant_id) DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at`,
+ toolName, tenantID, enabled, time.Now().UTC(),
+ )
+ return err
+}
+
+func (s *SQLiteBuiltinToolTenantConfigStore) Delete(ctx context.Context, tenantID uuid.UUID, toolName string) error {
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM builtin_tool_tenant_configs WHERE tool_name = ? AND tenant_id = ?`,
+ toolName, tenantID,
+ )
+ return err
+}
+
+// SQLiteSkillTenantConfigStore implements store.SkillTenantConfigStore.
+type SQLiteSkillTenantConfigStore struct {
+ db *sql.DB
+}
+
+func NewSQLiteSkillTenantConfigStore(db *sql.DB) *SQLiteSkillTenantConfigStore {
+ return &SQLiteSkillTenantConfigStore{db: db}
+}
+
+func (s *SQLiteSkillTenantConfigStore) ListDisabledSkillIDs(ctx context.Context, tenantID uuid.UUID) ([]uuid.UUID, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT skill_id FROM skill_tenant_configs WHERE tenant_id = ? AND enabled = 0`,
+ tenantID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var ids []uuid.UUID
+ for rows.Next() {
+ var id uuid.UUID
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ ids = append(ids, id)
+ }
+ return ids, rows.Err()
+}
+
+func (s *SQLiteSkillTenantConfigStore) ListAll(ctx context.Context, tenantID uuid.UUID) (map[uuid.UUID]bool, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT skill_id, enabled FROM skill_tenant_configs WHERE tenant_id = ?`,
+ tenantID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := make(map[uuid.UUID]bool)
+ for rows.Next() {
+ var id uuid.UUID
+ var enabled bool
+ if err := rows.Scan(&id, &enabled); err != nil {
+ return nil, err
+ }
+ result[id] = enabled
+ }
+ return result, rows.Err()
+}
+
+func (s *SQLiteSkillTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error {
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO skill_tenant_configs (skill_id, tenant_id, enabled, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT (skill_id, tenant_id) DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at`,
+ skillID, tenantID, enabled, time.Now().UTC(),
+ )
+ return err
+}
+
+func (s *SQLiteSkillTenantConfigStore) Delete(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID) error {
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM skill_tenant_configs WHERE skill_id = ? AND tenant_id = ?`,
+ skillID, tenantID,
+ )
+ return err
+}
diff --git a/internal/store/sqlitestore/tenants.go b/internal/store/sqlitestore/tenants.go
new file mode 100644
index 00000000..f6d26077
--- /dev/null
+++ b/internal/store/sqlitestore/tenants.go
@@ -0,0 +1,240 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteTenantStore implements store.TenantStore backed by SQLite.
+type SQLiteTenantStore struct {
+ db *sql.DB
+}
+
+func NewSQLiteTenantStore(db *sql.DB) *SQLiteTenantStore {
+ return &SQLiteTenantStore{db: db}
+}
+
+// ============================================================
+// Tenant CRUD
+// ============================================================
+
+func (s *SQLiteTenantStore) CreateTenant(ctx context.Context, tenant *store.TenantData) error {
+ if tenant.ID == uuid.Nil {
+ tenant.ID = store.GenNewID()
+ }
+ now := time.Now()
+ tenant.CreatedAt = now
+ tenant.UpdatedAt = now
+
+ settings := tenant.Settings
+ if len(settings) == 0 {
+ settings = json.RawMessage(`{}`)
+ }
+
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO tenants (id, name, slug, status, settings, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ tenant.ID, tenant.Name, tenant.Slug, tenant.Status, settings, now, now,
+ )
+ return err
+}
+
+func (s *SQLiteTenantStore) GetTenant(ctx context.Context, id uuid.UUID) (*store.TenantData, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT id, name, slug, status, settings, created_at, updated_at
+ FROM tenants WHERE id = ?`, id)
+ return scanTenantRow(row)
+}
+
+func (s *SQLiteTenantStore) GetTenantBySlug(ctx context.Context, slug string) (*store.TenantData, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT id, name, slug, status, settings, created_at, updated_at
+ FROM tenants WHERE slug = ?`, slug)
+ return scanTenantRow(row)
+}
+
+func (s *SQLiteTenantStore) ListTenants(ctx context.Context) ([]store.TenantData, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, name, slug, status, settings, created_at, updated_at
+ FROM tenants ORDER BY created_at`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var tenants []store.TenantData
+ for rows.Next() {
+ d, err := scanTenantRowScanner(rows)
+ if err != nil {
+ return nil, err
+ }
+ tenants = append(tenants, *d)
+ }
+ return tenants, rows.Err()
+}
+
+func (s *SQLiteTenantStore) UpdateTenant(ctx context.Context, id uuid.UUID, updates map[string]any) error {
+ return execMapUpdate(ctx, s.db, "tenants", id, updates)
+}
+
+// ============================================================
+// Tenant-user membership
+// ============================================================
+
+func (s *SQLiteTenantStore) AddUser(ctx context.Context, tenantID uuid.UUID, userID, role string) error {
+ now := time.Now()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO tenant_users (id, tenant_id, user_id, role, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at`,
+ store.GenNewID(), tenantID, userID, role, now, now,
+ )
+ return err
+}
+
+func (s *SQLiteTenantStore) GetTenantUser(ctx context.Context, id uuid.UUID) (*store.TenantUserData, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at
+ FROM tenant_users WHERE id = ?`, id)
+ var d store.TenantUserData
+ createdAt, updatedAt := scanTimePair()
+ if err := row.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, createdAt, updatedAt); err != nil {
+ return nil, err
+ }
+ d.CreatedAt = createdAt.Time
+ d.UpdatedAt = updatedAt.Time
+ return &d, nil
+}
+
+func (s *SQLiteTenantStore) CreateTenantUserReturning(ctx context.Context, tenantID uuid.UUID, userID, displayName, role string) (*store.TenantUserData, error) {
+ now := time.Now()
+ var dn *string
+ if displayName != "" {
+ dn = &displayName
+ }
+ // SQLite 3.35+ supports RETURNING.
+ row := s.db.QueryRowContext(ctx,
+ `INSERT INTO tenant_users (id, tenant_id, user_id, display_name, role, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (tenant_id, user_id) DO UPDATE SET
+ display_name = COALESCE(excluded.display_name, tenant_users.display_name),
+ updated_at = excluded.updated_at
+ RETURNING id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at`,
+ store.GenNewID(), tenantID, userID, dn, role, now, now,
+ )
+ var d store.TenantUserData
+ createdAt, updatedAt := scanTimePair()
+ if err := row.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, createdAt, updatedAt); err != nil {
+ return nil, err
+ }
+ d.CreatedAt = createdAt.Time
+ d.UpdatedAt = updatedAt.Time
+ return &d, nil
+}
+
+func (s *SQLiteTenantStore) RemoveUser(ctx context.Context, tenantID uuid.UUID, userID string) error {
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM tenant_users WHERE tenant_id = ? AND user_id = ?`,
+ tenantID, userID,
+ )
+ return err
+}
+
+func (s *SQLiteTenantStore) GetUserRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) {
+ var role string
+ err := s.db.QueryRowContext(ctx,
+ `SELECT role FROM tenant_users WHERE tenant_id = ? AND user_id = ?`,
+ tenantID, userID,
+ ).Scan(&role)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", nil
+ }
+ return role, err
+}
+
+func (s *SQLiteTenantStore) ListUsers(ctx context.Context, tenantID uuid.UUID) ([]store.TenantUserData, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at
+ FROM tenant_users WHERE tenant_id = ? ORDER BY created_at`, tenantID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTenantUserRows(rows)
+}
+
+func (s *SQLiteTenantStore) ListUserTenants(ctx context.Context, userID string) ([]store.TenantUserData, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at
+ FROM tenant_users WHERE user_id = ? ORDER BY created_at`, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTenantUserRows(rows)
+}
+
+func (s *SQLiteTenantStore) ResolveUserTenant(ctx context.Context, userID string) (uuid.UUID, error) {
+ var tenantID uuid.UUID
+ err := s.db.QueryRowContext(ctx,
+ `SELECT tenant_id FROM tenant_users WHERE user_id = ? ORDER BY created_at LIMIT 1`,
+ userID,
+ ).Scan(&tenantID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return store.MasterTenantID, nil
+ }
+ if err != nil {
+ return uuid.Nil, err
+ }
+ return tenantID, nil
+}
+
+// ============================================================
+// Scan helpers
+// ============================================================
+
+func scanTenantRow(row *sql.Row) (*store.TenantData, error) {
+ var d store.TenantData
+ createdAt, updatedAt := scanTimePair()
+ if err := row.Scan(&d.ID, &d.Name, &d.Slug, &d.Status, &d.Settings, createdAt, updatedAt); err != nil {
+ return nil, err
+ }
+ d.CreatedAt = createdAt.Time
+ d.UpdatedAt = updatedAt.Time
+ return &d, nil
+}
+
+func scanTenantRowScanner(row interface{ Scan(...any) error }) (*store.TenantData, error) {
+ var d store.TenantData
+ createdAt, updatedAt := scanTimePair()
+ if err := row.Scan(&d.ID, &d.Name, &d.Slug, &d.Status, &d.Settings, createdAt, updatedAt); err != nil {
+ return nil, err
+ }
+ d.CreatedAt = createdAt.Time
+ d.UpdatedAt = updatedAt.Time
+ return &d, nil
+}
+
+func scanTenantUserRows(rows *sql.Rows) ([]store.TenantUserData, error) {
+ var result []store.TenantUserData
+ for rows.Next() {
+ var d store.TenantUserData
+ createdAt, updatedAt := scanTimePair()
+ if err := rows.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, createdAt, updatedAt); err != nil {
+ return nil, err
+ }
+ d.CreatedAt = createdAt.Time
+ d.UpdatedAt = updatedAt.Time
+ result = append(result, d)
+ }
+ return result, rows.Err()
+}
diff --git a/internal/store/sqlitestore/tracing.go b/internal/store/sqlitestore/tracing.go
new file mode 100644
index 00000000..3e2d6bd1
--- /dev/null
+++ b/internal/store/sqlitestore/tracing.go
@@ -0,0 +1,252 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// SQLiteTracingStore implements store.TracingStore backed by SQLite.
+type SQLiteTracingStore struct {
+ db *sql.DB
+}
+
+func NewSQLiteTracingStore(db *sql.DB) *SQLiteTracingStore {
+ return &SQLiteTracingStore{db: db}
+}
+
+func (s *SQLiteTracingStore) CreateTrace(ctx context.Context, trace *store.TraceData) error {
+ if trace.ID == uuid.Nil {
+ trace.ID = store.GenNewID()
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO traces (id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
+ duration_ms, name, channel, input_preview, output_preview,
+ total_input_tokens, total_output_tokens, total_cost, span_count, llm_call_count, tool_call_count,
+ status, error, metadata, tags, team_id, created_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ trace.ID, nilUUID(trace.ParentTraceID), nilUUID(trace.AgentID), nilStr(trace.UserID), nilStr(trace.SessionKey),
+ nilStr(trace.RunID), trace.StartTime, nilTime(trace.EndTime),
+ nilInt(trace.DurationMS), nilStr(trace.Name), nilStr(trace.Channel),
+ nilStr(trace.InputPreview), nilStr(trace.OutputPreview),
+ trace.TotalInputTokens, trace.TotalOutputTokens, trace.TotalCost, trace.SpanCount, trace.LLMCallCount, trace.ToolCallCount,
+ trace.Status, nilStr(trace.Error), jsonOrEmpty(trace.Metadata), jsonStringArray(trace.Tags), nilUUID(trace.TeamID), trace.CreatedAt, tenantID,
+ )
+ return err
+}
+
+func (s *SQLiteTracingStore) UpdateTrace(ctx context.Context, traceID uuid.UUID, updates map[string]any) error {
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "traces", traceID, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "traces", updates, traceID, tid)
+}
+
+func (s *SQLiteTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*store.TraceData, error) {
+ query := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
+ duration_ms, name, channel, input_preview, output_preview,
+ total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count,
+ status, error, metadata, tags, team_id, created_at
+ FROM traces WHERE id = ?`
+ qArgs := []any{traceID}
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, sql.ErrNoRows
+ }
+ query += ` AND tenant_id = ?`
+ qArgs = append(qArgs, tenantID)
+ }
+ return scanTraceRow(s.db.QueryRowContext(ctx, query, qArgs...))
+}
+
+func buildTraceWhere(ctx context.Context, opts store.TraceListOpts) (string, []any) {
+ var conditions []string
+ var args []any
+
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return " WHERE 1=0", nil
+ }
+ conditions = append(conditions, "tenant_id = ?")
+ args = append(args, tenantID)
+ }
+ if opts.AgentID != nil {
+ conditions = append(conditions, "agent_id = ?")
+ args = append(args, *opts.AgentID)
+ }
+ if opts.UserID != "" {
+ conditions = append(conditions, "user_id = ?")
+ args = append(args, opts.UserID)
+ }
+ if opts.SessionKey != "" {
+ conditions = append(conditions, "session_key = ?")
+ args = append(args, opts.SessionKey)
+ }
+ if opts.Status != "" {
+ conditions = append(conditions, "status = ?")
+ args = append(args, opts.Status)
+ }
+ if opts.Channel != "" {
+ conditions = append(conditions, "channel = ?")
+ args = append(args, opts.Channel)
+ }
+
+ if len(conditions) == 0 {
+ return "", nil
+ }
+ return " WHERE " + strings.Join(conditions, " AND "), args
+}
+
+func (s *SQLiteTracingStore) CountTraces(ctx context.Context, opts store.TraceListOpts) (int, error) {
+ where, args := buildTraceWhere(ctx, opts)
+ var count int
+ err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM traces"+where, args...).Scan(&count)
+ return count, err
+}
+
+func (s *SQLiteTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpts) ([]store.TraceData, error) {
+ where, args := buildTraceWhere(ctx, opts)
+ limit := opts.Limit
+ if limit <= 0 {
+ limit = 50
+ }
+ q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
+ duration_ms, name, channel, input_preview, output_preview,
+ total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count,
+ status, error, metadata, tags, team_id, created_at
+ FROM traces` + where +
+ fmt.Sprintf(" ORDER BY created_at DESC LIMIT %d OFFSET %d", limit, opts.Offset)
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTraceRows(rows)
+}
+
+func (s *SQLiteTracingStore) ListChildTraces(ctx context.Context, parentTraceID uuid.UUID) ([]store.TraceData, error) {
+ q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
+ duration_ms, name, channel, input_preview, output_preview,
+ total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count,
+ status, error, metadata, tags, team_id, created_at
+ FROM traces WHERE parent_trace_id = ?`
+ qArgs := []any{parentTraceID}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ q += " AND tenant_id = ?"
+ qArgs = append(qArgs, tid)
+ }
+ q += " ORDER BY created_at"
+ rows, err := s.db.QueryContext(ctx, q, qArgs...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTraceRows(rows)
+}
+
+func (s *SQLiteTracingStore) GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error) {
+ start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
+ end := start.AddDate(0, 1, 0)
+ q := `SELECT COALESCE(SUM(total_cost), 0) FROM traces
+ WHERE agent_id = ? AND created_at >= ? AND created_at < ? AND parent_trace_id IS NULL`
+ qArgs := []any{agentID, start, end}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += " AND tenant_id = ?"
+ qArgs = append(qArgs, tid)
+ }
+ }
+ var cost float64
+ err := s.db.QueryRowContext(ctx, q, qArgs...).Scan(&cost)
+ return cost, err
+}
+
+func (s *SQLiteTracingStore) GetCostSummary(ctx context.Context, opts store.CostSummaryOpts) ([]store.CostSummaryRow, error) {
+ var conditions []string
+ var args []any
+
+ conditions = append(conditions, "parent_trace_id IS NULL")
+
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID != uuid.Nil {
+ conditions = append(conditions, "tenant_id = ?")
+ args = append(args, tenantID)
+ }
+ }
+ if opts.AgentID != nil {
+ conditions = append(conditions, "agent_id = ?")
+ args = append(args, *opts.AgentID)
+ }
+ if opts.From != nil {
+ conditions = append(conditions, "created_at >= ?")
+ args = append(args, *opts.From)
+ }
+ if opts.To != nil {
+ conditions = append(conditions, "created_at < ?")
+ args = append(args, *opts.To)
+ }
+
+ where := " WHERE " + strings.Join(conditions, " AND ")
+ q := `SELECT agent_id, COALESCE(SUM(total_cost), 0), COALESCE(SUM(total_input_tokens), 0),
+ COALESCE(SUM(total_output_tokens), 0), COUNT(*)
+ FROM traces` + where + ` GROUP BY agent_id ORDER BY SUM(total_cost) DESC`
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.CostSummaryRow
+ for rows.Next() {
+ var r store.CostSummaryRow
+ var agentID *uuid.UUID
+ if err := rows.Scan(&agentID, &r.TotalCost, &r.TotalInputTokens, &r.TotalOutputTokens, &r.TraceCount); err != nil {
+ continue
+ }
+ r.AgentID = agentID
+ result = append(result, r)
+ }
+ return result, rows.Err()
+}
+
+// DeleteTracesOlderThan deletes traces and their spans older than cutoff.
+func (s *SQLiteTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
+ // Delete spans belonging to old traces.
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM spans WHERE trace_id IN (SELECT id FROM traces WHERE created_at < ?)`, cutoff)
+ if err != nil {
+ return 0, fmt.Errorf("delete old spans: %w", err)
+ }
+
+ res, err := s.db.ExecContext(ctx, `DELETE FROM traces WHERE created_at < ?`, cutoff)
+ if err != nil {
+ return 0, fmt.Errorf("delete old traces: %w", err)
+ }
+ return res.RowsAffected()
+}
diff --git a/internal/store/sqlitestore/tracing_scan.go b/internal/store/sqlitestore/tracing_scan.go
new file mode 100644
index 00000000..6e0aa86f
--- /dev/null
+++ b/internal/store/sqlitestore/tracing_scan.go
@@ -0,0 +1,97 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "database/sql"
+ "log/slog"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+func scanTraceRow(row *sql.Row) (*store.TraceData, error) {
+ var d store.TraceData
+ var parentTraceID, agentID, teamID *uuid.UUID
+ var userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr *string
+ var endTime nullSqliteTime
+ var durationMS *int
+ var metadata *[]byte
+ var tags []byte
+ var startTime, createdAt sqliteTime
+
+ err := row.Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &startTime, &endTime,
+ &durationMS, &name, &channel, &inputPreview, &outputPreview,
+ &d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
+ &d.Status, &errStr, &metadata, &tags, &teamID, &createdAt)
+ if err != nil {
+ return nil, err
+ }
+ d.StartTime = startTime.Time
+ d.CreatedAt = createdAt.Time
+ var endTimePtr *time.Time
+ if endTime.Valid {
+ endTimePtr = &endTime.Time
+ }
+ applyTraceNullables(&d, parentTraceID, agentID, teamID, userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr, endTimePtr, durationMS, metadata, tags)
+ return &d, nil
+}
+
+func scanTraceRows(rows *sql.Rows) ([]store.TraceData, error) {
+ var result []store.TraceData
+ for rows.Next() {
+ var d store.TraceData
+ var parentTraceID, agentID, teamID *uuid.UUID
+ var userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr *string
+ var endTime nullSqliteTime
+ var durationMS *int
+ var metadata *[]byte
+ var tags []byte
+ var startTime, createdAt sqliteTime
+
+ if err := rows.Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &startTime, &endTime,
+ &durationMS, &name, &channel, &inputPreview, &outputPreview,
+ &d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
+ &d.Status, &errStr, &metadata, &tags, &teamID, &createdAt); err != nil {
+ slog.Warn("tracing: trace scan failed", "error", err)
+ continue
+ }
+ d.StartTime = startTime.Time
+ d.CreatedAt = createdAt.Time
+ var endTimePtr *time.Time
+ if endTime.Valid {
+ endTimePtr = &endTime.Time
+ }
+ applyTraceNullables(&d, parentTraceID, agentID, teamID, userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr, endTimePtr, durationMS, metadata, tags)
+ result = append(result, d)
+ }
+ return result, rows.Err()
+}
+
+func applyTraceNullables(d *store.TraceData,
+ parentTraceID, agentID, teamID *uuid.UUID,
+ userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr *string,
+ endTime *time.Time, durationMS *int, metadata *[]byte, tags []byte,
+) {
+ d.ParentTraceID = parentTraceID
+ d.AgentID = agentID
+ d.TeamID = teamID
+ d.UserID = derefStr(userID)
+ d.SessionKey = derefStr(sessionKey)
+ d.RunID = derefStr(runID)
+ d.EndTime = endTime
+ if durationMS != nil {
+ d.DurationMS = *durationMS
+ }
+ d.Name = derefStr(name)
+ d.Channel = derefStr(channel)
+ d.InputPreview = derefStr(inputPreview)
+ d.OutputPreview = derefStr(outputPreview)
+ d.Error = derefStr(errStr)
+ if metadata != nil {
+ d.Metadata = *metadata
+ }
+ scanJSONStringArray(tags, &d.Tags)
+}
diff --git a/internal/store/sqlitestore/tracing_spans.go b/internal/store/sqlitestore/tracing_spans.go
new file mode 100644
index 00000000..f5d0ac29
--- /dev/null
+++ b/internal/store/sqlitestore/tracing_spans.go
@@ -0,0 +1,208 @@
+//go:build sqlite || sqliteonly
+
+package sqlitestore
+
+import (
+ "context"
+ "log/slog"
+ "strings"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+func (s *SQLiteTracingStore) CreateSpan(ctx context.Context, span *store.SpanData) error {
+ if span.ID == uuid.Nil {
+ span.ID = store.GenNewID()
+ }
+ tenantID := span.TenantID
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO spans (id, trace_id, parent_span_id, agent_id, span_type, name,
+ start_time, end_time, duration_ms, status, error, level,
+ model, provider, input_tokens, output_tokens, finish_reason,
+ model_params, tool_name, tool_call_id, input_preview, output_preview,
+ metadata, team_id, created_at, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ span.ID, span.TraceID, span.ParentSpanID, span.AgentID, span.SpanType, nilStr(span.Name),
+ span.StartTime, nilTime(span.EndTime), nilInt(span.DurationMS), span.Status, nilStr(span.Error), span.Level,
+ nilStr(span.Model), nilStr(span.Provider), nilInt(span.InputTokens), nilInt(span.OutputTokens), nilStr(span.FinishReason),
+ jsonOrNull(span.ModelParams), nilStr(span.ToolName), nilStr(span.ToolCallID), nilStr(span.InputPreview), nilStr(span.OutputPreview),
+ jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, tenantID,
+ )
+ return err
+}
+
+func (s *SQLiteTracingStore) UpdateSpan(ctx context.Context, spanID uuid.UUID, updates map[string]any) error {
+ return execMapUpdate(ctx, s.db, "spans", spanID, updates)
+}
+
+func (s *SQLiteTracingStore) GetTraceSpans(ctx context.Context, traceID uuid.UUID) ([]store.SpanData, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, trace_id, parent_span_id, agent_id, span_type, name,
+ start_time, end_time, duration_ms, status, error, level,
+ model, provider, input_tokens, output_tokens, finish_reason,
+ model_params, tool_name, tool_call_id, input_preview, output_preview,
+ metadata, team_id, created_at
+ FROM spans WHERE trace_id = ? ORDER BY start_time`, traceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.SpanData
+ for rows.Next() {
+ var d store.SpanData
+ var parentSpanID, agentID, teamID *uuid.UUID
+ var name, errStr, level, model, provider, finishReason, toolName, toolCallID, inputPreview, outputPreview *string
+ var status *string
+ var endTimeSt nullSqliteTime
+ var durationMS, inputTokens, outputTokens *int
+ var modelParams, metadata *[]byte
+ var startTime, createdAt sqliteTime
+
+ if err := rows.Scan(&d.ID, &d.TraceID, &parentSpanID, &agentID, &d.SpanType, &name,
+ &startTime, &endTimeSt, &durationMS, &status, &errStr, &level,
+ &model, &provider, &inputTokens, &outputTokens, &finishReason,
+ &modelParams, &toolName, &toolCallID, &inputPreview, &outputPreview,
+ &metadata, &teamID, &createdAt); err != nil {
+ slog.Warn("tracing: span scan failed", "trace_id", traceID, "error", err)
+ continue
+ }
+ d.StartTime = startTime.Time
+ d.CreatedAt = createdAt.Time
+
+ d.ParentSpanID = parentSpanID
+ d.AgentID = agentID
+ d.TeamID = teamID
+ d.Name = derefStr(name)
+ if endTimeSt.Valid {
+ d.EndTime = &endTimeSt.Time
+ }
+ d.Status = derefStr(status)
+ d.Level = derefStr(level)
+ if modelParams != nil {
+ d.ModelParams = *modelParams
+ }
+ if metadata != nil {
+ d.Metadata = *metadata
+ }
+ if durationMS != nil {
+ d.DurationMS = *durationMS
+ }
+ d.Error = derefStr(errStr)
+ d.Model = derefStr(model)
+ d.Provider = derefStr(provider)
+ if inputTokens != nil {
+ d.InputTokens = *inputTokens
+ }
+ if outputTokens != nil {
+ d.OutputTokens = *outputTokens
+ }
+ d.FinishReason = derefStr(finishReason)
+ d.ToolName = derefStr(toolName)
+ d.ToolCallID = derefStr(toolCallID)
+ d.InputPreview = derefStr(inputPreview)
+ d.OutputPreview = derefStr(outputPreview)
+ result = append(result, d)
+ }
+ return result, rows.Err()
+}
+
+// sqliteSpanCols is the number of columns in the spans INSERT.
+const sqliteSpanCols = 26
+
+// sqliteSpanBatchSize limits rows per batch: 999 / 26 ≈ 38.
+const sqliteSpanBatchSize = 38
+
+// BatchCreateSpans inserts spans in batches to stay under SQLite's 999-variable limit.
+func (s *SQLiteTracingStore) BatchCreateSpans(ctx context.Context, spans []store.SpanData) error {
+ if len(spans) == 0 {
+ return nil
+ }
+ for start := 0; start < len(spans); start += sqliteSpanBatchSize {
+ end := start + sqliteSpanBatchSize
+ if end > len(spans) {
+ end = len(spans)
+ }
+ if err := s.batchInsertSpans(ctx, spans[start:end]); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *SQLiteTracingStore) batchInsertSpans(ctx context.Context, spans []store.SpanData) error {
+ placeholderRow := "(" + strings.Repeat("?, ", sqliteSpanCols-1) + "?)"
+ valueGroups := make([]string, len(spans))
+ args := make([]any, 0, len(spans)*sqliteSpanCols)
+
+ for i, span := range spans {
+ if span.ID == uuid.Nil {
+ span.ID = store.GenNewID()
+ spans[i].ID = span.ID
+ }
+ tenantID := span.TenantID
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+ valueGroups[i] = placeholderRow
+ args = append(args,
+ span.ID, span.TraceID, span.ParentSpanID, span.AgentID, span.SpanType, nilStr(span.Name),
+ span.StartTime, nilTime(span.EndTime), nilInt(span.DurationMS), span.Status, nilStr(span.Error), span.Level,
+ nilStr(span.Model), nilStr(span.Provider), nilInt(span.InputTokens), nilInt(span.OutputTokens), nilStr(span.FinishReason),
+ jsonOrNull(span.ModelParams), nilStr(span.ToolName), nilStr(span.ToolCallID), nilStr(span.InputPreview), nilStr(span.OutputPreview),
+ jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, tenantID,
+ )
+ }
+
+ q := `INSERT INTO spans (id, trace_id, parent_span_id, agent_id, span_type, name,
+ start_time, end_time, duration_ms, status, error, level,
+ model, provider, input_tokens, output_tokens, finish_reason,
+ model_params, tool_name, tool_call_id, input_preview, output_preview,
+ metadata, team_id, created_at, tenant_id)
+ VALUES ` + strings.Join(valueGroups, ", ")
+
+ _, err := s.db.ExecContext(ctx, q, args...)
+ if err == nil {
+ return nil
+ }
+
+ // Fallback to individual inserts on batch failure.
+ slog.Warn("tracing: batch span insert failed, falling back", "count", len(spans), "error", err)
+ var firstErr error
+ for i := range spans {
+ if e := s.CreateSpan(ctx, &spans[i]); e != nil {
+ slog.Warn("tracing: individual span insert failed", "span_id", spans[i].ID, "error", e)
+ if firstErr == nil {
+ firstErr = e
+ }
+ }
+ }
+ return firstErr
+}
+
+func (s *SQLiteTracingStore) BatchUpdateTraceAggregates(ctx context.Context, traceID uuid.UUID) error {
+ _, err := s.db.ExecContext(ctx, `
+ UPDATE traces SET
+ span_count = (SELECT COUNT(*) FROM spans WHERE trace_id = ?),
+ llm_call_count = (SELECT COUNT(*) FROM spans WHERE trace_id = ? AND span_type = 'llm_call'),
+ tool_call_count = (SELECT COUNT(*) FROM spans WHERE trace_id = ? AND span_type = 'tool_call'),
+ total_input_tokens = COALESCE((SELECT SUM(input_tokens) FROM spans WHERE trace_id = ? AND span_type = 'llm_call' AND input_tokens IS NOT NULL), 0),
+ total_output_tokens = COALESCE((SELECT SUM(output_tokens) FROM spans WHERE trace_id = ? AND span_type = 'llm_call' AND output_tokens IS NOT NULL), 0),
+ total_cost = COALESCE((SELECT SUM(total_cost) FROM spans WHERE trace_id = ? AND total_cost IS NOT NULL), 0),
+ metadata = (
+ SELECT json_object(
+ 'total_cache_read_tokens', COALESCE(SUM(json_extract(metadata, '$.cache_read_tokens')), 0),
+ 'total_cache_creation_tokens', COALESCE(SUM(json_extract(metadata, '$.cache_creation_tokens')), 0)
+ )
+ FROM spans WHERE trace_id = ? AND span_type = 'llm_call' AND metadata IS NOT NULL
+ )
+ WHERE id = ?`,
+ traceID, traceID, traceID, traceID, traceID, traceID, traceID, traceID)
+ return err
+}
+
diff --git a/internal/store/tracing_store.go b/internal/store/tracing_store.go
index 1f97a80a..97e7e221 100644
--- a/internal/store/tracing_store.go
+++ b/internal/store/tracing_store.go
@@ -144,4 +144,7 @@ type TracingStore interface {
// Cost aggregation
GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error)
GetCostSummary(ctx context.Context, opts CostSummaryOpts) ([]CostSummaryRow, error)
+
+ // Maintenance
+ DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error)
}
diff --git a/internal/store/types.go b/internal/store/types.go
index 6fbc88e0..4f4c4863 100644
--- a/internal/store/types.go
+++ b/internal/store/types.go
@@ -20,9 +20,15 @@ func GenNewID() uuid.UUID {
// StoreConfig configures the store layer.
type StoreConfig struct {
- // PostgresDSN is the Postgres connection string (required).
+ // PostgresDSN is the Postgres connection string (required for postgres backend).
PostgresDSN string
+ // SQLitePath is the path to the SQLite database file (required for sqlite backend).
+ SQLitePath string
+
+ // StorageBackend selects the database backend: "postgres" (default) or "sqlite".
+ StorageBackend string
+
// SkillsStorageDir is the directory for skill file content (default: dataDir/skills-store/).
SkillsStorageDir string
diff --git a/internal/tools/create_audio.go b/internal/tools/create_audio.go
index 407b93ee..fd9b5d60 100644
--- a/internal/tools/create_audio.go
+++ b/internal/tools/create_audio.go
@@ -171,7 +171,7 @@ func (t *CreateAudioTool) Execute(ctx context.Context, args map[string]any) *Res
slog.Info("create_audio: file saved", "path", audioPath, "size", fi.Size(), "data_len", len(audioBytes), "provider", providerName, "type", audioType)
}
- result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s", audioPath)}
+ result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s\nUse the EXACT filename when referencing: %s", audioPath, filepath.Base(audioPath))}
result.Media = []bus.MediaFile{{Path: audioPath, MimeType: "audio/mpeg"}}
result.Deliverable = fmt.Sprintf("[Generated audio: %s]\nPrompt: %s", filepath.Base(audioPath), prompt)
result.Provider = providerName
diff --git a/internal/tools/create_image.go b/internal/tools/create_image.go
index 9d51e9e4..e18db98e 100644
--- a/internal/tools/create_image.go
+++ b/internal/tools/create_image.go
@@ -122,7 +122,7 @@ func (t *CreateImageTool) Execute(ctx context.Context, args map[string]any) *Res
slog.Info("create_image: file saved", "path", imagePath, "size", fi.Size(), "data_len", len(chainResult.Data))
}
- result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s", imagePath)}
+ result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s\nUse the EXACT filename when referencing: %s", imagePath, filepath.Base(imagePath))}
result.Media = []bus.MediaFile{{Path: imagePath, MimeType: "image/png"}}
result.Deliverable = fmt.Sprintf("[Generated image: %s]\nPrompt: %s", filepath.Base(imagePath), prompt)
result.Provider = chainResult.Provider
diff --git a/internal/tools/create_video.go b/internal/tools/create_video.go
index 5b8acc0c..ea6ccaf1 100644
--- a/internal/tools/create_video.go
+++ b/internal/tools/create_video.go
@@ -140,7 +140,7 @@ func (t *CreateVideoTool) Execute(ctx context.Context, args map[string]any) *Res
slog.Info("create_video: file saved", "path", videoPath, "size", fi.Size(), "data_len", len(chainResult.Data))
}
- result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s", videoPath)}
+ result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s\nUse the EXACT filename when referencing: %s", videoPath, filepath.Base(videoPath))}
result.Media = []bus.MediaFile{{Path: videoPath, MimeType: "video/mp4"}}
result.Deliverable = fmt.Sprintf("[Generated video: %s]\nPrompt: %s", filepath.Base(videoPath), prompt)
result.Provider = chainResult.Provider
diff --git a/internal/tools/registry.go b/internal/tools/registry.go
index 8611a327..0325a464 100644
--- a/internal/tools/registry.go
+++ b/internal/tools/registry.go
@@ -17,6 +17,7 @@ import (
type Registry struct {
tools map[string]Tool
aliases map[string]string // alias name → canonical tool name
+ disabled map[string]bool // tools disabled via admin UI (kept in registry, excluded from List)
mu sync.RWMutex
rateLimiter *ToolRateLimiter // nil = no rate limiting
scrubbing bool // scrub credentials from output (default true)
@@ -30,6 +31,7 @@ func NewRegistry() *Registry {
return &Registry{
tools: make(map[string]Tool),
aliases: make(map[string]string),
+ disabled: make(map[string]bool),
scrubbing: true, // enabled by default
}
}
@@ -93,11 +95,18 @@ func (r *Registry) Aliases() map[string]string {
}
// resolve looks up a tool by name, checking real tools first, then aliases.
+// Disabled tools are excluded.
func (r *Registry) resolve(name string) (Tool, bool) {
if t, ok := r.tools[name]; ok {
+ if r.disabled[name] {
+ return nil, false
+ }
return t, true
}
if canonical, ok := r.aliases[name]; ok {
+ if r.disabled[canonical] {
+ return nil, false
+ }
t, ok := r.tools[canonical]
return t, ok
}
@@ -218,10 +227,16 @@ func (r *Registry) ProviderDefs() []providers.ToolDefinition {
defer r.mu.RUnlock()
defs := make([]providers.ToolDefinition, 0, len(r.tools)+len(r.aliases))
- for _, tool := range r.tools {
+ for name, tool := range r.tools {
+ if r.disabled[name] {
+ continue
+ }
defs = append(defs, ToProviderDef(tool))
}
for alias, canonical := range r.aliases {
+ if r.disabled[canonical] {
+ continue
+ }
tool, ok := r.tools[canonical]
if !ok {
continue
@@ -244,11 +259,28 @@ func (r *Registry) List() []string {
defer r.mu.RUnlock()
names := make([]string, 0, len(r.tools))
for name := range r.tools {
- names = append(names, name)
+ if !r.disabled[name] {
+ names = append(names, name)
+ }
}
return names
}
+// Disable marks a tool as disabled (excluded from List and policy evaluation)
+// without removing it from the registry. Can be re-enabled later.
+func (r *Registry) Disable(name string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.disabled[name] = true
+}
+
+// Enable re-enables a previously disabled tool.
+func (r *Registry) Enable(name string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ delete(r.disabled, name)
+}
+
// Count returns the number of registered tools.
func (r *Registry) Count() int {
r.mu.RLock()
@@ -265,10 +297,12 @@ func (r *Registry) Clone() *Registry {
clone := &Registry{
tools: make(map[string]Tool, len(r.tools)),
aliases: make(map[string]string, len(r.aliases)),
+ disabled: make(map[string]bool, len(r.disabled)),
rateLimiter: r.rateLimiter,
scrubbing: r.scrubbing,
}
maps.Copy(clone.tools, r.tools)
maps.Copy(clone.aliases, r.aliases)
+ maps.Copy(clone.disabled, r.disabled)
return clone
}
diff --git a/internal/tools/team_action_policy.go b/internal/tools/team_action_policy.go
new file mode 100644
index 00000000..5f7bd695
--- /dev/null
+++ b/internal/tools/team_action_policy.go
@@ -0,0 +1,49 @@
+package tools
+
+// TeamActionPolicy controls which team_tasks actions are available.
+// Injected into TeamTasksTool at construction — no scattered if/else.
+type TeamActionPolicy interface {
+ // IsAllowed returns true if the action is permitted in this edition.
+ IsAllowed(action string) bool
+ // AllowedActions returns the list of permitted action names (for Schema enum).
+ AllowedActions() []string
+ // MemberGuidance returns system-prompt text for team members.
+ MemberGuidance() string
+}
+
+// FullTeamPolicy allows all 18 team task actions (standard/PG edition).
+type FullTeamPolicy struct{}
+
+var fullActions = []string{
+ "list", "get", "create", "claim", "complete", "cancel",
+ "approve", "reject", "search", "review", "comment",
+ "progress", "attach", "update", "ask_user", "clear_ask_user", "retry",
+}
+
+func (FullTeamPolicy) IsAllowed(string) bool { return true }
+func (FullTeamPolicy) AllowedActions() []string { return fullActions }
+func (FullTeamPolicy) MemberGuidance() string {
+ return "Use comment(type='blocker') to escalate blockers to the leader. " +
+ "Use review to submit work for approval. " +
+ "Use progress to report incremental status updates."
+}
+
+// LiteTeamPolicy allows core lifecycle actions only (desktop/lite edition).
+// Blocked: comment, review, approve, reject, attach, ask_user, clear_ask_user.
+type LiteTeamPolicy struct{}
+
+var liteActions = []string{
+ "list", "get", "create", "claim", "complete", "cancel",
+ "progress", "search", "update", "retry",
+}
+
+var liteBlocked = map[string]bool{
+ "comment": true, "review": true, "approve": true, "reject": true,
+ "attach": true, "ask_user": true, "clear_ask_user": true,
+}
+
+func (LiteTeamPolicy) IsAllowed(action string) bool { return !liteBlocked[action] }
+func (LiteTeamPolicy) AllowedActions() []string { return liteActions }
+func (LiteTeamPolicy) MemberGuidance() string {
+ return "Use progress to update status. Use complete when finished."
+}
diff --git a/internal/tools/team_tasks_tool.go b/internal/tools/team_tasks_tool.go
index cb0c771f..7f7b276d 100644
--- a/internal/tools/team_tasks_tool.go
+++ b/internal/tools/team_tasks_tool.go
@@ -3,16 +3,18 @@ package tools
import (
"context"
"fmt"
+ "strings"
)
// TeamTasksTool exposes the shared team task list to agents.
-// Actions: list, get, create, claim, complete, cancel, search, review, comment, progress, attach, update.
+// Actions are filtered by TeamActionPolicy (full in standard, limited in lite).
type TeamTasksTool struct {
manager *TeamToolManager
+ policy TeamActionPolicy
}
-func NewTeamTasksTool(manager *TeamToolManager) *TeamTasksTool {
- return &TeamTasksTool{manager: manager}
+func NewTeamTasksTool(manager *TeamToolManager, policy TeamActionPolicy) *TeamTasksTool {
+ return &TeamTasksTool{manager: manager, policy: policy}
}
func (t *TeamTasksTool) Name() string { return "team_tasks" }
@@ -27,10 +29,8 @@ func (t *TeamTasksTool) Parameters() map[string]any {
"properties": map[string]any{
"action": map[string]any{
"type": "string",
- "description": "'list', 'get', 'create', 'claim', 'complete', 'cancel', 'approve', 'reject', 'search', 'review', 'comment', 'progress', 'attach', 'update', 'ask_user', or 'clear_ask_user'. " +
- "ask_user: set a periodic reminder that is sent to the USER (not the team) when you need the user's input/decision to continue (e.g. 'Which design do you prefer?'). ONLY use when you have a question for the user. Do NOT use for status updates, waiting for teammates, or notifications — use 'progress' instead. " +
- "clear_ask_user: cancel a previously set ask_user reminder. " +
- "retry: re-dispatch a stale or failed task.",
+ "enum": t.policy.AllowedActions(),
+ "description": t.buildActionDescription(),
},
"task_id": map[string]any{
"type": "string",
@@ -98,9 +98,26 @@ func (t *TeamTasksTool) Parameters() map[string]any {
}
}
+// buildActionDescription returns the action parameter description based on policy.
+func (t *TeamTasksTool) buildActionDescription() string {
+ base := "Available actions: " + strings.Join(t.policy.AllowedActions(), ", ") + "."
+ if t.policy.IsAllowed("ask_user") {
+ base += " ask_user: set a periodic reminder sent to the USER when you need input. clear_ask_user: cancel a previously set reminder."
+ }
+ if t.policy.IsAllowed("retry") {
+ base += " retry: re-dispatch a stale or failed task."
+ }
+ return base
+}
+
func (t *TeamTasksTool) Execute(ctx context.Context, args map[string]any) *Result {
action, _ := args["action"].(string)
+ // Edition policy guard — reject actions not allowed in this edition.
+ if !t.policy.IsAllowed(action) {
+ return ErrorResult(fmt.Sprintf("action %q is not available in this edition", action))
+ }
+
// Block mutations during notification runs — leader may only relay status.
if RunKindFromCtx(ctx) == RunKindNotification {
switch action {
diff --git a/internal/tracing/collector.go b/internal/tracing/collector.go
index 184843e1..a6ba4ec2 100644
--- a/internal/tracing/collector.go
+++ b/internal/tracing/collector.go
@@ -4,10 +4,8 @@ import (
"context"
"log/slog"
"os"
- "strings"
"sync"
"time"
- "unicode/utf8"
"github.com/google/uuid"
@@ -17,7 +15,9 @@ import (
const (
defaultFlushInterval = 5 * time.Second
defaultBufferSize = 1000
- previewMaxLen = 500
+ previewMaxLen = 40_000
+ traceRetention = 7 * 24 * time.Hour // auto-delete traces older than 7 days
+ pruneInterval = 8 * time.Hour
)
// SpanExporter is implemented by backends that receive span data alongside
@@ -205,18 +205,41 @@ func (c *Collector) flushLoop() {
ticker := time.NewTicker(defaultFlushInterval)
defer ticker.Stop()
+ pruneTicker := time.NewTicker(pruneInterval)
+ defer pruneTicker.Stop()
+
+ // Run initial prune shortly after startup.
+ go c.pruneOldTraces()
+
for {
select {
case <-ticker.C:
c.flush()
+ case <-pruneTicker.C:
+ c.pruneOldTraces()
case <-c.stopCh:
- // Drain remaining spans
c.flush()
return
}
}
}
+// pruneOldTraces deletes traces and spans older than traceRetention.
+func (c *Collector) pruneOldTraces() {
+ cutoff := time.Now().UTC().Add(-traceRetention)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ deleted, err := c.store.DeleteTracesOlderThan(ctx, cutoff)
+ if err != nil {
+ slog.Warn("tracing: prune old traces failed", "error", err)
+ return
+ }
+ if deleted > 0 {
+ slog.Info("tracing: pruned old traces", "deleted", deleted, "older_than", cutoff.Format(time.RFC3339))
+ }
+}
+
func (c *Collector) flush() {
// Drain span channel
var spans []store.SpanData
@@ -296,15 +319,7 @@ doneUpdates:
}
}
-// truncatePreviewStr sanitizes and truncates a string based on verbose mode.
+// truncatePreviewStr sanitizes and truncates a string by removing the middle.
func (c *Collector) truncatePreviewStr(s string) string {
- limit := c.PreviewMaxLen()
- s = strings.ToValidUTF8(s, "")
- if len(s) <= limit {
- return s
- }
- for limit > 0 && !utf8.RuneStart(s[limit]) {
- limit--
- }
- return s[:limit] + "..."
+ return TruncateMid(s, c.PreviewMaxLen())
}
diff --git a/internal/tracing/snapshot_worker.go b/internal/tracing/snapshot_worker.go
index 79519b11..a5c14791 100644
--- a/internal/tracing/snapshot_worker.go
+++ b/internal/tracing/snapshot_worker.go
@@ -191,13 +191,13 @@ func queryTraceAggregates(ctx context.Context, db *sql.DB, from, to time.Time) (
agent_id,
COALESCE(channel, '') as channel,
COUNT(*) as request_count,
- COUNT(*) FILTER (WHERE status = 'error') as error_count,
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_count,
COUNT(DISTINCT user_id) as unique_users,
COALESCE(SUM(total_input_tokens), 0) as input_tokens,
COALESCE(SUM(total_output_tokens), 0) as output_tokens,
COALESCE(SUM(total_cost), 0) as total_cost,
COALESCE(SUM(tool_call_count), 0) as tool_call_count,
- COALESCE(AVG(duration_ms), 0)::INTEGER as avg_duration_ms
+ CAST(COALESCE(AVG(duration_ms), 0) AS INTEGER) as avg_duration_ms
FROM traces
WHERE start_time >= $1 AND start_time < $2
AND parent_trace_id IS NULL
@@ -249,9 +249,9 @@ func querySpanAggregates(ctx context.Context, db *sql.DB, from, to time.Time) ([
COALESCE(SUM(s.input_tokens), 0) as span_input_tokens,
COALESCE(SUM(s.output_tokens), 0) as span_output_tokens,
COALESCE(SUM(s.total_cost), 0) as span_cost,
- COALESCE(SUM((s.metadata->>'cache_read_tokens')::BIGINT), 0) as cache_read_tokens,
- COALESCE(SUM((s.metadata->>'cache_creation_tokens')::BIGINT), 0) as cache_create_tokens,
- COALESCE(SUM((s.metadata->>'thinking_tokens')::BIGINT), 0) as thinking_tokens
+ COALESCE(SUM(CAST(s.metadata->>'cache_read_tokens' AS INTEGER)), 0) as cache_read_tokens,
+ COALESCE(SUM(CAST(s.metadata->>'cache_creation_tokens' AS INTEGER)), 0) as cache_create_tokens,
+ COALESCE(SUM(CAST(s.metadata->>'thinking_tokens' AS INTEGER)), 0) as thinking_tokens
FROM traces t
JOIN spans s ON s.trace_id = t.id AND s.span_type = 'llm_call'
WHERE t.start_time >= $1 AND t.start_time < $2
diff --git a/internal/tracing/truncate.go b/internal/tracing/truncate.go
new file mode 100644
index 00000000..b86c5a34
--- /dev/null
+++ b/internal/tracing/truncate.go
@@ -0,0 +1,158 @@
+package tracing
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "unicode/utf8"
+)
+
+const truncateMarker = "\n\n... [truncated %d chars] ...\n\n"
+
+// TruncateMid truncates s by removing the middle portion, keeping head + tail.
+// Returns s unchanged if it fits within maxLen.
+func TruncateMid(s string, maxLen int) string {
+ s = strings.ToValidUTF8(s, "")
+ if len(s) <= maxLen {
+ return s
+ }
+
+ marker := fmt.Sprintf(truncateMarker, len(s)-maxLen)
+ usable := maxLen - len(marker)
+ if usable <= 0 {
+ return s[len(s)-maxLen:]
+ }
+
+ head := usable * 2 / 3 // 2/3 head, 1/3 tail
+ tail := usable - head
+
+ // Align to rune boundaries.
+ for head > 0 && !utf8.RuneStart(s[head]) {
+ head--
+ }
+ tailStart := len(s) - tail
+ for tailStart < len(s) && !utf8.RuneStart(s[tailStart]) {
+ tailStart++
+ }
+
+ return s[:head] + marker + s[tailStart:]
+}
+
+// TruncateJSON truncates a JSON string by removing middle array elements,
+// preserving valid JSON structure. Falls back to TruncateMid if the input
+// is not a JSON array.
+func TruncateJSON(s string, maxLen int) string {
+ s = strings.ToValidUTF8(s, "")
+ if len(s) <= maxLen {
+ return s
+ }
+
+ // Only handle top-level JSON arrays (the common case: messages array).
+ var arr []json.RawMessage
+ if err := json.Unmarshal([]byte(s), &arr); err != nil || len(arr) < 3 {
+ return TruncateMid(s, maxLen)
+ }
+
+ // Pre-compute each element's serialized length to avoid repeated string builds.
+ elemLens := make([]int, len(arr))
+ for i, raw := range arr {
+ elemLens[i] = len(raw)
+ }
+
+ // Find max elements to keep via binary search on total size.
+ // We keep `keep` elements split as ceil(keep/2) head + floor(keep/2) tail.
+ maxKeep := len(arr) - 1 // must drop at least 1 to have a placeholder
+ bestKeep := 0
+
+ lo, hi := 2, maxKeep // minimum 2 (1 head + 1 tail)
+ if lo > hi {
+ lo = hi
+ }
+ for lo <= hi {
+ mid := (lo + hi) / 2
+ size := estimateArraySize(arr, elemLens, mid)
+ if size <= maxLen {
+ bestKeep = mid
+ lo = mid + 1
+ } else {
+ hi = mid - 1
+ }
+ }
+
+ // Even 2 elements too big — truncate individual elements.
+ if bestKeep < 2 {
+ return truncateArrayElements(arr, maxLen)
+ }
+
+ keepHead := (bestKeep + 1) / 2
+ keepTail := bestKeep - keepHead
+ return buildTruncatedArray(arr, keepHead, keepTail)
+}
+
+// estimateArraySize returns the byte length of a JSON array keeping `keep` elements
+// (split head/tail) plus a placeholder, without building the actual string.
+func estimateArraySize(arr []json.RawMessage, elemLens []int, keep int) int {
+ keepHead := (keep + 1) / 2
+ keepTail := keep - keepHead
+ dropped := len(arr) - keep
+
+ // placeholder: {"__truncated__":"N elements omitted"}
+ placeholderLen := 22 + digitCount(dropped) + 17 // key + number + " elements omitted"}
+
+ total := 2 // [ and ]
+ for i := 0; i < keepHead; i++ {
+ total += elemLens[i]
+ }
+ total += placeholderLen
+ for i := len(arr) - keepTail; i < len(arr); i++ {
+ total += elemLens[i]
+ }
+ total += keep // commas between elements (keep elements + 1 placeholder - 1)
+ return total
+}
+
+// digitCount returns the number of decimal digits in n.
+func digitCount(n int) int {
+ if n <= 0 {
+ return 1
+ }
+ d := 0
+ for n > 0 {
+ d++
+ n /= 10
+ }
+ return d
+}
+
+// buildTruncatedArray constructs a JSON array string keeping head and tail elements,
+// with a placeholder object in the middle showing how many elements were omitted.
+func buildTruncatedArray(arr []json.RawMessage, keepHead, keepTail int) string {
+ dropped := len(arr) - keepHead - keepTail
+ placeholder := fmt.Sprintf(`{"__truncated__":"%d elements omitted"}`, dropped)
+
+ parts := make([]string, 0, keepHead+1+keepTail)
+ for i := 0; i < keepHead; i++ {
+ parts = append(parts, string(arr[i]))
+ }
+ parts = append(parts, placeholder)
+ for i := len(arr) - keepTail; i < len(arr); i++ {
+ parts = append(parts, string(arr[i]))
+ }
+
+ return "[" + strings.Join(parts, ",") + "]"
+}
+
+// truncateArrayElements keeps first + last element but truncates their content.
+func truncateArrayElements(arr []json.RawMessage, maxLen int) string {
+ dropped := len(arr) - 2
+ placeholder := fmt.Sprintf(`{"__truncated__":"%d elements omitted"}`, dropped)
+ overhead := len("[,,]") + len(placeholder)
+ perElem := (maxLen - overhead) / 2
+ if perElem < 50 {
+ perElem = 50
+ }
+
+ first := TruncateMid(string(arr[0]), perElem)
+ last := TruncateMid(string(arr[len(arr)-1]), perElem)
+ return "[" + first + "," + placeholder + "," + last + "]"
+}
diff --git a/ui/desktop/app.go b/ui/desktop/app.go
new file mode 100644
index 00000000..a0cbc2e1
--- /dev/null
+++ b/ui/desktop/app.go
@@ -0,0 +1,216 @@
+//go:build sqliteonly
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/cmd"
+ wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
+)
+
+// App holds Wails application state and embedded gateway lifecycle.
+type App struct {
+ ctx context.Context
+ cancelGw context.CancelFunc
+ gatewayToken string
+ gatewayPort int
+}
+
+// NewApp creates a new App instance with default port.
+func NewApp() *App {
+ return &App{
+ gatewayPort: 18790,
+ }
+}
+
+// startup is called by Wails when the application starts.
+func (a *App) startup(ctx context.Context) {
+ a.ctx = ctx
+
+ // Resolve port from env or use default.
+ if p := os.Getenv("GOCLAW_PORT"); p != "" {
+ fmt.Sscanf(p, "%d", &a.gatewayPort)
+ }
+
+ // Ensure secrets (encryption key + gateway token) via OS keyring or file fallback.
+ encKey, gwToken, err := EnsureSecrets()
+ if err != nil {
+ slog.Error("failed to setup secrets", "error", err)
+ return
+ }
+ a.gatewayToken = gwToken
+
+ // Set env vars consumed by the embedded gateway.
+ os.Setenv("GOCLAW_ENCRYPTION_KEY", encKey)
+ os.Setenv("GOCLAW_GATEWAY_TOKEN", gwToken)
+ os.Setenv("GOCLAW_STORAGE_BACKEND", "sqlite")
+ os.Setenv("GOCLAW_DESKTOP", "1")
+ // Bind to localhost only — desktop has no reason to expose on LAN.
+ if os.Getenv("GOCLAW_HOST") == "" {
+ os.Setenv("GOCLAW_HOST", "127.0.0.1")
+ }
+ slog.Info("desktop secrets configured", "token_len", len(gwToken), "token_prefix", gwToken[:min(8, len(gwToken))])
+
+ // Ensure data directory exists.
+ dataDir := os.Getenv("GOCLAW_DATA_DIR")
+ if dataDir == "" {
+ home, _ := os.UserHomeDir()
+ dataDir = home + "/.goclaw/data"
+ os.Setenv("GOCLAW_DATA_DIR", dataDir)
+ }
+ os.MkdirAll(dataDir, 0755)
+
+ // Start gateway in background with a cancel context for graceful shutdown.
+ gwCtx, cancel := context.WithCancel(context.Background())
+ a.cancelGw = cancel
+
+ go func() {
+ // When context is cancelled, interrupt the process to trigger gateway shutdown.
+ go func() {
+ <-gwCtx.Done()
+ p, _ := os.FindProcess(os.Getpid())
+ p.Signal(os.Interrupt)
+ }()
+ slog.Info("starting embedded gateway", "port", a.gatewayPort)
+ cmd.RunGateway()
+ }()
+
+ a.waitForGateway()
+}
+
+// waitForGateway polls the health endpoint until the gateway is ready or times out.
+func (a *App) waitForGateway() {
+ url := fmt.Sprintf("http://localhost:%d/health", a.gatewayPort)
+ for i := 0; i < 30; i++ {
+ resp, err := http.Get(url)
+ if err == nil && resp.StatusCode == 200 {
+ resp.Body.Close()
+ slog.Info("gateway ready", "port", a.gatewayPort)
+ return
+ }
+ if resp != nil {
+ resp.Body.Close()
+ }
+ time.Sleep(500 * time.Millisecond)
+ }
+ slog.Warn("gateway not responding after 15s")
+}
+
+// shutdown is called by Wails when the application is closing.
+func (a *App) shutdown(ctx context.Context) {
+ if a.cancelGw != nil {
+ a.cancelGw()
+ }
+}
+
+// GetGatewayURL returns the base URL of the embedded gateway.
+func (a *App) GetGatewayURL() string {
+ return fmt.Sprintf("http://localhost:%d", a.gatewayPort)
+}
+
+// GetGatewayToken returns the token for WebSocket authentication.
+func (a *App) GetGatewayToken() string {
+ return a.gatewayToken
+}
+
+// GetGatewayPort returns the gateway port number.
+func (a *App) GetGatewayPort() int {
+ return a.gatewayPort
+}
+
+// IsGatewayReady checks if the gateway health endpoint is responding.
+func (a *App) IsGatewayReady() bool {
+ url := fmt.Sprintf("http://localhost:%d/health", a.gatewayPort)
+ resp, err := http.Get(url)
+ if err != nil {
+ return false
+ }
+ resp.Body.Close()
+ return resp.StatusCode == 200
+}
+
+// GetVersion returns the embedded gateway version string.
+func (a *App) GetVersion() string {
+ return cmd.Version
+}
+
+// OpenFile opens a local file with the system's default application.
+func (a *App) OpenFile(path string) error {
+ switch runtime.GOOS {
+ case "darwin":
+ return exec.Command("open", path).Start()
+ case "linux":
+ return exec.Command("xdg-open", path).Start()
+ default: // windows
+ return exec.Command("cmd", "/c", "start", "", path).Start()
+ }
+}
+
+// SaveFile opens a Save As dialog and copies the source file to the chosen location.
+func (a *App) SaveFile(srcPath string) error {
+ dest, err := wailsRuntime.SaveFileDialog(a.ctx, wailsRuntime.SaveDialogOptions{
+ DefaultFilename: filepath.Base(srcPath),
+ Title: "Save File",
+ })
+ if err != nil || dest == "" {
+ return err
+ }
+ src, err := os.Open(srcPath)
+ if err != nil {
+ return err
+ }
+ defer src.Close()
+ dst, err := os.Create(dest)
+ if err != nil {
+ return err
+ }
+ defer dst.Close()
+ _, err = io.Copy(dst, src)
+ return err
+}
+
+// DownloadURL fetches a URL with Bearer auth and opens a Save As dialog.
+func (a *App) DownloadURL(url, defaultFilename string) error {
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return err
+ }
+ // Use gateway token for local API calls
+ if strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1") {
+ req.Header.Set("Authorization", "Bearer "+a.gatewayToken)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+
+ dest, err := wailsRuntime.SaveFileDialog(a.ctx, wailsRuntime.SaveDialogOptions{
+ DefaultFilename: defaultFilename,
+ Title: "Save File",
+ })
+ if err != nil || dest == "" {
+ return err
+ }
+ f, err := os.Create(dest)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = io.Copy(f, resp.Body)
+ return err
+}
diff --git a/ui/desktop/build/appicon.png b/ui/desktop/build/appicon.png
new file mode 100644
index 00000000..dc9525c4
Binary files /dev/null and b/ui/desktop/build/appicon.png differ
diff --git a/ui/desktop/build/darwin/Info.dev.plist b/ui/desktop/build/darwin/Info.dev.plist
new file mode 100644
index 00000000..14121ef7
--- /dev/null
+++ b/ui/desktop/build/darwin/Info.dev.plist
@@ -0,0 +1,68 @@
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ {{.Info.ProductName}}
+ CFBundleExecutable
+ {{.OutputFilename}}
+ CFBundleIdentifier
+ com.wails.{{.Name}}
+ CFBundleVersion
+ {{.Info.ProductVersion}}
+ CFBundleGetInfoString
+ {{.Info.Comments}}
+ CFBundleShortVersionString
+ {{.Info.ProductVersion}}
+ CFBundleIconFile
+ iconfile
+ LSMinimumSystemVersion
+ 10.13.0
+ NSHighResolutionCapable
+ true
+ NSHumanReadableCopyright
+ {{.Info.Copyright}}
+ {{if .Info.FileAssociations}}
+ CFBundleDocumentTypes
+
+ {{range .Info.FileAssociations}}
+
+ CFBundleTypeExtensions
+
+ {{.Ext}}
+
+ CFBundleTypeName
+ {{.Name}}
+ CFBundleTypeRole
+ {{.Role}}
+ CFBundleTypeIconFile
+ {{.IconName}}
+
+ {{end}}
+
+ {{end}}
+ {{if .Info.Protocols}}
+ CFBundleURLTypes
+
+ {{range .Info.Protocols}}
+
+ CFBundleURLName
+ com.wails.{{.Scheme}}
+ CFBundleURLSchemes
+
+ {{.Scheme}}
+
+ CFBundleTypeRole
+ {{.Role}}
+
+ {{end}}
+
+ {{end}}
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+
+
diff --git a/ui/desktop/build/darwin/iconfile.icns b/ui/desktop/build/darwin/iconfile.icns
new file mode 100644
index 00000000..96182702
Binary files /dev/null and b/ui/desktop/build/darwin/iconfile.icns differ
diff --git a/ui/desktop/frontend/.npmrc b/ui/desktop/frontend/.npmrc
new file mode 100644
index 00000000..00369fee
--- /dev/null
+++ b/ui/desktop/frontend/.npmrc
@@ -0,0 +1 @@
+{"onlyBuiltDependencies":["esbuild"]}
diff --git a/ui/desktop/frontend/dist/goclaw-icon.svg b/ui/desktop/frontend/dist/goclaw-icon.svg
new file mode 100644
index 00000000..797941f5
--- /dev/null
+++ b/ui/desktop/frontend/dist/goclaw-icon.svg
@@ -0,0 +1,74 @@
+
diff --git a/ui/desktop/frontend/dist/index.html b/ui/desktop/frontend/dist/index.html
new file mode 100644
index 00000000..479c4f31
--- /dev/null
+++ b/ui/desktop/frontend/dist/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ GoClaw
+
+
+
+
+
+
+
+
diff --git a/ui/desktop/frontend/index.html b/ui/desktop/frontend/index.html
new file mode 100644
index 00000000..faf22865
--- /dev/null
+++ b/ui/desktop/frontend/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ GoClaw
+
+
+
+
+
+
+
diff --git a/ui/desktop/frontend/package.json b/ui/desktop/frontend/package.json
new file mode 100644
index 00000000..e3d7c286
--- /dev/null
+++ b/ui/desktop/frontend/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "goclaw-desktop",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "framer-motion": "^12.38.0",
+ "i18next": "^25.10.10",
+ "immer": "^10.1.1",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "react-i18next": "^16.6.6",
+ "react-markdown": "^9.0.3",
+ "react-syntax-highlighter": "^15.6.1",
+ "rehype-sanitize": "^6.0.0",
+ "remark-gfm": "^4.0.0",
+ "zustand": "^5.0.3"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "@vitejs/plugin-react": "^4.3.4",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/ui/desktop/frontend/pnpm-lock.yaml b/ui/desktop/frontend/pnpm-lock.yaml
new file mode 100644
index 00000000..dea8db4c
--- /dev/null
+++ b/ui/desktop/frontend/pnpm-lock.yaml
@@ -0,0 +1,2703 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ framer-motion:
+ specifier: ^12.38.0
+ version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ i18next:
+ specifier: ^25.10.10
+ version: 25.10.10(typescript@5.9.3)
+ immer:
+ specifier: ^10.1.1
+ version: 10.2.0
+ react:
+ specifier: ^19.0.0
+ version: 19.2.4
+ react-dom:
+ specifier: ^19.0.0
+ version: 19.2.4(react@19.2.4)
+ react-i18next:
+ specifier: ^16.6.6
+ version: 16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
+ react-markdown:
+ specifier: ^9.0.3
+ version: 9.1.0(@types/react@19.2.14)(react@19.2.4)
+ react-syntax-highlighter:
+ specifier: ^15.6.1
+ version: 15.6.6(react@19.2.4)
+ rehype-sanitize:
+ specifier: ^6.0.0
+ version: 6.0.0
+ remark-gfm:
+ specifier: ^4.0.0
+ version: 4.0.1
+ zustand:
+ specifier: ^5.0.3
+ version: 5.0.12(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+ devDependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.0.0
+ version: 4.2.2(vite@6.4.1(jiti@2.6.1)(lightningcss@1.32.0))
+ '@types/react':
+ specifier: ^19.0.0
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.0.0
+ version: 19.2.3(@types/react@19.2.14)
+ '@types/react-syntax-highlighter':
+ specifier: ^15.5.13
+ version: 15.5.13
+ '@vitejs/plugin-react':
+ specifier: ^4.3.4
+ version: 4.7.0(vite@6.4.1(jiti@2.6.1)(lightningcss@1.32.0))
+ tailwindcss:
+ specifier: ^4.0.0
+ version: 4.2.2
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+ vite:
+ specifier: ^6.0.0
+ version: 6.4.1(jiti@2.6.1)(lightningcss@1.32.0)
+
+packages:
+
+ '@babel/code-frame@7.29.0':
+ resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.0':
+ resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.0':
+ resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.1':
+ resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.28.6':
+ resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.28.0':
+ resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.28.6':
+ resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.28.6':
+ resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.28.6':
+ resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.27.1':
+ resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.2':
+ resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.2':
+ resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-transform-react-jsx-self@7.27.1':
+ resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1':
+ resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/runtime@7.29.2':
+ resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/template@7.28.6':
+ resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.0':
+ resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.0':
+ resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@rolldown/pluginutils@1.0.0-beta.27':
+ resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
+ '@rollup/rollup-android-arm-eabi@4.60.0':
+ resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.60.0':
+ resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.60.0':
+ resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.60.0':
+ resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.60.0':
+ resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.60.0':
+ resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.0':
+ resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.0':
+ resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.0':
+ resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.60.0':
+ resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.0':
+ resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.60.0':
+ resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.0':
+ resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.0':
+ resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.0':
+ resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.0':
+ resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.0':
+ resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.60.0':
+ resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.60.0':
+ resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.60.0':
+ resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.60.0':
+ resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.0':
+ resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.0':
+ resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.60.0':
+ resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.60.0':
+ resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/node@4.2.2':
+ resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
+
+ '@tailwindcss/oxide-android-arm64@4.2.2':
+ resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.2.2':
+ resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.2.2':
+ resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.2.2':
+ resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
+ resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
+ resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.2':
+ resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.2':
+ resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.2':
+ resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.2.2':
+ resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
+ resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
+ resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.2.2':
+ resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==}
+ engines: {node: '>= 20'}
+
+ '@tailwindcss/vite@4.2.2':
+ resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==}
+ peerDependencies:
+ vite: ^5.2.0 || ^6 || ^7 || ^8
+
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+ '@types/debug@4.1.13':
+ resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+
+ '@types/estree-jsx@1.0.5':
+ resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/hast@2.3.10':
+ resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==}
+
+ '@types/hast@3.0.4':
+ resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
+ '@types/mdast@4.0.4':
+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react-syntax-highlighter@15.5.13':
+ resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
+
+ '@types/react@19.2.14':
+ resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+
+ '@types/unist@2.0.11':
+ resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
+
+ '@types/unist@3.0.3':
+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
+ '@ungap/structured-clone@1.3.0':
+ resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+
+ '@vitejs/plugin-react@4.7.0':
+ resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ bail@2.0.2:
+ resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
+ baseline-browser-mapping@2.10.10:
+ resolution: {integrity: sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ browserslist@4.28.1:
+ resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ caniuse-lite@1.0.30001781:
+ resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
+
+ ccount@2.0.1:
+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
+ character-entities-html4@2.1.0:
+ resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
+
+ character-entities-legacy@1.1.4:
+ resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==}
+
+ character-entities-legacy@3.0.0:
+ resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+
+ character-entities@1.2.4:
+ resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==}
+
+ character-entities@2.0.2:
+ resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
+ character-reference-invalid@1.1.4:
+ resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==}
+
+ character-reference-invalid@2.0.1:
+ resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ comma-separated-tokens@1.0.8:
+ resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==}
+
+ comma-separated-tokens@2.0.3:
+ resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ decode-named-character-reference@1.3.0:
+ resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
+
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ devlop@1.1.0:
+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
+ electron-to-chromium@1.5.325:
+ resolution: {integrity: sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==}
+
+ enhanced-resolve@5.20.1:
+ resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
+ engines: {node: '>=10.13.0'}
+
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-string-regexp@5.0.0:
+ resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
+ engines: {node: '>=12'}
+
+ estree-util-is-identifier-name@3.0.0:
+ resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
+ fault@1.0.4:
+ resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ format@0.2.2:
+ resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
+ engines: {node: '>=0.4.x'}
+
+ framer-motion@12.38.0:
+ resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ hast-util-parse-selector@2.2.5:
+ resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==}
+
+ hast-util-sanitize@5.0.2:
+ resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
+
+ hast-util-to-jsx-runtime@2.3.6:
+ resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+
+ hast-util-whitespace@3.0.0:
+ resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+
+ hastscript@6.0.0:
+ resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==}
+
+ highlight.js@10.7.3:
+ resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
+
+ highlightjs-vue@1.0.0:
+ resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==}
+
+ html-parse-stringify@3.0.1:
+ resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+
+ html-url-attributes@3.0.1:
+ resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
+
+ i18next@25.10.10:
+ resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==}
+ peerDependencies:
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ immer@10.2.0:
+ resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
+
+ inline-style-parser@0.2.7:
+ resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+
+ is-alphabetical@1.0.4:
+ resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==}
+
+ is-alphabetical@2.0.1:
+ resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
+
+ is-alphanumerical@1.0.4:
+ resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==}
+
+ is-alphanumerical@2.0.1:
+ resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
+
+ is-decimal@1.0.4:
+ resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==}
+
+ is-decimal@2.0.1:
+ resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
+
+ is-hexadecimal@1.0.4:
+ resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==}
+
+ is-hexadecimal@2.0.1:
+ resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ hasBin: true
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
+ longest-streak@3.1.0:
+ resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
+ lowlight@1.20.0:
+ resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==}
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ markdown-table@3.0.4:
+ resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+
+ mdast-util-find-and-replace@3.0.2:
+ resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
+
+ mdast-util-from-markdown@2.0.3:
+ resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
+
+ mdast-util-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
+
+ mdast-util-gfm-table@2.0.0:
+ resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
+
+ mdast-util-gfm@3.1.0:
+ resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+
+ mdast-util-mdx-expression@2.0.1:
+ resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
+
+ mdast-util-mdx-jsx@3.2.0:
+ resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
+
+ mdast-util-mdxjs-esm@2.0.1:
+ resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+
+ mdast-util-phrasing@4.1.0:
+ resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
+ mdast-util-to-hast@13.2.1:
+ resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+
+ mdast-util-to-markdown@2.1.2:
+ resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
+
+ mdast-util-to-string@4.0.0:
+ resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
+ micromark-core-commonmark@2.0.3:
+ resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
+
+ micromark-extension-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
+
+ micromark-extension-gfm-table@2.1.1:
+ resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
+
+ micromark-extension-gfm@3.0.0:
+ resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+
+ micromark-factory-destination@2.0.1:
+ resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+ micromark-factory-label@2.0.1:
+ resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+ micromark-factory-space@2.0.1:
+ resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
+
+ micromark-factory-title@2.0.1:
+ resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+ micromark-factory-whitespace@2.0.1:
+ resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
+ micromark-util-character@2.1.1:
+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+ micromark-util-chunked@2.0.1:
+ resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+ micromark-util-classify-character@2.0.1:
+ resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+ micromark-util-combine-extensions@2.0.1:
+ resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+ micromark-util-decode-string@2.0.1:
+ resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
+ micromark-util-encode@2.0.1:
+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+ micromark-util-html-tag-name@2.0.1:
+ resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+ micromark-util-normalize-identifier@2.0.1:
+ resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+ micromark-util-resolve-all@2.0.1:
+ resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
+ micromark-util-sanitize-uri@2.0.1:
+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+ micromark-util-subtokenize@2.1.0:
+ resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
+ micromark-util-symbol@2.0.1:
+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+ micromark-util-types@2.0.2:
+ resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
+ micromark@4.0.2:
+ resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+
+ motion-dom@12.38.0:
+ resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
+
+ motion-utils@12.36.0:
+ resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ node-releases@2.0.36:
+ resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
+
+ parse-entities@2.0.0:
+ resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==}
+
+ parse-entities@4.0.2:
+ resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.8:
+ resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ prismjs@1.27.0:
+ resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==}
+ engines: {node: '>=6'}
+
+ prismjs@1.30.0:
+ resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
+ engines: {node: '>=6'}
+
+ property-information@5.6.0:
+ resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
+
+ property-information@7.1.0:
+ resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+
+ react-dom@19.2.4:
+ resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
+ peerDependencies:
+ react: ^19.2.4
+
+ react-i18next@16.6.6:
+ resolution: {integrity: sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==}
+ peerDependencies:
+ i18next: '>= 25.10.9'
+ react: '>= 16.8.0'
+ react-dom: '*'
+ react-native: '*'
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ typescript:
+ optional: true
+
+ react-markdown@9.1.0:
+ resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==}
+ peerDependencies:
+ '@types/react': '>=18'
+ react: '>=18'
+
+ react-refresh@0.17.0:
+ resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+ engines: {node: '>=0.10.0'}
+
+ react-syntax-highlighter@15.6.6:
+ resolution: {integrity: sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==}
+ peerDependencies:
+ react: '>= 0.14.0'
+
+ react@19.2.4:
+ resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
+ engines: {node: '>=0.10.0'}
+
+ refractor@3.6.0:
+ resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==}
+
+ rehype-sanitize@6.0.0:
+ resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
+
+ remark-gfm@4.0.1:
+ resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
+
+ remark-parse@11.0.0:
+ resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+ remark-rehype@11.1.2:
+ resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
+
+ remark-stringify@11.0.0:
+ resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+
+ rollup@4.60.0:
+ resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ space-separated-tokens@1.1.5:
+ resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==}
+
+ space-separated-tokens@2.0.2:
+ resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+
+ stringify-entities@4.0.4:
+ resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+
+ style-to-js@1.1.21:
+ resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
+
+ style-to-object@1.0.14:
+ resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
+
+ tailwindcss@4.2.2:
+ resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
+
+ tapable@2.3.2:
+ resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
+ engines: {node: '>=6'}
+
+ tinyglobby@0.2.15:
+ resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ engines: {node: '>=12.0.0'}
+
+ trim-lines@3.0.1:
+ resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+
+ trough@2.2.0:
+ resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ unified@11.0.5:
+ resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
+ unist-util-is@6.0.1:
+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+ unist-util-position@5.0.0:
+ resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+
+ unist-util-stringify-position@4.0.0:
+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+ unist-util-visit-parents@6.0.2:
+ resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+ unist-util-visit@5.1.0:
+ resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ vfile-message@4.0.3:
+ resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+ vfile@6.0.3:
+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
+ vite@6.4.1:
+ resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ void-elements@3.1.0:
+ resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+ engines: {node: '>=0.10.0'}
+
+ xtend@4.0.2:
+ resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
+ engines: {node: '>=0.4'}
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+ zustand@5.0.12:
+ resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ immer: '>=9.0.6'
+ react: '>=18.0.0'
+ use-sync-external-store: '>=1.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+
+ zwitch@2.0.4:
+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
+snapshots:
+
+ '@babel/code-frame@7.29.0':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.28.5
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.0': {}
+
+ '@babel/core@7.29.0':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/helper-compilation-targets': 7.28.6
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
+ '@babel/helpers': 7.29.2
+ '@babel/parser': 7.29.2
+ '@babel/template': 7.28.6
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.1':
+ dependencies:
+ '@babel/parser': 7.29.2
+ '@babel/types': 7.29.0
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.28.6':
+ dependencies:
+ '@babel/compat-data': 7.29.0
+ '@babel/helper-validator-option': 7.27.1
+ browserslist: 4.28.1
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.28.0': {}
+
+ '@babel/helper-module-imports@7.28.6':
+ dependencies:
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-module-imports': 7.28.6
+ '@babel/helper-validator-identifier': 7.28.5
+ '@babel/traverse': 7.29.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.28.6': {}
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/helper-validator-option@7.27.1': {}
+
+ '@babel/helpers@7.29.2':
+ dependencies:
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
+
+ '@babel/parser@7.29.2':
+ dependencies:
+ '@babel/types': 7.29.0
+
+ '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
+
+ '@babel/runtime@7.29.2': {}
+
+ '@babel/template@7.28.6':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/parser': 7.29.2
+ '@babel/types': 7.29.0
+
+ '@babel/traverse@7.29.0':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/helper-globals': 7.28.0
+ '@babel/parser': 7.29.2
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.0':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@rolldown/pluginutils@1.0.0-beta.27': {}
+
+ '@rollup/rollup-android-arm-eabi@4.60.0':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.60.0':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.60.0':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.60.0':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.60.0':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.60.0':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.60.0':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.60.0':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.60.0':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.0':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.0':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.60.0':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.60.0':
+ optional: true
+
+ '@tailwindcss/node@4.2.2':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.20.1
+ jiti: 2.6.1
+ lightningcss: 1.32.0
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.2.2
+
+ '@tailwindcss/oxide-android-arm64@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide@4.2.2':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.2.2
+ '@tailwindcss/oxide-darwin-arm64': 4.2.2
+ '@tailwindcss/oxide-darwin-x64': 4.2.2
+ '@tailwindcss/oxide-freebsd-x64': 4.2.2
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.2
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.2
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.2
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.2
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.2
+
+ '@tailwindcss/vite@4.2.2(vite@6.4.1(jiti@2.6.1)(lightningcss@1.32.0))':
+ dependencies:
+ '@tailwindcss/node': 4.2.2
+ '@tailwindcss/oxide': 4.2.2
+ tailwindcss: 4.2.2
+ vite: 6.4.1(jiti@2.6.1)(lightningcss@1.32.0)
+
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.29.2
+ '@babel/types': 7.29.0
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.29.0
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.29.2
+ '@babel/types': 7.29.0
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.29.0
+
+ '@types/debug@4.1.13':
+ dependencies:
+ '@types/ms': 2.1.0
+
+ '@types/estree-jsx@1.0.5':
+ dependencies:
+ '@types/estree': 1.0.8
+
+ '@types/estree@1.0.8': {}
+
+ '@types/hast@2.3.10':
+ dependencies:
+ '@types/unist': 2.0.11
+
+ '@types/hast@3.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/ms@2.1.0': {}
+
+ '@types/react-dom@19.2.3(@types/react@19.2.14)':
+ dependencies:
+ '@types/react': 19.2.14
+
+ '@types/react-syntax-highlighter@15.5.13':
+ dependencies:
+ '@types/react': 19.2.14
+
+ '@types/react@19.2.14':
+ dependencies:
+ csstype: 3.2.3
+
+ '@types/unist@2.0.11': {}
+
+ '@types/unist@3.0.3': {}
+
+ '@ungap/structured-clone@1.3.0': {}
+
+ '@vitejs/plugin-react@4.7.0(vite@6.4.1(jiti@2.6.1)(lightningcss@1.32.0))':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
+ '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
+ '@rolldown/pluginutils': 1.0.0-beta.27
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.17.0
+ vite: 6.4.1(jiti@2.6.1)(lightningcss@1.32.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ bail@2.0.2: {}
+
+ baseline-browser-mapping@2.10.10: {}
+
+ browserslist@4.28.1:
+ dependencies:
+ baseline-browser-mapping: 2.10.10
+ caniuse-lite: 1.0.30001781
+ electron-to-chromium: 1.5.325
+ node-releases: 2.0.36
+ update-browserslist-db: 1.2.3(browserslist@4.28.1)
+
+ caniuse-lite@1.0.30001781: {}
+
+ ccount@2.0.1: {}
+
+ character-entities-html4@2.1.0: {}
+
+ character-entities-legacy@1.1.4: {}
+
+ character-entities-legacy@3.0.0: {}
+
+ character-entities@1.2.4: {}
+
+ character-entities@2.0.2: {}
+
+ character-reference-invalid@1.1.4: {}
+
+ character-reference-invalid@2.0.1: {}
+
+ clsx@2.1.1: {}
+
+ comma-separated-tokens@1.0.8: {}
+
+ comma-separated-tokens@2.0.3: {}
+
+ convert-source-map@2.0.0: {}
+
+ csstype@3.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ decode-named-character-reference@1.3.0:
+ dependencies:
+ character-entities: 2.0.2
+
+ dequal@2.0.3: {}
+
+ detect-libc@2.1.2: {}
+
+ devlop@1.1.0:
+ dependencies:
+ dequal: 2.0.3
+
+ electron-to-chromium@1.5.325: {}
+
+ enhanced-resolve@5.20.1:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.2
+
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
+ escalade@3.2.0: {}
+
+ escape-string-regexp@5.0.0: {}
+
+ estree-util-is-identifier-name@3.0.0: {}
+
+ extend@3.0.2: {}
+
+ fault@1.0.4:
+ dependencies:
+ format: 0.2.2
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
+ format@0.2.2: {}
+
+ framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ motion-dom: 12.38.0
+ motion-utils: 12.36.0
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ fsevents@2.3.3:
+ optional: true
+
+ gensync@1.0.0-beta.2: {}
+
+ graceful-fs@4.2.11: {}
+
+ hast-util-parse-selector@2.2.5: {}
+
+ hast-util-sanitize@5.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@ungap/structured-clone': 1.3.0
+ unist-util-position: 5.0.0
+
+ hast-util-to-jsx-runtime@2.3.6:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ hast-util-whitespace: 3.0.0
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ style-to-js: 1.1.21
+ unist-util-position: 5.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ hast-util-whitespace@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hastscript@6.0.0:
+ dependencies:
+ '@types/hast': 2.3.10
+ comma-separated-tokens: 1.0.8
+ hast-util-parse-selector: 2.2.5
+ property-information: 5.6.0
+ space-separated-tokens: 1.1.5
+
+ highlight.js@10.7.3: {}
+
+ highlightjs-vue@1.0.0: {}
+
+ html-parse-stringify@3.0.1:
+ dependencies:
+ void-elements: 3.1.0
+
+ html-url-attributes@3.0.1: {}
+
+ i18next@25.10.10(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ optionalDependencies:
+ typescript: 5.9.3
+
+ immer@10.2.0: {}
+
+ inline-style-parser@0.2.7: {}
+
+ is-alphabetical@1.0.4: {}
+
+ is-alphabetical@2.0.1: {}
+
+ is-alphanumerical@1.0.4:
+ dependencies:
+ is-alphabetical: 1.0.4
+ is-decimal: 1.0.4
+
+ is-alphanumerical@2.0.1:
+ dependencies:
+ is-alphabetical: 2.0.1
+ is-decimal: 2.0.1
+
+ is-decimal@1.0.4: {}
+
+ is-decimal@2.0.1: {}
+
+ is-hexadecimal@1.0.4: {}
+
+ is-hexadecimal@2.0.1: {}
+
+ is-plain-obj@4.1.0: {}
+
+ jiti@2.6.1: {}
+
+ js-tokens@4.0.0: {}
+
+ jsesc@3.1.0: {}
+
+ json5@2.2.3: {}
+
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
+ longest-streak@3.1.0: {}
+
+ lowlight@1.20.0:
+ dependencies:
+ fault: 1.0.4
+ highlight.js: 10.7.3
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ markdown-table@3.0.4: {}
+
+ mdast-util-find-and-replace@3.0.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ escape-string-regexp: 5.0.0
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ mdast-util-from-markdown@2.0.3:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ mdast-util-to-string: 4.0.0
+ micromark: 4.0.2
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-decode-string: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-stringify-position: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-find-and-replace: 3.0.2
+ micromark-util-character: 2.1.1
+
+ mdast-util-gfm-footnote@2.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ micromark-util-normalize-identifier: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-table@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ markdown-table: 3.0.4
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm@3.1.0:
+ dependencies:
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-gfm-autolink-literal: 2.0.1
+ mdast-util-gfm-footnote: 2.1.0
+ mdast-util-gfm-strikethrough: 2.0.0
+ mdast-util-gfm-table: 2.0.0
+ mdast-util-gfm-task-list-item: 2.0.0
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-expression@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-jsx@3.2.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ parse-entities: 4.0.2
+ stringify-entities: 4.0.4
+ unist-util-stringify-position: 4.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdxjs-esm@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-phrasing@4.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ unist-util-is: 6.0.1
+
+ mdast-util-to-hast@13.2.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@ungap/structured-clone': 1.3.0
+ devlop: 1.1.0
+ micromark-util-sanitize-uri: 2.0.1
+ trim-lines: 3.0.1
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
+ mdast-util-to-markdown@2.1.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ longest-streak: 3.1.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-string: 4.0.0
+ micromark-util-classify-character: 2.0.1
+ micromark-util-decode-string: 2.0.1
+ unist-util-visit: 5.1.0
+ zwitch: 2.0.4
+
+ mdast-util-to-string@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+
+ micromark-core-commonmark@2.0.3:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-factory-destination: 2.0.1
+ micromark-factory-label: 2.0.1
+ micromark-factory-space: 2.0.1
+ micromark-factory-title: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-html-tag-name: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-footnote@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-table@2.1.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm@3.0.0:
+ dependencies:
+ micromark-extension-gfm-autolink-literal: 2.1.0
+ micromark-extension-gfm-footnote: 2.1.0
+ micromark-extension-gfm-strikethrough: 2.1.0
+ micromark-extension-gfm-table: 2.1.1
+ micromark-extension-gfm-tagfilter: 2.0.0
+ micromark-extension-gfm-task-list-item: 2.1.0
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-destination@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-label@2.0.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-space@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-title@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-whitespace@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-chunked@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-classify-character@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-combine-extensions@2.0.1:
+ dependencies:
+ micromark-util-chunked: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-decode-string@2.0.1:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ micromark-util-character: 2.1.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-html-tag-name@2.0.1: {}
+
+ micromark-util-normalize-identifier@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-resolve-all@2.0.1:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-subtokenize@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.2: {}
+
+ micromark@4.0.2:
+ dependencies:
+ '@types/debug': 4.1.13
+ debug: 4.4.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-encode: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ motion-dom@12.38.0:
+ dependencies:
+ motion-utils: 12.36.0
+
+ motion-utils@12.36.0: {}
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.11: {}
+
+ node-releases@2.0.36: {}
+
+ parse-entities@2.0.0:
+ dependencies:
+ character-entities: 1.2.4
+ character-entities-legacy: 1.1.4
+ character-reference-invalid: 1.1.4
+ is-alphanumerical: 1.0.4
+ is-decimal: 1.0.4
+ is-hexadecimal: 1.0.4
+
+ parse-entities@4.0.2:
+ dependencies:
+ '@types/unist': 2.0.11
+ character-entities-legacy: 3.0.0
+ character-reference-invalid: 2.0.1
+ decode-named-character-reference: 1.3.0
+ is-alphanumerical: 2.0.1
+ is-decimal: 2.0.1
+ is-hexadecimal: 2.0.1
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.4: {}
+
+ postcss@8.5.8:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ prismjs@1.27.0: {}
+
+ prismjs@1.30.0: {}
+
+ property-information@5.6.0:
+ dependencies:
+ xtend: 4.0.2
+
+ property-information@7.1.0: {}
+
+ react-dom@19.2.4(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ scheduler: 0.27.0
+
+ react-i18next@16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ html-parse-stringify: 3.0.1
+ i18next: 25.10.10(typescript@5.9.3)
+ react: 19.2.4
+ use-sync-external-store: 1.6.0(react@19.2.4)
+ optionalDependencies:
+ react-dom: 19.2.4(react@19.2.4)
+ typescript: 5.9.3
+
+ react-markdown@9.1.0(@types/react@19.2.14)(react@19.2.4):
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/react': 19.2.14
+ devlop: 1.1.0
+ hast-util-to-jsx-runtime: 2.3.6
+ html-url-attributes: 3.0.1
+ mdast-util-to-hast: 13.2.1
+ react: 19.2.4
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ unified: 11.0.5
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ react-refresh@0.17.0: {}
+
+ react-syntax-highlighter@15.6.6(react@19.2.4):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ highlight.js: 10.7.3
+ highlightjs-vue: 1.0.0
+ lowlight: 1.20.0
+ prismjs: 1.30.0
+ react: 19.2.4
+ refractor: 3.6.0
+
+ react@19.2.4: {}
+
+ refractor@3.6.0:
+ dependencies:
+ hastscript: 6.0.0
+ parse-entities: 2.0.0
+ prismjs: 1.27.0
+
+ rehype-sanitize@6.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-sanitize: 5.0.2
+
+ remark-gfm@4.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-gfm: 3.1.0
+ micromark-extension-gfm: 3.0.0
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-parse@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ micromark-util-types: 2.0.2
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-rehype@11.1.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ mdast-util-to-hast: 13.2.1
+ unified: 11.0.5
+ vfile: 6.0.3
+
+ remark-stringify@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-to-markdown: 2.1.2
+ unified: 11.0.5
+
+ rollup@4.60.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.60.0
+ '@rollup/rollup-android-arm64': 4.60.0
+ '@rollup/rollup-darwin-arm64': 4.60.0
+ '@rollup/rollup-darwin-x64': 4.60.0
+ '@rollup/rollup-freebsd-arm64': 4.60.0
+ '@rollup/rollup-freebsd-x64': 4.60.0
+ '@rollup/rollup-linux-arm-gnueabihf': 4.60.0
+ '@rollup/rollup-linux-arm-musleabihf': 4.60.0
+ '@rollup/rollup-linux-arm64-gnu': 4.60.0
+ '@rollup/rollup-linux-arm64-musl': 4.60.0
+ '@rollup/rollup-linux-loong64-gnu': 4.60.0
+ '@rollup/rollup-linux-loong64-musl': 4.60.0
+ '@rollup/rollup-linux-ppc64-gnu': 4.60.0
+ '@rollup/rollup-linux-ppc64-musl': 4.60.0
+ '@rollup/rollup-linux-riscv64-gnu': 4.60.0
+ '@rollup/rollup-linux-riscv64-musl': 4.60.0
+ '@rollup/rollup-linux-s390x-gnu': 4.60.0
+ '@rollup/rollup-linux-x64-gnu': 4.60.0
+ '@rollup/rollup-linux-x64-musl': 4.60.0
+ '@rollup/rollup-openbsd-x64': 4.60.0
+ '@rollup/rollup-openharmony-arm64': 4.60.0
+ '@rollup/rollup-win32-arm64-msvc': 4.60.0
+ '@rollup/rollup-win32-ia32-msvc': 4.60.0
+ '@rollup/rollup-win32-x64-gnu': 4.60.0
+ '@rollup/rollup-win32-x64-msvc': 4.60.0
+ fsevents: 2.3.3
+
+ scheduler@0.27.0: {}
+
+ semver@6.3.1: {}
+
+ source-map-js@1.2.1: {}
+
+ space-separated-tokens@1.1.5: {}
+
+ space-separated-tokens@2.0.2: {}
+
+ stringify-entities@4.0.4:
+ dependencies:
+ character-entities-html4: 2.1.0
+ character-entities-legacy: 3.0.0
+
+ style-to-js@1.1.21:
+ dependencies:
+ style-to-object: 1.0.14
+
+ style-to-object@1.0.14:
+ dependencies:
+ inline-style-parser: 0.2.7
+
+ tailwindcss@4.2.2: {}
+
+ tapable@2.3.2: {}
+
+ tinyglobby@0.2.15:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ trim-lines@3.0.1: {}
+
+ trough@2.2.0: {}
+
+ tslib@2.8.1: {}
+
+ typescript@5.9.3: {}
+
+ unified@11.0.5:
+ dependencies:
+ '@types/unist': 3.0.3
+ bail: 2.0.2
+ devlop: 1.1.0
+ extend: 3.0.2
+ is-plain-obj: 4.1.0
+ trough: 2.2.0
+ vfile: 6.0.3
+
+ unist-util-is@6.0.1:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-stringify-position@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-parents@6.0.2:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-visit@5.1.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ update-browserslist-db@1.2.3(browserslist@4.28.1):
+ dependencies:
+ browserslist: 4.28.1
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ use-sync-external-store@1.6.0(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+
+ vfile-message@4.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-stringify-position: 4.0.0
+
+ vfile@6.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile-message: 4.0.3
+
+ vite@6.4.1(jiti@2.6.1)(lightningcss@1.32.0):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+ postcss: 8.5.8
+ rollup: 4.60.0
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.32.0
+
+ void-elements@3.1.0: {}
+
+ xtend@4.0.2: {}
+
+ yallist@3.1.1: {}
+
+ zustand@5.0.12(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
+ optionalDependencies:
+ '@types/react': 19.2.14
+ immer: 10.2.0
+ react: 19.2.4
+ use-sync-external-store: 1.6.0(react@19.2.4)
+
+ zwitch@2.0.4: {}
diff --git a/ui/desktop/frontend/public/goclaw-icon.svg b/ui/desktop/frontend/public/goclaw-icon.svg
new file mode 100644
index 00000000..797941f5
--- /dev/null
+++ b/ui/desktop/frontend/public/goclaw-icon.svg
@@ -0,0 +1,74 @@
+
diff --git a/ui/desktop/frontend/src/App.tsx b/ui/desktop/frontend/src/App.tsx
new file mode 100644
index 00000000..24b2b252
--- /dev/null
+++ b/ui/desktop/frontend/src/App.tsx
@@ -0,0 +1,119 @@
+import './i18n'
+import { useEffect, useState } from 'react'
+import { useUiStore } from './stores/ui-store'
+import { AppShell } from './components/layout/AppShell'
+import { ChatCanvas } from './components/chat/ChatCanvas'
+import { OnboardingWizard } from './components/onboarding/OnboardingWizard'
+import { wails } from './lib/wails'
+import { initWsClient } from './lib/ws'
+import { initApiClient } from './lib/api'
+import { useSessionStore } from './stores/session-store'
+import { useChatStore } from './stores/chat-store'
+import { ErrorBoundary } from './components/common/ErrorBoundary'
+import { Toaster } from './components/common/Toaster'
+import { SplashScreen } from './components/common/SplashScreen'
+
+function AppReady() {
+ const toggleSidebar = useUiStore((s) => s.toggleSidebar)
+ const openSettings = useUiStore((s) => s.openSettings)
+ const closeSettings = useUiStore((s) => s.closeSettings)
+ const activeView = useUiStore((s) => s.activeView)
+
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ const mod = e.metaKey || e.ctrlKey
+ if (mod && e.key === 'b') { e.preventDefault(); toggleSidebar() }
+ if (mod && e.key === 'n') {
+ e.preventDefault()
+ // "New Chat" — clear session + chat directly (avoids duplicate useSessions instance)
+ useSessionStore.getState().setActiveSession(null)
+ useChatStore.getState().clear()
+ }
+ if (mod && e.key === ',') { e.preventDefault(); openSettings() }
+ if (e.key === 'Escape' && activeView === 'settings') { closeSettings() }
+ }
+ window.addEventListener('keydown', handler)
+ return () => window.removeEventListener('keydown', handler)
+ }, [toggleSidebar, openSettings, closeSettings, activeView])
+
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+
+function App() {
+ const theme = useUiStore((s) => s.theme)
+ const onboarded = useUiStore((s) => s.onboarded)
+ const completeOnboarding = useUiStore((s) => s.completeOnboarding)
+ const [ready, setReady] = useState(false)
+ const [splashDone, setSplashDone] = useState(false)
+
+ useEffect(() => {
+ document.documentElement.classList.toggle('dark', theme === 'dark')
+ }, [theme])
+
+ useEffect(() => {
+ const splashMin = new Promise((r) => setTimeout(r, 2500))
+
+ const init = async () => {
+ let attempts = 0
+ while (attempts < 30) {
+ try {
+ const isReady = await wails.isGatewayReady()
+ if (isReady) break
+ } catch { /* not ready yet */ }
+ await new Promise((r) => setTimeout(r, 500))
+ attempts++
+ }
+
+ let token = ''
+ try { token = await wails.getGatewayToken() } catch (e) {
+ console.warn('[app] failed to get token:', e)
+ }
+
+ const gatewayUrl = await wails.getGatewayURL()
+ const wsUrl = gatewayUrl.replace(/^http/, 'ws') + '/ws'
+
+ initWsClient(wsUrl, token)
+ const api = initApiClient(gatewayUrl, token)
+ setReady(true)
+
+ // Auto-detect empty DB → reset onboarded flag (handles DB deletion)
+ try {
+ const [pRes, aRes] = await Promise.allSettled([
+ api.get<{ providers?: unknown[] | null }>('/v1/providers'),
+ api.get<{ agents?: unknown[] | null }>('/v1/agents'),
+ ])
+ const hasProviders = pRes.status === 'fulfilled' && (pRes.value.providers?.length ?? 0) > 0
+ const hasAgents = aRes.status === 'fulfilled' && (aRes.value.agents?.length ?? 0) > 0
+ if (!hasProviders && !hasAgents) {
+ useUiStore.getState().resetOnboarding()
+ }
+ } catch { /* ignore — onboarding wizard will handle */ }
+ }
+
+ // Wait for both gateway init AND minimum splash duration
+ Promise.all([init(), splashMin]).then(() => setSplashDone(true))
+ }, [])
+
+ if (!splashDone) {
+ return
+ }
+
+ if (!onboarded) {
+ return
+ }
+
+ return (
+
+
+
+ )
+}
+
+export default App
diff --git a/ui/desktop/frontend/src/components/agents/AgentAvatar.tsx b/ui/desktop/frontend/src/components/agents/AgentAvatar.tsx
new file mode 100644
index 00000000..57973fb0
--- /dev/null
+++ b/ui/desktop/frontend/src/components/agents/AgentAvatar.tsx
@@ -0,0 +1,34 @@
+import type { AgentStatus } from '../../stores/agent-store'
+
+interface AgentAvatarProps {
+ name: string
+ status: AgentStatus
+ size?: 'sm' | 'md'
+ emoji?: string
+}
+
+const STATUS_CLASSES: Record = {
+ online: 'bg-success',
+ busy: 'bg-warning animate-pulse',
+ offline: 'bg-error',
+ idle: 'border-2 border-idle bg-transparent',
+}
+
+export function AgentAvatar({ name, status, size = 'sm', emoji }: AgentAvatarProps) {
+ const dim = size === 'sm' ? 28 : 36
+ const textSize = size === 'sm' ? 'text-[11px]' : 'text-sm'
+
+ return (
+
+
+ {emoji || name.charAt(0).toUpperCase()}
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/ActivityIndicator.tsx b/ui/desktop/frontend/src/components/chat/ActivityIndicator.tsx
new file mode 100644
index 00000000..8294b96f
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/ActivityIndicator.tsx
@@ -0,0 +1,100 @@
+import { useTranslation } from 'react-i18next'
+
+interface ActivityIndicatorProps {
+ phase: string
+ tool?: string
+ iteration?: number
+}
+
+const PHASE_COLOR: Record = {
+ thinking: 'text-amber-500',
+ tool_exec: 'text-blue-500',
+ compacting: 'text-warning',
+ streaming: 'text-text-secondary',
+ retrying: 'text-amber-500',
+ leader_processing: 'text-emerald-500',
+}
+
+export function ActivityIndicator({ phase, tool, iteration }: ActivityIndicatorProps) {
+ const { t } = useTranslation('desktop')
+
+ const color = PHASE_COLOR[phase] ?? 'text-text-muted'
+
+ let label: string
+ switch (phase) {
+ case 'thinking':
+ label = t('activity.thinking')
+ break
+ case 'tool_exec':
+ label = tool ? t('activity.runningTool', { tool }) : t('activity.executingTool')
+ break
+ case 'compacting':
+ label = t('activity.compacting')
+ break
+ case 'streaming':
+ label = t('activity.streaming')
+ break
+ case 'retrying':
+ label = iteration ? t('activity.retryingAttempt', { n: iteration }) : t('activity.retrying')
+ break
+ case 'leader_processing':
+ label = t('activity.processing')
+ break
+ default:
+ label = phase
+ }
+
+ return (
+
+
+
{label}
+ {phase !== 'retrying' && iteration && iteration > 1 && (
+
· {t('activity.step', { n: iteration })}
+ )}
+
+ )
+}
+
+function PhaseIcon({ phase, color }: { phase: string; color: string }) {
+ const cls = `w-3.5 h-3.5 ${color}`
+
+ switch (phase) {
+ case 'thinking':
+ // Brain icon
+ return (
+
+ )
+ case 'tool_exec':
+ // Wrench icon
+ return (
+
+ )
+ case 'retrying':
+ // RefreshCw icon
+ return (
+
+ )
+ default:
+ // Bouncing dots fallback
+ return (
+
+ {[0, 150, 300].map((delay) => (
+
+ ))}
+
+ )
+ }
+}
diff --git a/ui/desktop/frontend/src/components/chat/AuthImage.tsx b/ui/desktop/frontend/src/components/chat/AuthImage.tsx
new file mode 100644
index 00000000..a31f0528
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/AuthImage.tsx
@@ -0,0 +1,30 @@
+import { useMediaUrl } from '../../hooks/use-media-url'
+import { SaveFile } from '../../../wailsjs/go/main/App'
+
+interface AuthImageProps {
+ src: string
+ alt?: string
+ className?: string
+ onClick?: () => void
+}
+
+/** Image with blob-cached src to prevent flickering on session switch. */
+export function AuthImage({ src, alt, className, onClick }: AuthImageProps) {
+ const cachedSrc = useMediaUrl(src)
+
+ if (!cachedSrc) {
+ return
+ }
+
+ return
+}
+
+/** Save file via native Save As dialog. Desktop — files are local on disk.
+ * Extracts absolute path from /v1/files/{path} URL and opens Save dialog via Go binding. */
+export function downloadFile(url: string, _filename: string) {
+ const match = url.match(/\/v1\/files\/(.+?)(?:\?|$)/)
+ if (match) {
+ const absPath = '/' + decodeURIComponent(match[1])
+ SaveFile(absPath)
+ }
+}
diff --git a/ui/desktop/frontend/src/components/chat/ChatCanvas.tsx b/ui/desktop/frontend/src/components/chat/ChatCanvas.tsx
new file mode 100644
index 00000000..ba4e896d
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/ChatCanvas.tsx
@@ -0,0 +1,135 @@
+import { useEffect, useRef, useCallback, useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useChat } from '../../hooks/use-chat'
+import { useAgents } from '../../hooks/use-agents'
+import { useSessionStore } from '../../stores/session-store'
+import { ChatTopBar } from './ChatTopBar'
+import { MessageBubble } from './MessageBubble'
+import { ActivityIndicator } from './ActivityIndicator'
+import { InputBar, type AttachedFile } from './InputBar'
+import { TaskPanel } from './TaskPanel'
+
+export function ChatCanvas() {
+ const { t } = useTranslation('common')
+ const { messages, isRunning, activity, sendMessage } = useChat()
+ const { selectedAgent } = useAgents()
+ const activeSessionKey = useSessionStore((s) => s.activeSessionKey)
+ const messagesEndRef = useRef(null)
+ const scrollAreaRef = useRef(null)
+ const userScrolledUp = useRef(false)
+
+ // Find last assistant message ID for streaming cursor
+ const lastAssistantId = useMemo(() => {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i].role === 'assistant') return messages[i].id
+ }
+ return null
+ }, [messages])
+
+ useEffect(() => {
+ if (!userScrolledUp.current) {
+ messagesEndRef.current?.scrollIntoView({ behavior: isRunning ? 'smooth' : 'instant' })
+ }
+ }, [messages, isRunning])
+
+ const handleScroll = useCallback(() => {
+ const el = scrollAreaRef.current
+ if (!el) return
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50
+ userScrolledUp.current = !atBottom
+ }, [])
+
+ const handleSend = useCallback((text: string, files?: AttachedFile[]) => {
+ if (!selectedAgent) return
+ userScrolledUp.current = false
+ sendMessage(text, selectedAgent.id, files)
+ }, [selectedAgent, sendMessage])
+
+ const hasMessages = messages.length > 0
+
+ return (
+
+
+
+ {/* Chat body — dots background covers messages + input */}
+
+ {/* Messages area */}
+
+
+ {!selectedAgent && (
+
+ )}
+ {selectedAgent && !hasMessages &&
}
+
+ {messages.map((msg) => (
+
+ ))}
+
+ {isRunning && activity && (
+
+ )}
+
+
+
+
+
+ {/* Team task panel */}
+
+
+ {/* Input bar */}
+
+
+
+ )
+}
+
+/** Empty state with logo and clickable suggested prompts */
+function EmptyState({ agentName, onSuggestion }: { agentName?: string; onSuggestion?: (text: string) => void }) {
+ const { t } = useTranslation('desktop')
+ const suggestions = t('chat.suggestions', { returnObjects: true }) as string[]
+ return (
+
+

+
+ {agentName
+ ? t('chat.emptyTitle', { name: agentName })
+ : t('chat.emptyTitleDefault')}
+
+
+ {agentName ? t('chat.emptyDescAgent') : t('chat.emptyDescNoAgent')}
+
+ {agentName && (
+
+ {suggestions.map((prompt) => (
+
+ ))}
+
+ )}
+
+ Press ⌘N {t('sidebar.newChat').toLowerCase()}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/ChatTopBar.tsx b/ui/desktop/frontend/src/components/chat/ChatTopBar.tsx
new file mode 100644
index 00000000..78a84e97
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/ChatTopBar.tsx
@@ -0,0 +1,178 @@
+import { useState, useRef, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useAgents } from '../../hooks/use-agents'
+import { useUiStore } from '../../stores/ui-store'
+import { LANGUAGES, getAllTimezones } from '../../lib/constants'
+
+function LanguagePicker() {
+ const locale = useUiStore((s) => s.locale)
+ const setLocale = useUiStore((s) => s.setLocale)
+ const { i18n } = useTranslation()
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ const handler = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [open])
+
+ const current = LANGUAGES.find((l) => l.value === locale) ?? LANGUAGES[0]
+
+ function handleSelect(lang: string) {
+ setLocale(lang)
+ i18n.changeLanguage(lang)
+ setOpen(false)
+ }
+
+ return (
+
+
+ {open && (
+
+ {LANGUAGES.map((lang) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+function TimezonePicker() {
+ const timezone = useUiStore((s) => s.timezone)
+ const setTimezone = useUiStore((s) => s.setTimezone)
+ const [open, setOpen] = useState(false)
+ const [search, setSearch] = useState('')
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ const handler = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [open])
+
+ const allTimezones = getAllTimezones()
+ const filtered = search
+ ? allTimezones.filter((tz: string) => tz.toLowerCase().includes(search.toLowerCase()))
+ : allTimezones
+
+ // Short display: "Asia/Saigon" → "Saigon"
+ const shortTz = timezone.split('/').pop() ?? timezone
+
+ return (
+
+
+ {open && (
+
+
+ setSearch(e.target.value)}
+ placeholder="Search timezone..."
+ className="w-full px-2 py-1 text-xs bg-surface-tertiary border border-border rounded text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ autoFocus
+ />
+
+
+ {filtered.slice(0, 50).map((tz) => (
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+export function ChatTopBar() {
+ const { selectedAgent } = useAgents()
+ const toggleSidebar = useUiStore((s) => s.toggleSidebar)
+ const sidebarOpen = useUiStore((s) => s.sidebarOpen)
+
+ return (
+
+ {/* Sidebar toggle */}
+ {!sidebarOpen && (
+
+ )}
+
+ {/* Agent info */}
+
+ {selectedAgent ? (
+
+ {selectedAgent.name}
+
+ {selectedAgent.model}
+
+
+ ) : (
+
Select an agent to start chatting
+ )}
+
+
+ {/* Top right pickers */}
+
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/CodeBlock.tsx b/ui/desktop/frontend/src/components/chat/CodeBlock.tsx
new file mode 100644
index 00000000..8a5942cf
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/CodeBlock.tsx
@@ -0,0 +1,47 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
+import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
+
+interface CodeBlockProps {
+ language: string
+ code: string
+}
+
+export function CodeBlock({ language, code }: CodeBlockProps) {
+ const { t } = useTranslation('common')
+ const [copied, setCopied] = useState(false)
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(code)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ return (
+
+
+ {language}
+
+
+
+ {code}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/FileButton.tsx b/ui/desktop/frontend/src/components/chat/FileButton.tsx
new file mode 100644
index 00000000..dfaa7c49
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/FileButton.tsx
@@ -0,0 +1,84 @@
+import { useState } from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import { FilePreviewDialog } from './FilePreviewDialog'
+import { downloadFile } from './AuthImage'
+
+export interface FileButtonProps {
+ url: string
+ filename: string
+ mimeType?: string
+ size?: number
+}
+
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+function fileIcon(name: string): string {
+ if (/\.(md|markdown)$/i.test(name)) return '📝'
+ if (/\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|rb|sh|yaml|yml|toml)$/i.test(name)) return '💻'
+ if (/\.(zip|tar|gz|rar|7z)$/i.test(name)) return '📦'
+ if (/\.(csv|xls|xlsx)$/i.test(name)) return '📊'
+ if (/\.(pdf)$/i.test(name)) return '📕'
+ if (/\.(json|xml|html|css)$/i.test(name)) return '📋'
+ return '📄'
+}
+
+function truncateName(name: string, max = 30): string {
+ if (name.length <= max) return name
+ const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : ''
+ return name.slice(0, max - ext.length - 1) + '…' + ext
+}
+
+export function FileButton({ url, filename, mimeType, size }: FileButtonProps) {
+ const { t } = useTranslation('common')
+ const [previewOpen, setPreviewOpen] = useState(false)
+
+ // Strip timestamp from filename for display: "file.1774537056.md" → "file.md"
+ const displayName = filename.split('?')[0].replace(/\.\d{9,}(\.\w+)$/, '$1')
+
+ return (
+ <>
+
+
+
+
+
+ {previewOpen && createPortal(
+ setPreviewOpen(false)}
+ />,
+ document.body,
+ )}
+ >
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/FilePreviewDialog.tsx b/ui/desktop/frontend/src/components/chat/FilePreviewDialog.tsx
new file mode 100644
index 00000000..54145820
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/FilePreviewDialog.tsx
@@ -0,0 +1,225 @@
+import { useEffect, useState, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
+import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
+import { MarkdownRenderer } from './MarkdownRenderer'
+import { AuthImage, downloadFile } from './AuthImage'
+import { getApiClient, isApiClientReady } from '../../lib/api'
+
+// Strip ?ft= token and timestamps from filename for display
+function cleanFilename(name: string): string {
+ // Remove query params
+ const base = name.split('?')[0]
+ // Remove timestamp suffix: "file.1774537056.md" → "file.md"
+ return base.replace(/\.\d{9,}(\.\w+)$/, '$1')
+}
+
+interface FilePreviewDialogProps {
+ url: string
+ filename: string
+ mimeType?: string
+ onClose: () => void
+}
+
+function getLanguage(filename: string): string {
+ const ext = filename.split('.').pop()?.toLowerCase() ?? ''
+ const map: Record = {
+ ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
+ py: 'python', go: 'go', rs: 'rust', java: 'java',
+ c: 'c', cpp: 'cpp', h: 'c', rb: 'ruby', sh: 'bash',
+ yaml: 'yaml', yml: 'yaml', toml: 'toml', json: 'json',
+ xml: 'xml', html: 'html', css: 'css',
+ }
+ return map[ext] ?? 'text'
+}
+
+function isImage(filename: string, mimeType?: string): boolean {
+ if (mimeType?.startsWith('image/')) return true
+ return /\.(jpe?g|png|gif|webp|svg|bmp|ico)$/i.test(filename)
+}
+
+function isVideo(filename: string, mimeType?: string): boolean {
+ if (mimeType?.startsWith('video/')) return true
+ return /\.(mp4|webm|ogg|mov)$/i.test(filename)
+}
+
+function isAudio(filename: string, mimeType?: string): boolean {
+ if (mimeType?.startsWith('audio/')) return true
+ return /\.(mp3|wav|ogg|flac|aac|m4a)$/i.test(filename)
+}
+
+function isMarkdown(filename: string): boolean {
+ return /\.(md|markdown)$/i.test(filename)
+}
+
+function isCode(filename: string): boolean {
+ return /\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|rb|sh|yaml|yml|toml|json|xml|html|css)$/i.test(filename)
+}
+
+function isText(filename: string, mimeType?: string): boolean {
+ if (mimeType?.startsWith('text/')) return true
+ return /\.(txt|log|csv|conf|ini|env)$/i.test(filename)
+}
+
+export function FilePreviewDialog({ url, filename: rawFilename, mimeType, onClose }: FilePreviewDialogProps) {
+ const { t } = useTranslation('common')
+ const [textContent, setTextContent] = useState(null)
+ const [loadError, setLoadError] = useState(false)
+
+ // Normalize: if filename is a full URL/path, extract the basename
+ const filename = (rawFilename.includes('/') ? rawFilename.split('/').pop() : rawFilename)?.split('?')[0] ?? rawFilename
+ const needsTextFetch = isMarkdown(filename) || isCode(filename) || isText(filename, mimeType)
+
+ useEffect(() => {
+ if (!needsTextFetch) return
+ let cancelled = false
+ setTextContent(null)
+ setLoadError(false)
+ // For URLs without ?ft= token, sign them first to get a valid ft= token.
+ // This avoids tenant header issues with direct Bearer auth fetch.
+ // Fetch with Bearer auth via ApiClient.fetchFile (handles base URL + auth header).
+ const doFetch = isApiClientReady()
+ ? getApiClient().fetchFile(url)
+ : fetch(url)
+ doFetch
+ .then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`)
+ return r.text()
+ })
+ .then((text) => { if (!cancelled) setTextContent(text) })
+ .catch(() => { if (!cancelled) setLoadError(true) })
+ return () => { cancelled = true }
+ }, [url, needsTextFetch])
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() },
+ [onClose],
+ )
+
+ useEffect(() => {
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [handleKeyDown])
+
+ const displayName = cleanFilename(filename)
+
+ function renderContent() {
+ if (isImage(filename, mimeType)) {
+ return (
+
+ )
+ }
+ if (isVideo(filename, mimeType)) {
+ return
+ }
+ if (isAudio(filename, mimeType)) {
+ return
+ }
+ if (needsTextFetch) {
+ if (loadError) {
+ return (
+
+
{t('errors.serverError', 'Failed to load file preview')}
+
+
+ )
+ }
+ if (textContent === null) {
+ return {t('loading')}
+ }
+ if (isMarkdown(filename)) {
+ // Resolve relative image/link paths in markdown against the file's directory URL
+ const baseDir = url.substring(0, url.lastIndexOf('/') + 1)
+ const resolved = textContent.replace(
+ /(!?\[.*?\])\((?!https?:\/\/|\/|#)(.*?)\)/g,
+ (_, prefix, relPath) => `${prefix}(${baseDir}${relPath})`,
+ )
+ return (
+
+
+
+ )
+ }
+ if (isCode(filename)) {
+ return (
+
+
+ {textContent}
+
+
+ )
+ }
+ // plain text / log / csv
+ return (
+
+ {textContent}
+
+ )
+ }
+ return (
+
+
{t('selectFileToView')}
+
+
+ )
+ }
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
{displayName}
+
+
+
+
+ {/* Content */}
+
+ {renderContent()}
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/ImageLightbox.tsx b/ui/desktop/frontend/src/components/chat/ImageLightbox.tsx
new file mode 100644
index 00000000..c48376f3
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/ImageLightbox.tsx
@@ -0,0 +1,118 @@
+import { useEffect, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { AuthImage, downloadFile } from './AuthImage'
+
+export interface LightboxImage {
+ src: string
+ alt?: string
+}
+
+interface ImageLightboxProps {
+ src: string
+ alt?: string
+ onClose: () => void
+ /** Gallery mode */
+ images?: LightboxImage[]
+ currentIndex?: number
+ onNavigate?: (index: number) => void
+}
+
+const btnClass =
+ 'rounded-full bg-white/90 dark:bg-neutral-800/90 p-2.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 hover:bg-white dark:hover:bg-neutral-700 transition-colors cursor-pointer'
+
+export function ImageLightbox(props: ImageLightboxProps) {
+ const { t } = useTranslation('common')
+ const { onClose, images, currentIndex, onNavigate } = props
+ const isGallery = images && images.length > 1 && onNavigate && currentIndex != null
+ const current = isGallery ? images[currentIndex] : { src: props.src, alt: props.alt }
+ const canPrev = isGallery && currentIndex > 0
+ const canNext = isGallery && currentIndex < images.length - 1
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === 'Escape') { e.preventDefault(); onClose() }
+ if (isGallery) {
+ if (e.key === 'ArrowLeft' && canPrev) { e.preventDefault(); onNavigate(currentIndex - 1) }
+ if (e.key === 'ArrowRight' && canNext) { e.preventDefault(); onNavigate(currentIndex + 1) }
+ }
+ },
+ [onClose, isGallery, canPrev, canNext, onNavigate, currentIndex],
+ )
+
+ useEffect(() => {
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [handleKeyDown])
+
+ return (
+
+ {/* Close + Download toolbar */}
+
+
+
+
+
+ {/* Prev arrow */}
+ {canPrev && (
+
+ )}
+
+ {/* Next arrow */}
+ {canNext && (
+
+ )}
+
+ {/* Image */}
+
e.stopPropagation()}>
+
+
+
+ {/* Gallery counter */}
+ {isGallery && (
+
e.stopPropagation()}
+ >
+ {currentIndex + 1} / {images.length}
+
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/InputBar.tsx b/ui/desktop/frontend/src/components/chat/InputBar.tsx
new file mode 100644
index 00000000..faeb1a6a
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/InputBar.tsx
@@ -0,0 +1,299 @@
+import { useState, useRef, useCallback, type KeyboardEvent, type DragEvent } from 'react'
+import { useTranslation } from 'react-i18next'
+
+/** A file queued for upload or a direct filesystem path (desktop). */
+export interface AttachedFile {
+ id: string
+ /** Browser File object — present for drag/drop and file picker uploads. */
+ file?: File
+ /** Direct filesystem path — present for pasted paths (desktop only, skip HTTP upload). */
+ localPath?: string
+ name: string
+ /** Image thumbnail data URL (only for browser File images). */
+ preview?: string
+}
+
+interface InputBarProps {
+ onSend: (text: string, files?: AttachedFile[]) => void
+ onStop?: () => void
+ disabled?: boolean
+ isRunning?: boolean
+ placeholder?: string
+}
+
+/** Human-readable file size. */
+function formatSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+/** Detect if text looks like one or more absolute file paths (one per line). */
+const FILE_PATH_RE = /^(\/[\w.\-/ ]+(?:\.\w+)?|[A-Z]:\\[\w.\-\\ ]+(?:\.\w+)?)$/
+
+function extractFilePaths(text: string): string[] {
+ return text.split('\n').map((l) => l.trim()).filter((l) => FILE_PATH_RE.test(l))
+}
+
+const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']
+
+export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: InputBarProps) {
+ const { t } = useTranslation('common')
+ const [text, setText] = useState('')
+ const [files, setFiles] = useState([])
+ const [dragging, setDragging] = useState(false)
+ const textareaRef = useRef(null)
+ const fileInputRef = useRef(null)
+ const dragCounter = useRef(0)
+
+ const addFiles = useCallback((incoming: FileList | File[]) => {
+ const arr = Array.from(incoming)
+ const newFiles: AttachedFile[] = arr.map((f) => ({
+ id: crypto.randomUUID().slice(0, 8),
+ file: f,
+ name: f.name,
+ }))
+
+ // Generate image previews
+ for (const af of newFiles) {
+ if (af.file && IMAGE_TYPES.includes(af.file.type)) {
+ const fileObj = af.file
+ const reader = new FileReader()
+ reader.onload = () => {
+ setFiles((prev) => prev.map((f) => f.id === af.id ? { ...f, preview: reader.result as string } : f))
+ }
+ reader.readAsDataURL(fileObj)
+ }
+ }
+
+ setFiles((prev) => [...prev, ...newFiles])
+ }, [])
+
+ /** Add files by filesystem path (desktop paste). */
+ const addLocalPaths = useCallback((paths: string[]) => {
+ const newFiles: AttachedFile[] = paths.map((p) => ({
+ id: crypto.randomUUID().slice(0, 8),
+ localPath: p,
+ name: p.split('/').pop() || p.split('\\').pop() || p,
+ }))
+ setFiles((prev) => [...prev, ...newFiles])
+ }, [])
+
+ const removeFile = useCallback((id: string) => {
+ setFiles((prev) => prev.filter((f) => f.id !== id))
+ }, [])
+
+ const handleSend = useCallback(() => {
+ const hasContent = text.trim().length > 0 || files.length > 0
+ if (!hasContent || disabled) return
+ onSend(text.trim(), files.length > 0 ? files : undefined)
+ setText('')
+ setFiles([])
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto'
+ }
+ }, [text, files, disabled, onSend])
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSend()
+ }
+ }
+
+ const handleInput = () => {
+ const el = textareaRef.current
+ if (!el) return
+ el.style.height = 'auto'
+ el.style.height = Math.min(el.scrollHeight, 160) + 'px'
+ }
+
+ // --- Paste: detect file paths ---
+ const handlePaste = useCallback((e: React.ClipboardEvent) => {
+ // If clipboard has files (e.g. screenshot paste), handle as file upload
+ const items = e.clipboardData.items
+ const pastedFiles: File[] = []
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].kind === 'file') {
+ const f = items[i].getAsFile()
+ if (f) pastedFiles.push(f)
+ }
+ }
+ if (pastedFiles.length > 0) {
+ e.preventDefault()
+ addFiles(pastedFiles)
+ return
+ }
+
+ // Check if pasted text looks like file path(s)
+ const pasted = e.clipboardData.getData('text/plain')
+ const paths = extractFilePaths(pasted)
+ if (paths.length > 0) {
+ e.preventDefault()
+ addLocalPaths(paths)
+ }
+ // Otherwise, let default paste behavior handle it (normal text)
+ }, [addFiles, addLocalPaths])
+
+ // --- Drag & drop ---
+ const handleDragEnter = (e: DragEvent) => {
+ e.preventDefault()
+ dragCounter.current++
+ if (e.dataTransfer.types.includes('Files')) setDragging(true)
+ }
+ const handleDragLeave = (e: DragEvent) => {
+ e.preventDefault()
+ dragCounter.current--
+ if (dragCounter.current === 0) setDragging(false)
+ }
+ const handleDragOver = (e: DragEvent) => {
+ e.preventDefault()
+ }
+ const handleDrop = (e: DragEvent) => {
+ e.preventDefault()
+ dragCounter.current = 0
+ setDragging(false)
+ if (e.dataTransfer.files.length > 0) {
+ addFiles(e.dataTransfer.files)
+ }
+ }
+
+ const handleAttachClick = () => {
+ fileInputRef.current?.click()
+ }
+
+ const handleFileChange = () => {
+ const input = fileInputRef.current
+ if (input?.files && input.files.length > 0) {
+ addFiles(input.files)
+ input.value = '' // reset so same file can be re-selected
+ }
+ }
+
+ const hasContent = text.trim().length > 0 || files.length > 0
+
+ return (
+
+
+ {/* Hidden file input */}
+
+
+ {/* Drop overlay */}
+ {dragging && (
+
+ {t('dropFilesHere', 'Drop files here')}
+
+ )}
+
+ {/* Attached files preview */}
+ {files.length > 0 && (
+
+ {files.map((af) => (
+
+ {af.preview ? (
+

+ ) : (
+
+ )}
+
{af.name}
+ {af.file &&
({formatSize(af.file.size)})}
+
+
+ ))}
+
+ )}
+
+ {/* Input container */}
+
+ {/* Attach button */}
+
+
+ {/* Textarea */}
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/MarkdownRenderer.tsx b/ui/desktop/frontend/src/components/chat/MarkdownRenderer.tsx
new file mode 100644
index 00000000..4dd76687
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/MarkdownRenderer.tsx
@@ -0,0 +1,128 @@
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import rehypeSanitize from 'rehype-sanitize'
+import { CodeBlock } from './CodeBlock'
+import { FileButton } from './FileButton'
+import { AuthImage, downloadFile } from './AuthImage'
+import { getApiClient, isApiClientReady } from '../../lib/api'
+
+interface MarkdownRendererProps {
+ content: string
+ /** Base URL for resolving relative paths (e.g. images in .md files) */
+ baseUrl?: string
+}
+
+const LOCAL_FILE_EXT_RE = /\.(png|jpg|jpeg|gif|webp|svg|bmp|mp3|wav|ogg|flac|aac|m4a|mp4|webm|mkv|avi|mov|pdf|doc|docx|xls|xlsx|csv|txt|md|json|zip)$/i
+
+function isFileLink(href: string | undefined): boolean {
+ if (!href) return false
+ if (href.includes('/v1/files/')) return true
+ if ((href.startsWith('./') || href.startsWith('../')) && LOCAL_FILE_EXT_RE.test(href)) return true
+ return false
+}
+
+function resolveFileUrl(path: string, baseUrl?: string): string {
+ if (path.startsWith('http://') || path.startsWith('https://')) return path
+ // Already a /v1/files/ URL — use as-is
+ if (path.includes('/v1/files/')) {
+ if (isApiClientReady()) return getApiClient().getBaseUrl() + path
+ return path
+ }
+ // Relative path with baseUrl — resolve against the base directory
+ if (baseUrl && !path.startsWith('/')) {
+ const baseDir = baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)
+ return baseDir + path
+ }
+ // Absolute path — preserve full path for direct file serving
+ const filePath = path.startsWith('/') ? `/v1/files${path}` : `/v1/files/${path.split('/').pop() ?? path}`
+ if (isApiClientReady()) return getApiClient().getBaseUrl() + filePath
+ return filePath
+}
+
+export function MarkdownRenderer({ content, baseUrl }: MarkdownRendererProps) {
+ return (
+
+ {children}
+
+ )
+ }
+ return (
+
+ )
+ },
+ p: ({ children }) => {children}
,
+ a: ({ children, href }) => {
+ if (isFileLink(href)) {
+ const filename = decodeURIComponent(href!.split('/').pop() ?? 'file')
+ return
+ }
+ return (
+
+ {children}
+
+ )
+ },
+ img: ({ src, alt }) => {
+ if (isFileLink(src) || (baseUrl && src && !src.startsWith('http'))) {
+ const resolvedSrc = resolveFileUrl(src!, baseUrl)
+ const filename = src!.split('/').pop()?.split('?')[0] ?? 'image'
+ return (
+
+
+
+
+ )
+ }
+ return
+ },
+ ul: ({ children }) => ,
+ ol: ({ children }) => {children}
,
+ li: ({ children }) => {children},
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+ table: ({ children }) => (
+
+ ),
+ th: ({ children }) => (
+
+ {children}
+ |
+ ),
+ td: ({ children }) => (
+ {children} |
+ ),
+ h1: ({ children }) => {children}
,
+ h2: ({ children }) => {children}
,
+ h3: ({ children }) => {children}
,
+ hr: () =>
,
+ }}
+ />
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/MediaBlock.tsx b/ui/desktop/frontend/src/components/chat/MediaBlock.tsx
new file mode 100644
index 00000000..9ebeb85c
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/MediaBlock.tsx
@@ -0,0 +1,88 @@
+import { useState } from 'react'
+import { ImageLightbox } from './ImageLightbox'
+import type { LightboxImage } from './ImageLightbox'
+import { FileButton } from './FileButton'
+import { AuthImage } from './AuthImage'
+import { useMediaUrl } from '../../hooks/use-media-url'
+
+interface MediaBlockProps {
+ items: { type: string; url: string }[]
+}
+
+// Strip timestamp from filename: "file.1774537056.md" → "file.md"
+function cleanFilename(url: string): string {
+ const basename = url.split('/').pop()?.split('?')[0] ?? 'file'
+ return basename.replace(/\.\d{9,}(\.\w+)$/, '$1')
+}
+
+// Audio/video with authenticated blob URL
+function AuthAudio({ src }: { src: string }) {
+ const blobUrl = useMediaUrl(src)
+ if (!blobUrl) return
+ return
+}
+
+function AuthVideo({ src }: { src: string }) {
+ const blobUrl = useMediaUrl(src)
+ if (!blobUrl) return
+ return
+}
+
+export function MediaBlock({ items }: MediaBlockProps) {
+ const [lightboxIndex, setLightboxIndex] = useState(null)
+
+ const images = items.filter((i) => i.type.startsWith('image/'))
+ const others = items.filter((i) => !i.type.startsWith('image/'))
+
+ const galleryImages: LightboxImage[] = images.map((i) => ({ src: i.url }))
+
+ return (
+
+ {/* Image grid */}
+ {images.length > 0 && (
+
1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
+ {images.map((item, i) => (
+
+ ))}
+
+ )}
+
+ {/* Non-image media */}
+ {others.map((item, i) => {
+ if (item.type.startsWith('audio/')) {
+ return
+ }
+ if (item.type.startsWith('video/')) {
+ return
+ }
+ return (
+
+ )
+ })}
+
+ {/* Lightbox */}
+ {lightboxIndex !== null && galleryImages[lightboxIndex] && (
+
setLightboxIndex(null)}
+ images={galleryImages}
+ currentIndex={lightboxIndex}
+ onNavigate={setLightboxIndex}
+ />
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/MessageBubble.tsx b/ui/desktop/frontend/src/components/chat/MessageBubble.tsx
new file mode 100644
index 00000000..f7cef723
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/MessageBubble.tsx
@@ -0,0 +1,73 @@
+import type { Message } from '../../stores/chat-store'
+import { formatTimestamp } from '../../lib/format'
+import { ThinkingBlock } from './ThinkingBlock'
+import { ToolCallBlock } from './ToolCallBlock'
+import { MarkdownRenderer } from './MarkdownRenderer'
+import { MediaBlock } from './MediaBlock'
+
+interface MessageBubbleProps {
+ message: Message
+ /** True when this is the last assistant message during an active run */
+ isStreaming?: boolean
+}
+
+export function MessageBubble({ message, isStreaming }: MessageBubbleProps) {
+ if (message.role === 'user') {
+ return (
+
+
+
{message.content}
+
+
+
+ )
+ }
+
+ // Assistant message
+ const outputTokens = message.usage?.outputTokens ?? 0
+ const toolCalls = message.toolCalls ?? []
+ const hasTools = toolCalls.length > 0
+
+ return (
+
+ {message.thinkingText && (
+
+ )}
+
+ {/* Group tool calls in a single bordered container */}
+ {hasTools && (
+
+ {toolCalls.map((tc) => (
+
+ ))}
+
+ )}
+
+ {message.content && (
+
+
+ {/* Streaming cursor on last streaming message */}
+ {isStreaming && (
+
+ )}
+
+ )}
+
+ {/* Streaming cursor when no content yet but run is active */}
+ {isStreaming && !message.content && !message.thinkingText && toolCalls.length === 0 && (
+
+ )}
+
+ {message.media?.length ?
: null}
+
+
+
+ {outputTokens > 0 && (
+ · {outputTokens.toLocaleString()} tokens
+ )}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/TaskPanel.tsx b/ui/desktop/frontend/src/components/chat/TaskPanel.tsx
new file mode 100644
index 00000000..c0f0ec45
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/TaskPanel.tsx
@@ -0,0 +1,161 @@
+import { useEffect, useState, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { getWsClient } from '../../lib/ws'
+import type { TeamTaskData } from '../../types/team'
+import { PRIORITY_BADGE } from '../../types/team'
+
+interface TaskPanelProps {
+ sessionKey: string | null
+ onTaskClick?: (task: TeamTaskData) => void
+}
+
+/** Lightweight task shape from active-by-session endpoint (camelCase) */
+interface ActiveTaskResponse {
+ taskId: string
+ taskNumber?: number
+ subject: string
+ status: string
+ ownerAgentKey?: string
+ progressPercent?: number
+ progressStep?: string
+}
+
+/** Event payload from team.task.* events */
+interface TaskEventPayload {
+ task_id: string
+ team_id: string
+ subject?: string
+ status: string
+ owner_agent_key?: string
+ progress_percent?: number
+ progress_step?: string
+}
+
+/** Normalize active-by-session response to TeamTaskData shape */
+function normalizeActiveTask(t: ActiveTaskResponse): TeamTaskData {
+ return {
+ id: t.taskId,
+ team_id: '',
+ subject: t.subject,
+ status: t.status as TeamTaskData['status'],
+ priority: 2,
+ task_number: t.taskNumber,
+ owner_agent_key: t.ownerAgentKey,
+ progress_percent: t.progressPercent,
+ progress_step: t.progressStep,
+ }
+}
+
+/** Compact task panel shown alongside chat when team tasks are active */
+export function TaskPanel({ sessionKey, onTaskClick }: TaskPanelProps) {
+ const { t } = useTranslation('teams')
+ const [tasks, setTasks] = useState([])
+ const [collapsed, setCollapsed] = useState(false)
+
+ const fetchActiveTasks = useCallback(async () => {
+ if (!sessionKey) return
+ try {
+ const ws = getWsClient()
+ const res = await ws.call('teams.tasks.active-by-session', { sessionKey }) as { tasks: ActiveTaskResponse[] | null }
+ setTasks((res.tasks ?? []).map(normalizeActiveTask))
+ } catch { /* session may not have team tasks */ }
+ }, [sessionKey])
+
+ useEffect(() => { fetchActiveTasks() }, [fetchActiveTasks])
+
+ // Real-time updates
+ useEffect(() => {
+ const ws = getWsClient()
+ const unsubs: Array<() => void> = []
+
+ const handleEvent = (payload: unknown) => {
+ const p = payload as TaskEventPayload
+ if (!p.task_id) return
+ setTasks((prev) => {
+ const idx = prev.findIndex((t) => t.id === p.task_id)
+ if (idx >= 0) {
+ return prev.map((t) => t.id === p.task_id ? {
+ ...t,
+ status: (p.status || t.status) as TeamTaskData['status'],
+ owner_agent_key: p.owner_agent_key ?? t.owner_agent_key,
+ progress_percent: p.progress_percent ?? t.progress_percent,
+ progress_step: p.progress_step ?? t.progress_step,
+ } : t)
+ }
+ if (p.subject) {
+ return [...prev, {
+ id: p.task_id,
+ team_id: p.team_id,
+ subject: p.subject,
+ status: (p.status || 'pending') as TeamTaskData['status'],
+ priority: 2,
+ owner_agent_key: p.owner_agent_key,
+ progress_percent: p.progress_percent,
+ } as TeamTaskData]
+ }
+ return prev
+ })
+ }
+
+ const handleRemove = (payload: unknown) => {
+ const p = payload as TaskEventPayload
+ if (p.task_id) setTasks((prev) => prev.filter((t) => t.id !== p.task_id))
+ }
+
+ for (const evt of ['team.task.created', 'team.task.progress', 'team.task.assigned', 'team.task.completed', 'team.task.failed']) {
+ unsubs.push(ws.on(evt, handleEvent))
+ }
+ unsubs.push(ws.on('team.task.deleted', handleRemove))
+
+ return () => { for (const fn of unsubs) fn() }
+ }, [])
+
+ const activeTasks = tasks.filter((t) => t.status === 'pending' || t.status === 'blocked' || t.status === 'in_progress')
+
+ if (activeTasks.length === 0) return null
+
+ return (
+
+
+
+ {!collapsed && (
+
+ {activeTasks.map((task) => {
+ const prio = PRIORITY_BADGE[task.priority] ?? PRIORITY_BADGE[3]
+ return (
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/ThinkingBlock.tsx b/ui/desktop/frontend/src/components/chat/ThinkingBlock.tsx
new file mode 100644
index 00000000..e8e0a002
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/ThinkingBlock.tsx
@@ -0,0 +1,59 @@
+import { useState, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+
+interface ThinkingBlockProps {
+ text: string
+ isStreaming?: boolean
+}
+
+export function ThinkingBlock({ text, isStreaming }: ThinkingBlockProps) {
+ const { t } = useTranslation('common')
+ const [expanded, setExpanded] = useState(false)
+
+ // Auto-expand when streaming starts, keep user's choice when done
+ useEffect(() => {
+ if (isStreaming) setExpanded(true)
+ }, [isStreaming])
+
+ if (!text) return null
+
+ return (
+
+
+
+ {expanded && (
+
+
+ {text}
+ {isStreaming && (
+
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/chat/ToolCallBlock.tsx b/ui/desktop/frontend/src/components/chat/ToolCallBlock.tsx
new file mode 100644
index 00000000..2b469622
--- /dev/null
+++ b/ui/desktop/frontend/src/components/chat/ToolCallBlock.tsx
@@ -0,0 +1,123 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { ToolCall } from '../../stores/chat-store'
+
+// Inline SVG icons (no lucide-react dependency)
+function WrenchIcon({ className }: { className?: string }) {
+ return (
+
+ )
+}
+
+function ZapIcon({ className }: { className?: string }) {
+ return (
+
+ )
+}
+
+function AlertIcon({ className }: { className?: string }) {
+ return (
+
+ )
+}
+
+const isSkillTool = (name: string) => name === 'use_skill'
+
+/** Extract first meaningful arg (path/command/query/url/name), truncate to 77 chars. */
+function buildToolSummary(args: Record): string | null {
+ const key = args.path ?? args.command ?? args.query ?? args.url ?? args.name
+ if (typeof key === 'string') return key.length > 80 ? key.slice(0, 77) + '...' : key
+ return null
+}
+
+function ToolIcon({ state, isSkill }: { state: ToolCall['state']; isSkill: boolean }) {
+ const cls = 'h-3.5 w-3.5 shrink-0'
+ if (isSkill) {
+ if (state === 'calling') return
+ if (state === 'error') return
+ return
+ }
+ if (state === 'calling') return
+ if (state === 'error') return
+ return
+}
+
+function PhaseLabel({ state, isSkill }: { state: ToolCall['state']; isSkill: boolean }) {
+ const { t } = useTranslation('common')
+ const label = isSkill
+ ? { calling: t('skillActivating'), completed: t('skillActivated'), error: t('toolFailed') }[state]
+ : { calling: t('toolRunning'), completed: t('toolDone'), error: t('toolFailed') }[state]
+ const color = state === 'error' ? 'text-error' : state === 'completed' ? 'text-text-secondary' : 'text-blue-500'
+ return {label}
+}
+
+interface ToolCallBlockProps {
+ toolCall: ToolCall
+ /** Compact mode — less padding, used inside grouped containers */
+ compact?: boolean
+}
+
+export function ToolCallBlock({ toolCall, compact }: ToolCallBlockProps) {
+ const { t } = useTranslation('common')
+ const [expanded, setExpanded] = useState(false)
+ const skill = isSkillTool(toolCall.toolName)
+ const displayName = skill
+ ? `skill: ${(toolCall.arguments?.name as string) || 'unknown'}`
+ : toolCall.toolName
+ const summary = buildToolSummary(toolCall.arguments)
+ const canExpand = Object.keys(toolCall.arguments).length > 0 || toolCall.result || toolCall.error
+
+ return (
+
+
+
+ {expanded && canExpand && (
+
+ {toolCall.error && (
+
{toolCall.error}
+ )}
+ {Object.keys(toolCall.arguments).length > 0 && (
+
+
{t('toolArguments')}
+
+ {JSON.stringify(toolCall.arguments, null, 2)}
+
+
+ )}
+ {toolCall.result && (
+
+
{t('toolResult')}
+
+ {toolCall.result}
+
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/common/Combobox.tsx b/ui/desktop/frontend/src/components/common/Combobox.tsx
new file mode 100644
index 00000000..f6bab521
--- /dev/null
+++ b/ui/desktop/frontend/src/components/common/Combobox.tsx
@@ -0,0 +1,151 @@
+import { useState, useRef, useEffect, useCallback, useLayoutEffect, type KeyboardEvent } from 'react'
+import { createPortal } from 'react-dom'
+
+interface ComboboxOption {
+ value: string
+ label: string
+}
+
+interface ComboboxProps {
+ value: string
+ onChange: (value: string) => void
+ options: ComboboxOption[]
+ placeholder?: string
+ loading?: boolean
+ allowCustom?: boolean
+ disabled?: boolean
+}
+
+export function Combobox({ value, onChange, options, placeholder, loading, allowCustom = true, disabled }: ComboboxProps) {
+ const [open, setOpen] = useState(false)
+ const [search, setSearch] = useState('')
+ const [highlightIdx, setHighlightIdx] = useState(-1)
+ const [pos, setPos] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 0 })
+ const inputRef = useRef(null)
+ const dropdownRef = useRef(null)
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
+
+ const filtered = options.filter((o) =>
+ o.label.toLowerCase().includes(search.toLowerCase()) ||
+ o.value.toLowerCase().includes(search.toLowerCase())
+ )
+
+ const displayValue = options.find((o) => o.value === value)?.label || value
+
+ useEffect(() => { setHighlightIdx(-1) }, [search])
+
+ // Calculate dropdown position — track scroll/resize
+ useLayoutEffect(() => {
+ if (!open || !inputRef.current) return
+ const update = () => {
+ const rect = inputRef.current!.getBoundingClientRect()
+ setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width })
+ }
+ update()
+ window.addEventListener('scroll', update, true)
+ window.addEventListener('resize', update)
+ return () => {
+ window.removeEventListener('scroll', update, true)
+ window.removeEventListener('resize', update)
+ }
+ }, [open])
+
+ // Close on outside click
+ useEffect(() => {
+ if (!open) return
+ const handler = (e: MouseEvent) => {
+ if (dropdownRef.current?.contains(e.target as Node)) return
+ if (inputRef.current?.contains(e.target as Node)) return
+ setOpen(false)
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [open])
+
+ const handleSelect = useCallback((val: string) => {
+ onChange(val)
+ setSearch('')
+ setOpen(false)
+ inputRef.current?.blur()
+ }, [onChange])
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (!open) return
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault()
+ setHighlightIdx((prev) => {
+ const next = prev < filtered.length - 1 ? prev + 1 : 0
+ itemRefs.current[next]?.scrollIntoView({ block: 'nearest' })
+ return next
+ })
+ break
+ case 'ArrowUp':
+ e.preventDefault()
+ setHighlightIdx((prev) => {
+ const next = prev > 0 ? prev - 1 : filtered.length - 1
+ itemRefs.current[next]?.scrollIntoView({ block: 'nearest' })
+ return next
+ })
+ break
+ case 'Enter':
+ e.preventDefault()
+ if (highlightIdx >= 0 && filtered[highlightIdx]) {
+ handleSelect(filtered[highlightIdx].value)
+ }
+ break
+ case 'Escape':
+ e.preventDefault()
+ setOpen(false)
+ inputRef.current?.blur()
+ break
+ }
+ }
+
+ return (
+
+
{ setSearch(e.target.value); if (allowCustom) onChange(e.target.value) }}
+ onFocus={() => { setOpen(true); setSearch('') }}
+ onKeyDown={handleKeyDown}
+ placeholder={loading ? 'Loading...' : placeholder}
+ disabled={disabled || loading}
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-2.5 py-1.5 pr-7 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+ {/* Dropdown chevron */}
+
+
+ {/* Portal dropdown — escapes overflow:hidden parents */}
+ {open && filtered.length > 0 && createPortal(
+
+ {filtered.map((o, idx) => (
+
+ ))}
+
,
+ document.body,
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/common/ConfirmDeleteDialog.tsx b/ui/desktop/frontend/src/components/common/ConfirmDeleteDialog.tsx
new file mode 100644
index 00000000..9a12a187
--- /dev/null
+++ b/ui/desktop/frontend/src/components/common/ConfirmDeleteDialog.tsx
@@ -0,0 +1,82 @@
+import { useState, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+
+interface ConfirmDeleteDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ title: string
+ description: string
+ /** Text the user must type to confirm (e.g. resource name) */
+ confirmValue: string
+ confirmLabel?: string
+ cancelLabel?: string
+ onConfirm: () => void
+ loading?: boolean
+}
+
+export function ConfirmDeleteDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ confirmValue,
+ confirmLabel,
+ cancelLabel,
+ onConfirm,
+ loading,
+}: ConfirmDeleteDialogProps) {
+ const { t } = useTranslation('common')
+ const resolvedConfirmLabel = confirmLabel ?? t('delete')
+ const resolvedCancelLabel = cancelLabel ?? t('cancel')
+ const [inputValue, setInputValue] = useState('')
+
+ useEffect(() => {
+ if (!open) setInputValue('')
+ }, [open])
+
+ const normalize = (v: string) => v.normalize('NFC').trim().toLocaleLowerCase()
+ const isMatch = confirmValue
+ ? normalize(inputValue) === normalize(confirmValue)
+ : inputValue.trim().length > 0
+
+ if (!open) return null
+
+ return (
+
+
+
+
{title}
+
{description}
+
+
+
+ {t('typeToConfirmPrefix')} {confirmValue} {t('typeToConfirmSuffix')}
+
+
setInputValue(e.target.value)}
+ placeholder={confirmValue}
+ autoFocus
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/common/ConfirmDialog.tsx b/ui/desktop/frontend/src/components/common/ConfirmDialog.tsx
new file mode 100644
index 00000000..8c55cc12
--- /dev/null
+++ b/ui/desktop/frontend/src/components/common/ConfirmDialog.tsx
@@ -0,0 +1,59 @@
+import { useTranslation } from 'react-i18next'
+
+interface ConfirmDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ title: string
+ description: string
+ confirmLabel?: string
+ cancelLabel?: string
+ variant?: 'default' | 'destructive'
+ onConfirm: () => void
+ loading?: boolean
+}
+
+export function ConfirmDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ confirmLabel,
+ cancelLabel,
+ variant = 'default',
+ onConfirm,
+ loading,
+}: ConfirmDialogProps) {
+ const { t } = useTranslation('common')
+ const resolvedConfirmLabel = confirmLabel ?? t('confirm')
+ const resolvedCancelLabel = cancelLabel ?? t('cancel')
+ if (!open) return null
+
+ return (
+
+
+
+
{title}
+
{description}
+
+
+
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/common/EditionCompareModal.tsx b/ui/desktop/frontend/src/components/common/EditionCompareModal.tsx
new file mode 100644
index 00000000..18159d40
--- /dev/null
+++ b/ui/desktop/frontend/src/components/common/EditionCompareModal.tsx
@@ -0,0 +1,136 @@
+import { useTranslation } from 'react-i18next'
+
+interface EditionCompareModalProps {
+ open: boolean
+ onClose: () => void
+}
+
+type FeatureVal = true | false | string
+
+interface FeatureRow {
+ key: string
+ lite: FeatureVal
+ standard: FeatureVal
+}
+
+interface FeatureGroup {
+ group: string
+ rows: FeatureRow[]
+}
+
+const FEATURES: FeatureGroup[] = [
+ {
+ group: 'limits',
+ rows: [
+ { key: 'agents', lite: 'Max 5', standard: true },
+ { key: 'teams', lite: 'Max 1', standard: true },
+ { key: 'teamMembers', lite: 'Max 5', standard: true },
+ { key: 'sessions', lite: 'Max 50', standard: true },
+ ],
+ },
+ {
+ group: 'core',
+ rows: [
+ { key: 'chat', lite: true, standard: true },
+ { key: 'tools', lite: true, standard: true },
+ { key: 'mcpServers', lite: true, standard: true },
+ { key: 'skills', lite: true, standard: true },
+ { key: 'memory', lite: 'FTS5', standard: 'pgvector' },
+ { key: 'cron', lite: true, standard: true },
+ { key: 'traces', lite: 'Compact', standard: 'Full' },
+ ],
+ },
+ {
+ group: 'standardOnly',
+ rows: [
+ { key: 'taskActions', lite: 'Core lifecycle', standard: 'Full + review/approve' },
+ { key: 'channels', lite: false, standard: true },
+ { key: 'heartbeat', lite: false, standard: true },
+ { key: 'storage', lite: false, standard: true },
+ { key: 'skillManage', lite: false, standard: true },
+ { key: 'knowledgeGraph', lite: false, standard: true },
+ { key: 'vectorSearch', lite: false, standard: true },
+ { key: 'rbac', lite: false, standard: true },
+ { key: 'multiTenant', lite: false, standard: true },
+ { key: 'tenantUsers', lite: false, standard: true },
+ { key: 'agentLinks', lite: false, standard: true },
+ { key: 'activityLogs', lite: false, standard: true },
+ { key: 'apiKeys', lite: false, standard: true },
+ { key: 'poolProvider', lite: false, standard: true },
+ { key: 'secureCli', lite: false, standard: true },
+ ],
+ },
+]
+
+function FeatureCell({ value }: { value: FeatureVal }) {
+ if (value === true) {
+ return (
+
+
+
+ )
+ }
+ if (value === false) {
+ return —
+ }
+ return {value}
+}
+
+export function EditionCompareModal({ open, onClose }: EditionCompareModalProps) {
+ const { t } = useTranslation('teams')
+
+ if (!open) return null
+
+ return (
+
+
e.stopPropagation()}
+ className="bg-surface-primary border border-border rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[80vh] flex flex-col"
+ >
+ {/* Header */}
+
+
+
+ {t('editionCompare', 'GoClaw Lite vs Standard')}
+
+
+ {t('editionUpgrade', 'Upgrade to Standard with PostgreSQL for full features')}
+
+
+
+
+
+ {/* Scrollable content */}
+
+ {/* Column headers */}
+
+ {t('feature', 'Feature')}
+ Lite
+ Standard
+
+
+ {FEATURES.map((group) => (
+
+
+ {t(`edition.group.${group.group}`, group.group)}
+
+ {group.rows.map((row) => (
+
+
{t(`edition.${row.key}`, row.key)}
+
+
+
+ ))}
+
+ ))}
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/common/ErrorBoundary.tsx b/ui/desktop/frontend/src/components/common/ErrorBoundary.tsx
new file mode 100644
index 00000000..f9a27032
--- /dev/null
+++ b/ui/desktop/frontend/src/components/common/ErrorBoundary.tsx
@@ -0,0 +1,46 @@
+import { Component, type ReactNode, type ErrorInfo } from 'react'
+
+interface Props {
+ children: ReactNode
+ fallback?: ReactNode
+}
+
+interface State {
+ hasError: boolean
+ error: Error | null
+}
+
+export class ErrorBoundary extends Component {
+ state: State = { hasError: false, error: null }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error }
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ console.error('[ErrorBoundary]', error, info.componentStack)
+ }
+
+ render() {
+ if (this.state.hasError) {
+ if (this.props.fallback) return this.props.fallback
+
+ return (
+
+
+
Something went wrong
+
{this.state.error?.message}
+
+
+
+ )
+ }
+
+ return this.props.children
+ }
+}
diff --git a/ui/desktop/frontend/src/components/common/KeyValueEditor.tsx b/ui/desktop/frontend/src/components/common/KeyValueEditor.tsx
new file mode 100644
index 00000000..b0a8e296
--- /dev/null
+++ b/ui/desktop/frontend/src/components/common/KeyValueEditor.tsx
@@ -0,0 +1,97 @@
+import { useState } from 'react'
+
+interface KeyValueEditorProps {
+ value: Record
+ onChange: (v: Record) => void
+ sensitivePattern?: RegExp
+ placeholder?: { key?: string; value?: string }
+}
+
+interface Row {
+ id: number
+ key: string
+ value: string
+}
+
+let nextRowId = 1
+
+function toRows(obj: Record): Row[] {
+ const entries = Object.entries(obj)
+ if (entries.length === 0) return []
+ return entries.map(([key, value]) => ({ id: nextRowId++, key, value }))
+}
+
+function fromRows(rows: Row[]): Record {
+ const result: Record = {}
+ for (const row of rows) {
+ if (row.key.trim()) result[row.key.trim()] = row.value
+ }
+ return result
+}
+
+export function KeyValueEditor({ value, onChange, sensitivePattern, placeholder }: KeyValueEditorProps) {
+ const [rows, setRows] = useState(() => toRows(value))
+
+ function updateAndEmit(updated: Row[]) {
+ setRows(updated)
+ onChange(fromRows(updated))
+ }
+
+ function addRow() {
+ updateAndEmit([...rows, { id: nextRowId++, key: '', value: '' }])
+ }
+
+ function removeRow(id: number) {
+ updateAndEmit(rows.filter((r) => r.id !== id))
+ }
+
+ function updateRow(id: number, field: 'key' | 'value', val: string) {
+ updateAndEmit(rows.map((r) => r.id === id ? { ...r, [field]: val } : r))
+ }
+
+ function isSensitive(key: string): boolean {
+ if (!sensitivePattern || !key) return false
+ return sensitivePattern.test(key)
+ }
+
+ return (
+
+ {rows.map((row) => (
+
+ ))}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/common/RefreshButton.tsx b/ui/desktop/frontend/src/components/common/RefreshButton.tsx
new file mode 100644
index 00000000..688cbc39
--- /dev/null
+++ b/ui/desktop/frontend/src/components/common/RefreshButton.tsx
@@ -0,0 +1,46 @@
+import { useState, useCallback } from 'react'
+
+const MIN_SPIN_MS = 500
+
+interface RefreshButtonProps {
+ onRefresh: () => Promise
+ className?: string
+}
+
+export function RefreshButton({ onRefresh, className }: RefreshButtonProps) {
+ const [spinning, setSpinning] = useState(false)
+
+ const handleClick = useCallback(async () => {
+ if (spinning) return
+ setSpinning(true)
+ const min = new Promise((r) => setTimeout(r, MIN_SPIN_MS))
+ try {
+ await Promise.all([onRefresh(), min])
+ } finally {
+ setSpinning(false)
+ }
+ }, [onRefresh, spinning])
+
+ return (
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/common/SplashScreen.tsx b/ui/desktop/frontend/src/components/common/SplashScreen.tsx
new file mode 100644
index 00000000..5bce692e
--- /dev/null
+++ b/ui/desktop/frontend/src/components/common/SplashScreen.tsx
@@ -0,0 +1,33 @@
+/** Splash screen shown while gateway boots. */
+export function SplashScreen({ ready }: { ready: boolean }) {
+ return (
+
+ {/* Logo with gentle pulse */}
+

+
+ {/* App name */}
+
GoClaw
+
Desktop AI Gateway
+
+ {/* Status */}
+
+ {ready ? (
+ <>
+
+
Ready
+ >
+ ) : (
+ <>
+
+
Starting gateway...
+ >
+ )}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/common/Switch.tsx b/ui/desktop/frontend/src/components/common/Switch.tsx
new file mode 100644
index 00000000..e1a72129
--- /dev/null
+++ b/ui/desktop/frontend/src/components/common/Switch.tsx
@@ -0,0 +1,32 @@
+interface SwitchProps {
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+ disabled?: boolean
+ id?: string
+}
+
+/** Toggle switch matching web UI's Radix Switch style */
+export function Switch({ checked, onCheckedChange, disabled, id }: SwitchProps) {
+ return (
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/common/Toaster.tsx b/ui/desktop/frontend/src/components/common/Toaster.tsx
new file mode 100644
index 00000000..bfda68c6
--- /dev/null
+++ b/ui/desktop/frontend/src/components/common/Toaster.tsx
@@ -0,0 +1,64 @@
+import { useToastStore, type Toast } from '../../stores/toast-store'
+
+const styles: Record = {
+ success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
+ destructive: 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300',
+ warning: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
+ default: 'border-border bg-surface-secondary text-text-primary',
+}
+
+function ToastIcon({ variant }: { variant: Toast['variant'] }) {
+ const cls = 'mt-0.5 h-4 w-4 shrink-0'
+ switch (variant) {
+ case 'success':
+ return ()
+ case 'destructive':
+ return ()
+ case 'warning':
+ return ()
+ default:
+ return ()
+ }
+}
+
+function ToastItem({ toast }: { toast: Toast }) {
+ const dismiss = useToastStore((s) => s.dismiss)
+
+ return (
+
+
+
+
{toast.title}
+ {toast.message && (
+
{toast.message}
+ )}
+
+
+
+ )
+}
+
+export function Toaster() {
+ const toasts = useToastStore((s) => s.toasts)
+
+ if (toasts.length === 0) return null
+
+ return (
+
+ {toasts.slice(-3).map((t) => (
+
+ ))}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/layout/AppShell.tsx b/ui/desktop/frontend/src/components/layout/AppShell.tsx
new file mode 100644
index 00000000..7392c431
--- /dev/null
+++ b/ui/desktop/frontend/src/components/layout/AppShell.tsx
@@ -0,0 +1,46 @@
+import { lazy, Suspense } from 'react'
+import { useUiStore } from '../../stores/ui-store'
+import { Sidebar } from './Sidebar'
+import { SettingsView } from '../settings/SettingsView'
+
+const TeamBoard = lazy(() => import('../teams/TeamBoard').then((m) => ({ default: m.TeamBoard })))
+
+interface AppShellProps {
+ children: React.ReactNode // ChatCanvas
+}
+
+export function AppShell({ children }: AppShellProps) {
+ const sidebarOpen = useUiStore((s) => s.sidebarOpen)
+ const activeView = useUiStore((s) => s.activeView)
+
+ let mainContent: React.ReactNode
+ switch (activeView) {
+ case 'settings':
+ mainContent =
+ break
+ case 'team-board':
+ mainContent =
+ break
+ default:
+ mainContent = children
+ }
+
+ return (
+
+ {/* Wails drag region — transparent overlay at top */}
+
+
+ {/* Sidebar panel */}
+ {sidebarOpen && (
+
+
+
+ )}
+
+ {/* Main panel — chat, settings, or team board */}
+
+ {mainContent}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/layout/Sidebar.tsx b/ui/desktop/frontend/src/components/layout/Sidebar.tsx
new file mode 100644
index 00000000..db64d4f8
--- /dev/null
+++ b/ui/desktop/frontend/src/components/layout/Sidebar.tsx
@@ -0,0 +1,28 @@
+import { SidebarHeader } from './sidebar/SidebarHeader'
+import { SidebarTeams } from './sidebar/SidebarTeams'
+import { SessionList } from './sidebar/SessionList'
+import { SidebarFooter } from './sidebar/SidebarFooter'
+
+export function Sidebar() {
+ return (
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/layout/sidebar/SessionList.tsx b/ui/desktop/frontend/src/components/layout/sidebar/SessionList.tsx
new file mode 100644
index 00000000..c8b49ac2
--- /dev/null
+++ b/ui/desktop/frontend/src/components/layout/sidebar/SessionList.tsx
@@ -0,0 +1,98 @@
+import { useMemo, useState } from 'react'
+import { useSessions } from '../../../hooks/use-sessions'
+import { useUiStore } from '../../../stores/ui-store'
+import { ConfirmDialog } from '../../common/ConfirmDialog'
+
+function groupByDate(sessions: Array<{ key: string; title: string; lastMessageAt: number }>) {
+ const now = new Date()
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
+ const yesterday = today - 86400000
+
+ const groups: { label: string; items: typeof sessions }[] = [
+ { label: 'Today', items: [] },
+ { label: 'Yesterday', items: [] },
+ { label: 'Older', items: [] },
+ ]
+
+ for (const s of sessions) {
+ if (s.lastMessageAt >= today) groups[0].items.push(s)
+ else if (s.lastMessageAt >= yesterday) groups[1].items.push(s)
+ else groups[2].items.push(s)
+ }
+
+ return groups.filter((g) => g.items.length > 0)
+}
+
+export function SessionList() {
+ const { sessions, activeSessionKey, setActiveSession, deleteSession } = useSessions()
+ const activeView = useUiStore((s) => s.activeView)
+ const closeSettings = useUiStore((s) => s.closeSettings)
+ const [confirmKey, setConfirmKey] = useState(null)
+
+ const groups = useMemo(() => groupByDate(sessions), [sessions])
+
+ if (sessions.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+ <>
+ { if (!open) setConfirmKey(null) }}
+ title="Delete conversation?"
+ description="This action cannot be undone."
+ confirmLabel="Delete"
+ variant="destructive"
+ onConfirm={() => { if (confirmKey) { deleteSession(confirmKey); setConfirmKey(null) } }}
+ />
+
+ {groups.map((group) => (
+
+
+ {group.label}
+
+
+ {group.items.map((session) => (
+
+
+
+
+ ))}
+
+
+ ))}
+
+ >
+ )
+}
diff --git a/ui/desktop/frontend/src/components/layout/sidebar/SidebarFooter.tsx b/ui/desktop/frontend/src/components/layout/sidebar/SidebarFooter.tsx
new file mode 100644
index 00000000..ab50768c
--- /dev/null
+++ b/ui/desktop/frontend/src/components/layout/sidebar/SidebarFooter.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useSessions } from '../../../hooks/use-sessions'
+import { useUiStore } from '../../../stores/ui-store'
+import { getWsClient } from '../../../lib/ws'
+
+export function SidebarFooter() {
+ const { t } = useTranslation('desktop')
+ const { createSession } = useSessions()
+ const openSettings = useUiStore((s) => s.openSettings)
+ const closeSettings = useUiStore((s) => s.closeSettings)
+ const toggleTheme = useUiStore((s) => s.toggleTheme)
+ const theme = useUiStore((s) => s.theme)
+
+ const [connected, setConnected] = useState(() => {
+ try { return getWsClient().isConnected } catch { return false }
+ })
+
+ useEffect(() => {
+ let ws: ReturnType
+ try { ws = getWsClient() } catch { return }
+ ws.onConnectionChange(setConnected)
+ return () => { ws.onConnectionChange(() => {}) }
+ }, [])
+
+ return (
+
+
+
+
+ {/* Connection status */}
+
+
+ {connected ? t('sidebar.connected') : t('sidebar.offline')}
+
+
+ {/* Action buttons */}
+
+ {/* Theme toggle */}
+
+
+ {/* Settings */}
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/layout/sidebar/SidebarHeader.tsx b/ui/desktop/frontend/src/components/layout/sidebar/SidebarHeader.tsx
new file mode 100644
index 00000000..a36861ce
--- /dev/null
+++ b/ui/desktop/frontend/src/components/layout/sidebar/SidebarHeader.tsx
@@ -0,0 +1,73 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useAgents } from '../../../hooks/use-agents'
+import { useUiStore } from '../../../stores/ui-store'
+import { AgentAvatar } from '../../agents/AgentAvatar'
+import { EditionCompareModal } from '../../common/EditionCompareModal'
+
+const MAX_AGENTS_LITE = 5
+
+export function SidebarHeader() {
+ const { t } = useTranslation('agents')
+ const { agents, selectedAgent, selectAgent } = useAgents()
+ const openSettings = useUiStore((s) => s.openSettings)
+ const closeSettings = useUiStore((s) => s.closeSettings)
+ const [editionOpen, setEditionOpen] = useState(false)
+
+ const atLimit = agents.length >= MAX_AGENTS_LITE
+
+ return (
+
+ {/* Logo */}
+
+

+
GoClaw
+
+
+
+ {/* Section title + add button */}
+
+
+ {t('title', 'Agents')} ({agents.length}/{MAX_AGENTS_LITE})
+
+
+
+
+ {/* Agent list */}
+
+ {agents.map((agent) => (
+
+ ))}
+
+
+ {/* Edition comparison modal */}
+
setEditionOpen(false)} />
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/layout/sidebar/SidebarTeams.tsx b/ui/desktop/frontend/src/components/layout/sidebar/SidebarTeams.tsx
new file mode 100644
index 00000000..412945c7
--- /dev/null
+++ b/ui/desktop/frontend/src/components/layout/sidebar/SidebarTeams.tsx
@@ -0,0 +1,93 @@
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useUiStore } from '../../../stores/ui-store'
+import { useAgentStore } from '../../../stores/agent-store'
+import { getWsClient } from '../../../lib/ws'
+import { TeamCreateDialog } from '../../teams/TeamCreateDialog'
+import type { TeamData } from '../../../types/team'
+
+const MAX_TEAMS_LITE = 1
+
+export function SidebarTeams() {
+ const { t } = useTranslation('teams')
+ const [teams, setTeams] = useState([])
+ const [createOpen, setCreateOpen] = useState(false)
+ const activeView = useUiStore((s) => s.activeView)
+ const activeTeamId = useUiStore((s) => s.activeTeamId)
+ const openTeamBoard = useUiStore((s) => s.openTeamBoard)
+ const agents = useAgentStore((s) => s.agents)
+
+ useEffect(() => {
+ getWsClient().call('teams.list').then((res) => {
+ const r = res as { teams: TeamData[] | null }
+ setTeams(r.teams ?? [])
+ }).catch(() => {})
+ }, [])
+
+ const atLimit = teams.length >= MAX_TEAMS_LITE
+
+ return (
+
+ {/* Section header + add button */}
+
+
+ {t('title', 'Teams')} ({teams.length}/{MAX_TEAMS_LITE})
+
+
+
+
+ {/* Team list */}
+ {teams.map((team) => (
+
+ ))}
+
+ {/* Empty state */}
+ {teams.length === 0 && (
+
{t('noTeams', 'No teams yet')}
+ )}
+
+ {/* Create team dialog */}
+ {createOpen && (
+
({ id: a.id, key: a.key, name: a.name, emoji: a.emoji }))}
+ onClose={() => setCreateOpen(false)}
+ onCreated={(team) => {
+ setTeams((prev) => [...prev, team])
+ openTeamBoard(team.id)
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/onboarding/AgentStep.tsx b/ui/desktop/frontend/src/components/onboarding/AgentStep.tsx
new file mode 100644
index 00000000..8eb5e117
--- /dev/null
+++ b/ui/desktop/frontend/src/components/onboarding/AgentStep.tsx
@@ -0,0 +1,197 @@
+import { useState, useMemo, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import { getApiClient } from '../../lib/api'
+
+import { SummoningModal } from './SummoningModal'
+import type { ProviderData } from '../../types/provider'
+
+// Preset keys — labels & prompts from agents.json (web) or desktop.json
+// agentKey: stable English slug used as agent_key (never translated)
+const PRESET_KEYS = [
+ { key: 'foxSpirit', emoji: '🦊', ns: 'agents', agentKey: 'little-fox' },
+ { key: 'artisan', emoji: '🎨', ns: 'agents', agentKey: 'artisan' },
+ { key: 'astrologer', emoji: '🔮', ns: 'agents', agentKey: 'mimi' },
+ { key: 'researcher', emoji: '🔬', ns: 'desktop', agentKey: 'scholar' },
+ { key: 'writer', emoji: '✍️', ns: 'desktop', agentKey: 'quill' },
+ { key: 'coder', emoji: '👨💻', ns: 'desktop', agentKey: 'dev' },
+]
+
+interface AgentStepProps {
+ provider: ProviderData
+ model: string | null
+ onBack: () => void
+ onComplete: () => void
+}
+
+export function AgentStep({ provider, model, onBack, onComplete }: AgentStepProps) {
+ const { t } = useTranslation(['desktop', 'agents', 'common'])
+ const [selectedPresetIdx, setSelectedPresetIdx] = useState(0)
+ const [description, setDescription] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [createdAgent, setCreatedAgent] = useState<{ id: string; name: string } | null>(null)
+
+ // Get prompt text from locale for the selected preset
+ function getPresetPrompt(idx: number): string {
+ const preset = PRESET_KEYS[idx]
+ if (!preset) return ''
+ return t(`${preset.ns}:presets.${preset.key}.prompt`)
+ }
+
+ // Init description from first preset
+ useEffect(() => {
+ if (!description) setDescription(getPresetPrompt(0))
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const displayName = useMemo(() => {
+ const preset = PRESET_KEYS[selectedPresetIdx]
+ if (preset) {
+ const label = t(`${preset.ns}:presets.${preset.key}.label`) as string
+ // Label format: "🦊 Fox Spirit" — strip emoji prefix
+ return label.replace(/^\S+\s*/, '')
+ }
+ return 'Fox Spirit'
+ }, [selectedPresetIdx, t])
+
+ // Agent key: stable English slug, never translated
+ const agentKey = useMemo(() => {
+ const preset = PRESET_KEYS[selectedPresetIdx]
+ return preset?.agentKey ?? 'little-fox'
+ }, [selectedPresetIdx])
+ const selectedEmoji = PRESET_KEYS[selectedPresetIdx]?.emoji ?? '🦊'
+
+ // Sync description when preset changes
+ useEffect(() => {
+ if (selectedPresetIdx >= 0) {
+ setDescription(getPresetPrompt(selectedPresetIdx))
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedPresetIdx])
+
+ const handleDescriptionChange = (value: string) => {
+ setDescription(value)
+ if (selectedPresetIdx >= 0) {
+ const presetPrompt = getPresetPrompt(selectedPresetIdx)
+ if (value !== presetPrompt) setSelectedPresetIdx(-1)
+ }
+ }
+
+ const handleSubmit = async () => {
+ if (!description.trim()) return
+ setLoading(true)
+ setError('')
+ try {
+ const otherConfig: Record = {}
+ if (description.trim()) otherConfig.description = description.trim()
+ if (selectedEmoji) otherConfig.emoji = selectedEmoji
+
+ const result = await getApiClient().post<{ id: string }>('/v1/agents', {
+ agent_key: agentKey,
+ display_name: displayName.trim() || undefined,
+ provider: provider.name,
+ model: model || '',
+ agent_type: 'predefined',
+ is_default: true,
+ other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined,
+ })
+ setCreatedAgent({ id: result.id, name: displayName.trim() || agentKey })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to create agent')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const providerLabel = provider.display_name || provider.name
+
+ if (createdAgent) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
{t('onboarding.agentStep')}
+
{t('onboarding.agentStepDesc')}
+
+
+ {/* Provider + model info */}
+
+
+ {t('common:provider')}
+
+ {providerLabel}
+
+
+ {model && (
+
+ {t('common:model')}
+
+ {model}
+
+
+ )}
+
+
+ {/* Preset personality buttons */}
+
+
+
+ {PRESET_KEYS.map((preset, idx) => (
+
+ ))}
+
+
+
+ {/* Description textarea */}
+
+
+
+
+ {error &&
{error}
}
+
+
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/onboarding/ModelVerifyStep.tsx b/ui/desktop/frontend/src/components/onboarding/ModelVerifyStep.tsx
new file mode 100644
index 00000000..39e0bc5f
--- /dev/null
+++ b/ui/desktop/frontend/src/components/onboarding/ModelVerifyStep.tsx
@@ -0,0 +1,172 @@
+import { useEffect, useState, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { getApiClient } from '../../lib/api'
+import { Combobox } from '../common/Combobox'
+import type { ProviderData } from '../../types/provider'
+
+const VERIFY_TIMEOUT_SECS = 30
+
+interface ModelVerifyStepProps {
+ provider: ProviderData
+ initialModel?: string | null
+ onBack: () => void
+ onComplete: (model: string) => void
+}
+
+export function ModelVerifyStep({ provider, initialModel, onBack, onComplete }: ModelVerifyStepProps) {
+ const { t } = useTranslation(['desktop', 'common'])
+ const [models, setModels] = useState([])
+ const [model, setModel] = useState(initialModel ?? '')
+ const [loading, setLoading] = useState(true)
+ const [verifying, setVerifying] = useState(false)
+ const [verified, setVerified] = useState(false)
+ const [error, setError] = useState('')
+ const [countdown, setCountdown] = useState(0)
+ const timerRef = useRef | null>(null)
+
+ // Fetch available models
+ useEffect(() => {
+ if (!provider.id) return
+ setLoading(true)
+ console.info('[models] fetching for provider:', provider.id, provider.name)
+ getApiClient()
+ .get<{ models?: { id: string; name?: string }[] }>(`/v1/providers/${provider.id}/models`)
+ .then((res) => {
+ console.info('[models] response:', res)
+ const ids = (res.models ?? []).map((m) => m.id)
+ setModels(ids)
+ if (!model && ids.length > 0) setModel(ids[0])
+ })
+ .catch((err) => {
+ console.warn('[models] fetch failed:', err)
+ })
+ .finally(() => setLoading(false))
+ }, [provider.id])
+
+ // Reset verification when model changes
+ useEffect(() => {
+ setVerified(false)
+ setError('')
+ }, [model])
+
+ // Countdown timer during verification
+ useEffect(() => {
+ if (verifying) {
+ setCountdown(VERIFY_TIMEOUT_SECS)
+ timerRef.current = setInterval(() => {
+ setCountdown((prev) => (prev <= 1 ? 0 : prev - 1))
+ }, 1000)
+ } else {
+ setCountdown(0)
+ if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null }
+ }
+ return () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null } }
+ }, [verifying])
+
+ const handleVerify = async () => {
+ if (!model.trim()) return
+ setVerifying(true)
+ setError('')
+ setVerified(false)
+ try {
+ const result = await getApiClient().post<{ valid: boolean; error?: string }>(
+ `/v1/providers/${provider.id}/verify`,
+ { model: model.trim() }
+ )
+ if (result.valid) {
+ setVerified(true)
+ } else {
+ setError(result.error ?? t('desktop:agent.verifyFailed'))
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : t('desktop:agent.verifyFailed'))
+ } finally {
+ setVerifying(false)
+ }
+ }
+
+ const providerLabel = provider.display_name || provider.name
+
+ const verifyButtonLabel = verifying
+ ? `${t('desktop:agent.verifying')} (${countdown}s)`
+ : verified
+ ? t('desktop:agent.verified')
+ : t('desktop:agent.verifyModel')
+
+ return (
+
+
+
Select & Verify Model
+
Choose a model and test the connection.
+
+
+ {/* Provider badge */}
+
+ Provider
+
+ {providerLabel}
+
+
+
+ {/* Model selection */}
+
+
+
({ value: m, label: m }))}
+ placeholder={loading ? t('common:loadingModels') : t('common:enterOrSelectModel')}
+ loading={loading}
+ allowCustom
+ />
+ {!loading && models.length === 0 && (
+
+ No models returned by API. You can type a model name manually.
+
+ )}
+
+
+ {/* Verification status */}
+ {error && (
+
{error}
+ )}
+ {verified && (
+
+
+
+
Model verified
+
{model} is working with {providerLabel}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/onboarding/OnboardingWizard.tsx b/ui/desktop/frontend/src/components/onboarding/OnboardingWizard.tsx
new file mode 100644
index 00000000..71f2cc2e
--- /dev/null
+++ b/ui/desktop/frontend/src/components/onboarding/OnboardingWizard.tsx
@@ -0,0 +1,94 @@
+import { useState, useEffect } from 'react'
+import { useBootstrapStatus, type SetupStep } from '../../hooks/use-bootstrap-status'
+import { SetupStepper } from './SetupStepper'
+import { ProviderStep } from './ProviderStep'
+import { ModelVerifyStep } from './ModelVerifyStep'
+import { AgentStep } from './AgentStep'
+import type { ProviderData } from '../../types/provider'
+
+interface OnboardingWizardProps {
+ onComplete: () => void
+}
+
+export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
+ const { currentStep, loading, providers } = useBootstrapStatus()
+ const [step, setStep] = useState<1 | 2 | 3>(1)
+ const [createdProvider, setCreatedProvider] = useState(null)
+ const [selectedModel, setSelectedModel] = useState(null)
+ const [initialized, setInitialized] = useState(false)
+
+ // Initialize step from server state (only on first load)
+ useEffect(() => {
+ if (loading || initialized) return
+ if (currentStep === ('complete' as SetupStep)) {
+ onComplete()
+ return
+ }
+ setStep(currentStep as 1 | 2 | 3)
+ setInitialized(true)
+ }, [currentStep, loading, initialized, onComplete])
+
+ if (loading || !initialized) {
+ return (
+
+ )
+ }
+
+ const completedSteps: number[] = []
+ if (step > 1) completedSteps.push(1)
+ if (step > 2) completedSteps.push(2)
+
+ // Resume: find existing provider from server data (any enabled provider)
+ const activeProvider = createdProvider ?? providers.find((p) => p.enabled) ?? providers[0] ?? null
+
+ return (
+
+
+ {/* Header */}
+
+

+
GoClaw Setup
+
+ Let's get your gateway up and running
+
+
+
+
+
+ {step === 1 && (
+
{
+ setCreatedProvider(provider)
+ setStep(2)
+ }}
+ />
+ )}
+
+ {step === 2 && activeProvider && (
+ setStep(1)}
+ onComplete={(model) => {
+ setSelectedModel(model)
+ setStep(3)
+ }}
+ />
+ )}
+
+ {step === 3 && activeProvider && (
+ setStep(2)}
+ onComplete={onComplete}
+ />
+ )}
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/onboarding/ProviderStep.tsx b/ui/desktop/frontend/src/components/onboarding/ProviderStep.tsx
new file mode 100644
index 00000000..7c6291cd
--- /dev/null
+++ b/ui/desktop/frontend/src/components/onboarding/ProviderStep.tsx
@@ -0,0 +1,161 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { getApiClient } from '../../lib/api'
+import { PROVIDER_TYPES, slugify } from '../../constants/providers'
+import { Combobox } from '../common/Combobox'
+import type { ProviderData } from '../../types/provider'
+
+interface ProviderStepProps {
+ existingProvider?: ProviderData | null
+ onComplete: (provider: ProviderData) => void
+}
+
+export function ProviderStep({ existingProvider, onComplete }: ProviderStepProps) {
+ const { t } = useTranslation(['desktop', 'common'])
+ const isEditing = !!existingProvider
+
+ const [providerType, setProviderType] = useState(existingProvider?.provider_type ?? 'openrouter')
+ const [name, setName] = useState(existingProvider?.name ?? 'openrouter')
+ const [apiKey, setApiKey] = useState('')
+ const [apiBase, setApiBase] = useState(
+ existingProvider?.api_base ?? 'https://openrouter.ai/api/v1'
+ )
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+
+ const preset = PROVIDER_TYPES.find((t) => t.value === providerType)
+ const needsKey = preset?.needsKey ?? true
+
+ const handleTypeChange = (value: string) => {
+ setProviderType(value)
+ const p = PROVIDER_TYPES.find((t) => t.value === value)
+ setName(slugify(value))
+ setApiBase(p?.apiBase || '')
+ setApiKey('')
+ setError('')
+ }
+
+ const handleSubmit = async () => {
+ if (!isEditing && needsKey && !apiKey.trim()) {
+ setError('API key is required')
+ return
+ }
+ setLoading(true)
+ setError('')
+ try {
+ const api = getApiClient()
+ if (isEditing) {
+ const patch: Record = {
+ name: name.trim(),
+ provider_type: providerType,
+ api_base: apiBase.trim() || undefined,
+ }
+ if (apiKey.trim()) patch.api_key = apiKey.trim()
+ await api.put(`/v1/providers/${existingProvider!.id}`, patch)
+ onComplete({ ...existingProvider!, ...patch } as ProviderData)
+ } else {
+ const payload: Record = {
+ name: name.trim(),
+ provider_type: providerType,
+ api_base: apiBase.trim() || undefined,
+ enabled: true,
+ }
+ if (needsKey) payload.api_key = apiKey.trim()
+
+ // Check if provider with same name already exists → update instead of create
+ const list = await api.get<{ providers?: ProviderData[] | null }>('/v1/providers')
+ const existing = (list.providers ?? []).find((p) => p.name === name.trim())
+ if (existing) {
+ await api.put(`/v1/providers/${existing.id}`, payload)
+ onComplete({ ...existing, ...payload } as ProviderData)
+ } else {
+ const result = await api.post('/v1/providers', payload)
+ onComplete(result)
+ }
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to create provider')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
Configure Provider
+
Select an AI provider and enter your credentials.
+
+
+
+ {/* Provider type select */}
+
+
+ ({ value: t.value, label: t.label }))}
+ placeholder="Search providers..."
+ allowCustom={false}
+ />
+
+
+ {/* Name (slug) */}
+
+
+ setName(slugify(e.target.value))}
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2.5 text-base md:text-sm text-text-primary font-mono focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+
+ {/* API Key */}
+ {needsKey && (
+
+
+ { setApiKey(e.target.value); setError('') }}
+ placeholder="sk-..."
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2.5 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+ )}
+
+ {/* API Base */}
+ {needsKey && (
+
+
+ setApiBase(e.target.value)}
+ placeholder="https://api.example.com/v1"
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2.5 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+ )}
+
+ {!needsKey && (
+
No API key required for {preset?.label}.
+ )}
+
+ {error &&
{error}
}
+
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/onboarding/SetupStepper.tsx b/ui/desktop/frontend/src/components/onboarding/SetupStepper.tsx
new file mode 100644
index 00000000..22be6c55
--- /dev/null
+++ b/ui/desktop/frontend/src/components/onboarding/SetupStepper.tsx
@@ -0,0 +1,65 @@
+import { useTranslation } from 'react-i18next'
+
+interface SetupStepperProps {
+ currentStep: 1 | 2 | 3
+ completedSteps: number[]
+}
+
+export function SetupStepper({ currentStep, completedSteps }: SetupStepperProps) {
+ const { t } = useTranslation('desktop')
+
+ const STEPS = [
+ { num: 1, label: t('onboarding.stepProvider') },
+ { num: 2, label: t('onboarding.stepModel') },
+ { num: 3, label: t('onboarding.stepAgent') },
+ ]
+
+ return (
+
+ {STEPS.map((step, i) => {
+ const isCompleted = completedSteps.includes(step.num)
+ const isCurrent = step.num === currentStep
+
+ return (
+
+
+
+ {isCompleted ? (
+
+ ) : step.num}
+
+
+ {step.label}
+
+
+
+ {i < STEPS.length - 1 && (
+
+ )}
+
+ )
+ })}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/onboarding/SummoningModal.tsx b/ui/desktop/frontend/src/components/onboarding/SummoningModal.tsx
new file mode 100644
index 00000000..54258c8f
--- /dev/null
+++ b/ui/desktop/frontend/src/components/onboarding/SummoningModal.tsx
@@ -0,0 +1,235 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { motion, AnimatePresence } from 'framer-motion'
+import { getWsClient } from '../../lib/ws'
+
+const SUMMONING_FILE_KEYS = [
+ { name: 'SOUL.md', required: true, labelKey: 'summoning.fileLabelSOUL' },
+ { name: 'IDENTITY.md', required: true, labelKey: 'summoning.fileLabelIDENTITY' },
+ { name: 'USER_PREDEFINED.md', required: false, labelKey: 'summoning.fileLabelUSER_PREDEFINED' },
+]
+
+interface SummoningModalProps {
+ agentId: string
+ agentName: string
+ onContinue: () => void
+}
+
+export function SummoningModal({ agentId, agentName, onContinue }: SummoningModalProps) {
+ const { t } = useTranslation(['desktop', 'common'])
+ const [generatedFiles, setGeneratedFiles] = useState([])
+ const [status, setStatus] = useState<'summoning' | 'completed' | 'failed'>('summoning')
+ const [errorMsg, setErrorMsg] = useState('')
+ const [retrying, setRetrying] = useState(false)
+ const [elapsed, setElapsed] = useState(0)
+ const timerRef = useRef | null>(null)
+
+ // Elapsed timer — runs while summoning, stops on completion/failure
+ useEffect(() => {
+ if (status === 'summoning') {
+ timerRef.current = setInterval(() => setElapsed((prev) => prev + 1), 1000)
+ } else if (timerRef.current) {
+ clearInterval(timerRef.current)
+ timerRef.current = null
+ }
+ return () => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current)
+ timerRef.current = null
+ }
+ }
+ }, [status])
+
+ // Listen to agent.summoning WS events
+ const handleSummoningEvent = useCallback(
+ (payload: unknown) => {
+ const data = payload as Record
+ if (data.agent_id !== agentId) return
+
+ if (data.type === 'file_generated' && data.file) {
+ setGeneratedFiles((prev) =>
+ prev.includes(data.file) ? prev : [...prev, data.file],
+ )
+ }
+ if (data.type === 'completed') {
+ const required = SUMMONING_FILE_KEYS.filter((f) => f.required).map((f) => f.name)
+ setGeneratedFiles((prev) => [...new Set([...prev, ...required])])
+ setStatus('completed')
+ }
+ if (data.type === 'failed') {
+ setStatus('failed')
+ setErrorMsg(data.error || t('desktop:summoning.failed'))
+ }
+ },
+ [agentId],
+ )
+
+ useEffect(() => {
+ try {
+ const ws = getWsClient()
+ const unsub = ws.on('agent.summoning', handleSummoningEvent)
+ return unsub
+ } catch { /* ws not ready */ }
+ }, [handleSummoningEvent])
+
+ const handleRetry = async () => {
+ setRetrying(true)
+ try {
+ const ws = getWsClient()
+ await ws.call('agents.resummon', { agentId })
+ setGeneratedFiles([])
+ setStatus('summoning')
+ setErrorMsg('')
+ setElapsed(0)
+ } catch {
+ // stay in failed state
+ } finally {
+ setRetrying(false)
+ }
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+ {status === 'completed'
+ ? t('desktop:summoning.completedTitle')
+ : status === 'failed'
+ ? t('desktop:summoning.failedTitle')
+ : t('desktop:summoning.title')}
+
+
+
+
+ {/* Animated orb — ported from web UI */}
+
+ {status === 'summoning' && (
+ <>
+
+
+ >
+ )}
+
+ {status === 'completed' ? '✨' : status === 'failed' ? '💨' : '🪄'}
+
+
+
+ {/* Agent name */}
+
+ {status === 'completed' ? (
+
+ {agentName} is ready!
+
+ ) : status === 'failed' ? (
+
+ {errorMsg || t('desktop:summoning.failed')}
+
+ ) : (
+ <>Weaving soul for {agentName}...>
+ )}
+
+
+ {/* File progress */}
+
+
+ {SUMMONING_FILE_KEYS.map((file, i) => {
+ const done = generatedFiles.includes(file.name)
+ return (
+
+
+ {done ? '✓' : i + 1}
+
+
+
+ {file.name}
+
+ {t(`desktop:${file.labelKey}`)}
+
+ {done && (
+
+ Done
+
+ )}
+
+ )
+ })}
+
+
+
+ {status === 'summoning' && (
+
+ Please wait... ({Math.floor(elapsed / 60)}:{String(elapsed % 60).padStart(2, '0')})
+
+ )}
+
+ {status === 'completed' && (
+
+ )}
+
+ {status === 'failed' && (
+
+ )}
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/AboutTab.tsx b/ui/desktop/frontend/src/components/settings/AboutTab.tsx
new file mode 100644
index 00000000..efc0fe6a
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/AboutTab.tsx
@@ -0,0 +1,61 @@
+import { useTranslation } from 'react-i18next'
+import { BrowserOpenURL } from '../../../wailsjs/runtime/runtime'
+
+export function AboutTab() {
+ const { t } = useTranslation('desktop')
+ return (
+
+
+

+
+
{t('about.title')}
+
{t('about.subtitle')}
+
+
+
+
+
+ {t('about.version')}
+ 0.1.0-beta
+
+
+ {t('about.edition')}
+ Lite (SQLite)
+
+
+ {t('about.runtime')}
+ {t('about.runtimeValue')}
+
+
+
+
+
{t('about.editionLimits')}
+
+ {[
+ { label: t('about.agents'), limit: t('about.maxAgents') },
+ { label: t('about.teams'), limit: t('about.maxTeams') },
+ { label: t('about.teamMembers'), limit: t('about.maxTeamMembers') },
+ { label: t('about.database'), limit: t('about.databaseValue') },
+ { label: t('about.users'), limit: t('about.usersValue') },
+ ].map((item) => (
+
+ {item.label}
+ {item.limit}
+
+ ))}
+
+
+
+
+
+ ·
+ {t('about.builtWith')}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/AppearanceTab.tsx b/ui/desktop/frontend/src/components/settings/AppearanceTab.tsx
new file mode 100644
index 00000000..0da946d3
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/AppearanceTab.tsx
@@ -0,0 +1,73 @@
+import { useTranslation } from 'react-i18next'
+import { useUiStore } from '../../stores/ui-store'
+
+const LANGUAGES = [
+ { value: 'en', label: 'English' },
+ { value: 'vi', label: 'Tiếng Việt' },
+ { value: 'zh', label: '中文' },
+] as const
+
+export function AppearanceTab() {
+ const theme = useUiStore((s) => s.theme)
+ const toggleTheme = useUiStore((s) => s.toggleTheme)
+ const locale = useUiStore((s) => s.locale)
+ const setLocale = useUiStore((s) => s.setLocale)
+ const { t, i18n } = useTranslation('desktop')
+
+ function handleLanguageChange(lang: string) {
+ setLocale(lang)
+ i18n.changeLanguage(lang)
+ }
+
+ return (
+
+
+
{t('settings.theme')}
+
+ {(['dark', 'light'] as const).map((th) => (
+
+ ))}
+
+
+
+
+
{t('settings.language')}
+
+ {LANGUAGES.map((lang) => (
+
+ ))}
+
+
+
+
+
{t('settings.timezone')}
+
+ {useUiStore.getState().timezone}
+
+
{t('settings.timezoneHint')}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/SettingsTabBar.tsx b/ui/desktop/frontend/src/components/settings/SettingsTabBar.tsx
new file mode 100644
index 00000000..cea06a2e
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/SettingsTabBar.tsx
@@ -0,0 +1,33 @@
+import { useTranslation } from 'react-i18next'
+import type { SettingsTab } from '../../stores/ui-store'
+
+const TAB_KEYS: SettingsTab[] = [
+ 'appearance', 'providers', 'agents', 'mcp', 'skills', 'tools', 'cron', 'traces', 'about',
+]
+
+interface SettingsTabBarProps {
+ activeTab: SettingsTab
+ onTabChange: (tab: SettingsTab) => void
+}
+
+export function SettingsTabBar({ activeTab, onTabChange }: SettingsTabBarProps) {
+ const { t } = useTranslation('desktop')
+ return (
+
+ {TAB_KEYS.map((key) => (
+
+ ))}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/SettingsView.tsx b/ui/desktop/frontend/src/components/settings/SettingsView.tsx
new file mode 100644
index 00000000..4ca96754
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/SettingsView.tsx
@@ -0,0 +1,68 @@
+import { useUiStore } from '../../stores/ui-store'
+import { SettingsTabBar } from './SettingsTabBar'
+import { AppearanceTab } from './AppearanceTab'
+import { AboutTab } from './AboutTab'
+import { ProviderList } from './providers/ProviderList'
+import { AgentList } from './agents/AgentList'
+import { McpServerList } from './mcp/McpServerList'
+import { SkillList } from './skills/SkillList'
+import { ToolList } from './tools/ToolList'
+import { CronList } from './cron/CronList'
+import { TraceList } from './traces/TraceList'
+
+export function SettingsView() {
+ const settingsTab = useUiStore((s) => s.settingsTab)
+ const setSettingsTab = useUiStore((s) => s.setSettingsTab)
+ const closeSettings = useUiStore((s) => s.closeSettings)
+
+ return (
+
+ {/* Header */}
+
+
Settings
+
+
+
+ {/* Tab bar */}
+
+
+
+
+ {/* Tab content */}
+
+
+ )
+}
+
+function TabContent({ tab }: { tab: string }) {
+ switch (tab) {
+ case 'appearance': return
+ case 'providers': return
+ case 'agents': return
+ case 'mcp': return
+ case 'skills': return
+ case 'tools': return
+ case 'cron': return
+ case 'traces': return
+ case 'about': return
+ default:
+ return (
+
+
Coming soon
+
This tab will be available in a future update.
+
+ )
+ }
+}
diff --git a/ui/desktop/frontend/src/components/settings/agents/AgentCard.tsx b/ui/desktop/frontend/src/components/settings/agents/AgentCard.tsx
new file mode 100644
index 00000000..00b4ffd4
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/agents/AgentCard.tsx
@@ -0,0 +1,134 @@
+import { useTranslation } from 'react-i18next'
+import type { AgentData } from '../../../types/agent'
+
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+interface AgentCardProps {
+ agent: AgentData
+ onEdit: (agent: AgentData) => void
+ onDelete: (agent: AgentData) => void
+ onResummon: (agent: AgentData) => void
+}
+
+export function AgentCard({ agent, onEdit, onDelete, onResummon }: AgentCardProps) {
+ const { t } = useTranslation('agents')
+ const displayName = agent.display_name
+ || (UUID_RE.test(agent.agent_key) ? 'Unnamed Agent' : agent.agent_key)
+ const otherCfg = (agent.other_config ?? {}) as Record
+ const selfEvolve = agent.agent_type === 'predefined' && Boolean(otherCfg.self_evolve)
+ const emoji = typeof otherCfg.emoji === 'string' ? otherCfg.emoji : ''
+ const showSubtitle = agent.display_name && !UUID_RE.test(agent.agent_key)
+ const isSummoning = agent.status === 'summoning'
+ const isFailed = agent.status === 'summon_failed'
+
+ return (
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/agents/AgentDetailPanel.tsx b/ui/desktop/frontend/src/components/settings/agents/AgentDetailPanel.tsx
new file mode 100644
index 00000000..6772cc48
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/agents/AgentDetailPanel.tsx
@@ -0,0 +1,201 @@
+import { useState, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { PersonalitySection } from './PersonalitySection'
+import { ModelBudgetSection } from './ModelBudgetSection'
+import { EvolutionSection } from './EvolutionSection'
+import { AgentSkillsSection } from './AgentSkillsSection'
+import { AgentMcpSection } from './AgentMcpSection'
+import { AgentFilesTab } from './AgentFilesTab'
+import { ConfirmDialog } from '../../common/ConfirmDialog'
+import type { AgentData } from '../../../types/agent'
+
+type DetailTab = 'overview' | 'files'
+
+interface AgentDetailPanelProps {
+ agent: AgentData
+ onSave: (id: string, updates: Partial) => Promise
+ onResummon: (id: string) => Promise
+ onClose: () => void
+}
+
+export function AgentDetailPanel({ agent, onSave, onResummon, onClose }: AgentDetailPanelProps) {
+ const { t } = useTranslation(['agents', 'common'])
+ const [tab, setTab] = useState('overview')
+
+ // --- Overview local state ---
+ const [emoji, setEmoji] = useState((agent.other_config?.emoji as string) ?? '🤖')
+ const [displayName, setDisplayName] = useState(agent.display_name ?? '')
+ const [description, setDescription] = useState((agent.other_config?.description as string) ?? '')
+ const [status, setStatus] = useState(agent.status ?? 'active')
+ const [isDefault, setIsDefault] = useState(agent.is_default ?? false)
+ const [provider, setProvider] = useState(agent.provider)
+ const [model, setModel] = useState(agent.model)
+ const [contextWindow, setContextWindow] = useState(agent.context_window ?? 200000)
+ const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations ?? 25)
+ const [selfEvolve, setSelfEvolve] = useState(!!(agent.other_config?.self_evolve))
+ const [saveBlocked, setSaveBlocked] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [saveError, setSaveError] = useState('')
+ const [confirmResummon, setConfirmResummon] = useState(false)
+
+ const handleSave = useCallback(async () => {
+ setSaving(true)
+ setSaveError('')
+ try {
+ const otherConfig: Record = { ...agent.other_config }
+ if (emoji) otherConfig.emoji = emoji
+ if (description.trim()) otherConfig.description = description.trim()
+ else delete otherConfig.description
+ otherConfig.self_evolve = selfEvolve
+
+ await onSave(agent.id, {
+ display_name: displayName.trim() || undefined,
+ provider,
+ model,
+ context_window: contextWindow,
+ max_tool_iterations: maxToolIterations,
+ is_default: isDefault,
+ status,
+ other_config: otherConfig,
+ })
+ onClose()
+ } catch (err) {
+ setSaveError(err instanceof Error ? err.message : 'Failed to save')
+ } finally {
+ setSaving(false)
+ }
+ }, [agent, emoji, displayName, description, selfEvolve, provider, model, contextWindow, maxToolIterations, isDefault, status, onSave, onClose])
+
+ const handleConfirmResummon = async () => {
+ setConfirmResummon(false)
+ await onResummon(agent.id)
+ }
+
+ const isPredefined = agent.agent_type === 'predefined'
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ {emoji || '🤖'}
+
+
+
+ {displayName || agent.agent_key}
+
+
+
+ {agent.agent_key}
+ {agent.agent_type}
+
+
+
setConfirmResummon(true)}
+ className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors flex items-center gap-1.5"
+ >
+
+ {t('agents:files.resummon')}
+
+
+
+ {/* Tab bar */}
+
+ {(['overview', 'files'] as const).map((tabKey) => (
+ setTab(tabKey)}
+ className={[
+ 'px-3 py-1.5 text-xs rounded-t-md transition-colors -mb-px border-b-2',
+ tab === tabKey
+ ? 'border-accent text-accent font-medium'
+ : 'border-transparent text-text-muted hover:text-text-primary',
+ ].join(' ')}
+ >
+ {tabKey === 'overview' ? t('agents:detail.tabs.agent') : t('agents:detail.tabs.files')}
+
+ ))}
+
+
+ {/* Tab content */}
+
+ {tab === 'overview' ? (
+
+
+
+
+ {isPredefined && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+ {/* Sticky save bar — only on overview tab */}
+ {tab === 'overview' && (
+
+
+ {saveError &&
{saveError}
}
+
+
+ {t('common:cancel')}
+
+
+ {saving && }
+ {saving ? t('common:saving') : saveBlocked ? t('agents:create.check') : t('common:saveChanges')}
+
+
+
+
+ )}
+
+ {/* Resummon confirm */}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/agents/AgentFilesTab.tsx b/ui/desktop/frontend/src/components/settings/agents/AgentFilesTab.tsx
new file mode 100644
index 00000000..28e56369
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/agents/AgentFilesTab.tsx
@@ -0,0 +1,253 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { getWsClient } from '../../../lib/ws'
+import { getApiClient } from '../../../lib/api'
+import type { BootstrapFile } from '../../../types/agent'
+
+/** Files hidden from desktop UI — USER.md is per-user (managed by bootstrap), HEARTBEAT.md needs cron config */
+const HIDDEN_FILES = ['MEMORY.json', 'BOOTSTRAP.md', 'USER.md', 'USER_PREDEFINED.md', 'HEARTBEAT.md']
+
+interface AgentFilesTabProps {
+ agentId: string
+ agentKey: string
+ agentType: string
+}
+
+export function AgentFilesTab({ agentId, agentKey, agentType }: AgentFilesTabProps) {
+ const { t } = useTranslation('agents')
+ const [files, setFiles] = useState([])
+ const [selectedFile, setSelectedFile] = useState(null)
+ const [content, setContent] = useState('')
+ const [loading, setLoading] = useState(true)
+ const [fileLoading, setFileLoading] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [dirty, setDirty] = useState(false)
+ const [regenerateOpen, setRegenerateOpen] = useState(false)
+
+ // Load file list
+ useEffect(() => {
+ const ws = getWsClient()
+ setLoading(true)
+ ws.call('agents.files.list', { agentId: agentKey })
+ .then((res: unknown) => {
+ const r = res as { files?: BootstrapFile[] }
+ const fileList = (r?.files ?? []).filter((f) => !HIDDEN_FILES.includes(f.name))
+ setFiles(fileList)
+ // Auto-select first file
+ if (fileList.length > 0 && !selectedFile) {
+ setSelectedFile(fileList[0].name)
+ }
+ })
+ .catch(console.error)
+ .finally(() => setLoading(false))
+ }, [agentKey, agentType, selectedFile])
+
+ // Load file content
+ const loadFile = useCallback(async (name: string) => {
+ setFileLoading(true)
+ try {
+ const ws = getWsClient()
+ const res = (await ws.call('agents.files.get', { agentId: agentKey, name })) as { file?: BootstrapFile }
+ setContent(res?.file?.content ?? '')
+ setDirty(false)
+ } catch {
+ setContent('')
+ } finally {
+ setFileLoading(false)
+ }
+ }, [agentKey])
+
+ useEffect(() => {
+ if (selectedFile) loadFile(selectedFile)
+ }, [selectedFile, loadFile])
+
+ const handleSave = async () => {
+ if (!selectedFile) return
+ setSaving(true)
+ try {
+ const ws = getWsClient()
+ await ws.call('agents.files.set', { agentId: agentKey, name: selectedFile, content })
+ setDirty(false)
+ } catch (err) {
+ console.error('Failed to save file:', err)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleRegenerate = async (prompt: string) => {
+ try {
+ await getApiClient().post(`/v1/agents/${agentId}/regenerate`, { prompt })
+ // Reload current file after regeneration
+ if (selectedFile) {
+ setTimeout(() => loadFile(selectedFile), 2000)
+ }
+ } catch (err) {
+ console.error('Regenerate failed:', err)
+ }
+ }
+
+ if (loading) {
+ return Loading files...
+ }
+
+ return (
+
+ {/* Toolbar */}
+
+
Agent context files — SOUL.md, IDENTITY.md, AGENTS.md
+
setRegenerateOpen(true)}
+ className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors flex items-center gap-1.5"
+ >
+
+ Edit with AI
+
+
+
+ {/* File editor layout */}
+
+ {/* File sidebar */}
+
+ {files.map((file) => (
+
setSelectedFile(file.name)}
+ className={[
+ 'w-full text-left px-3 py-2.5 text-xs border-b border-border last:border-b-0 transition-colors',
+ selectedFile === file.name
+ ? 'bg-accent/10 text-accent font-medium'
+ : 'text-text-secondary hover:bg-surface-tertiary',
+ file.missing ? 'opacity-50' : '',
+ ].join(' ')}
+ >
+
+ {file.missing && (
+ Not created
+ )}
+
+ ))}
+
+
+ {/* Editor */}
+
+ {selectedFile ? (
+ <>
+
+
+ {selectedFile}
+ {dirty && Unsaved}
+
+
+ {saving && }
+ {saving ? t('files.saving') : t('files.save')}
+
+
+ {fileLoading ? (
+
+
+
+ ) : (
+
+
+
+ {/* Edit with AI dialog */}
+ {regenerateOpen && (
+
+ )}
+
+ )
+}
+
+/** Dialog for "Edit with AI" — sends prompt to regenerate endpoint */
+function RegenerateDialog({ open, onOpenChange, onRegenerate }: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onRegenerate: (prompt: string) => Promise
+}) {
+ const { t } = useTranslation('agents')
+ const [prompt, setPrompt] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ const handleSubmit = async () => {
+ if (!prompt.trim()) return
+ setLoading(true)
+ try {
+ await onRegenerate(prompt.trim())
+ onOpenChange(false)
+ setPrompt('')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (!open) return null
+
+ return (
+
+
+
+
+ Edit with AI
+
+
+ Describe how you want to modify the agent's personality, knowledge, or behavior. The AI will regenerate the context files accordingly.
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/agents/AgentFormDialog.tsx b/ui/desktop/frontend/src/components/settings/agents/AgentFormDialog.tsx
new file mode 100644
index 00000000..4c5322d8
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/agents/AgentFormDialog.tsx
@@ -0,0 +1,328 @@
+import { useState, useEffect, useMemo, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Combobox } from '../../common/Combobox'
+import { useProviders } from '../../../hooks/use-providers'
+import { getApiClient } from '../../../lib/api'
+import { Switch } from '../../common/Switch'
+import { slugify } from '../../../constants/providers'
+import type { AgentData, AgentInput } from '../../../types/agent'
+
+// Preset keys match agents.json presets (foxSpirit, artisan, astrologer)
+// plus desktop-only extras (researcher, writer, coder) in desktop.json
+const PRESET_KEYS = [
+ { key: 'foxSpirit', emoji: '🦊', ns: 'agents', agentKey: 'little-fox' },
+ { key: 'artisan', emoji: '🎨', ns: 'agents', agentKey: 'artisan' },
+ { key: 'astrologer', emoji: '🔮', ns: 'agents', agentKey: 'mimi' },
+ { key: 'researcher', emoji: '🔬', ns: 'desktop', agentKey: 'scholar' },
+ { key: 'writer', emoji: '✍️', ns: 'desktop', agentKey: 'quill' },
+ { key: 'coder', emoji: '👨💻', ns: 'desktop', agentKey: 'dev' },
+]
+
+interface AgentFormDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ agent?: AgentData | null
+ onSubmit: (input: AgentInput) => Promise
+}
+
+export function AgentFormDialog({ open, onOpenChange, agent, onSubmit }: AgentFormDialogProps) {
+ const isEditing = !!agent
+ const { t } = useTranslation(['agents', 'desktop', 'common'])
+ const { providers } = useProviders()
+
+ const [displayName, setDisplayName] = useState('')
+ const [providerName, setProviderName] = useState('')
+ const [model, setModel] = useState('')
+ const agentType = 'predefined' as const
+ const [description, setDescription] = useState('')
+ const [emoji, setEmoji] = useState('🦊')
+ const [isDefault, setIsDefault] = useState(false)
+ const [models, setModels] = useState([])
+ const [modelsLoading, setModelsLoading] = useState(false)
+ const [verifyResult, setVerifyResult] = useState<{ valid: boolean; error?: string } | null>(null)
+ const [verifying, setVerifying] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [selectedPresetKey, setSelectedPresetKey] = useState('')
+
+ // Reset form when dialog opens
+ useEffect(() => {
+ if (!open) return
+ setDisplayName(agent?.display_name ?? '')
+ setProviderName(agent?.provider ?? '')
+ setModel(agent?.model ?? '')
+ setDescription((agent?.other_config?.description as string) ?? '')
+ setEmoji((agent?.other_config?.emoji as string) ?? '🦊')
+ setIsDefault(agent?.is_default ?? false)
+ setError('')
+ setSelectedPresetKey('')
+ setVerifyResult(isEditing ? { valid: true } : null) // editing = already verified
+ setModels([])
+ }, [open, agent, isEditing])
+
+ // Selected provider object
+ const selectedProvider = useMemo(
+ () => providers.find((p) => p.name === providerName),
+ [providers, providerName],
+ )
+
+ // Load models when provider changes
+ const loadModels = useCallback(async (providerId: string) => {
+ setModelsLoading(true)
+ try {
+ const res = await getApiClient().get<{ models: Array<{ id: string }> }>(
+ `/v1/providers/${providerId}/models`,
+ )
+ setModels((res.models ?? []).map((m) => m.id))
+ } catch {
+ setModels([])
+ } finally {
+ setModelsLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ if (selectedProvider?.id) loadModels(selectedProvider.id)
+ }, [selectedProvider?.id, loadModels])
+
+ // Reset verify when provider or model changes
+ useEffect(() => {
+ if (!isEditing) setVerifyResult(null)
+ }, [providerName, model, isEditing])
+
+ const agentKey = useMemo(() => {
+ if (isEditing) return agent!.agent_key
+ // Use preset agentKey if selected, otherwise slugify display name
+ return selectedPresetKey || slugify(displayName) || 'agent'
+ }, [isEditing, agent, selectedPresetKey, displayName])
+
+ const providerOptions = useMemo(
+ () => providers.filter((p) => p.enabled).map((p) => ({
+ value: p.name,
+ label: p.display_name || p.name,
+ })),
+ [providers],
+ )
+
+ const modelOptions = useMemo(
+ () => models.map((m) => ({ value: m, label: m })),
+ [models],
+ )
+
+ const handleVerify = async () => {
+ if (!selectedProvider?.id || !model.trim()) return
+ setVerifying(true)
+ try {
+ const res = await getApiClient().post<{ valid: boolean; error?: string }>(
+ `/v1/providers/${selectedProvider.id}/verify`,
+ { model: model.trim() },
+ )
+ setVerifyResult({ valid: res.valid, error: res.error })
+ } catch (err) {
+ setVerifyResult({ valid: false, error: err instanceof Error ? err.message : 'Verification failed' })
+ } finally {
+ setVerifying(false)
+ }
+ }
+
+ const handleCreate = async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const otherConfig: Record = {}
+ if (description.trim()) otherConfig.description = description.trim()
+ if (emoji.trim()) otherConfig.emoji = emoji.trim()
+ await onSubmit({
+ agent_key: agentKey,
+ display_name: displayName.trim() || undefined,
+ provider: providerName,
+ model: model.trim(),
+ agent_type: isEditing ? agent!.agent_type : agentType,
+ is_default: isDefault || undefined,
+ other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined,
+ })
+ onOpenChange(false)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save agent')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (!open) return null
+
+ const canCreate = isEditing
+ ? !!providerName && !!model.trim()
+ : !!displayName.trim() && !!providerName && !!model.trim() && verifyResult?.valid && !!description.trim()
+
+ return (
+
+
+ {/* Header */}
+
+
+ {isEditing ? t('agents:detail.tabs.agent') + ' — ' + t('common:edit') : t('agents:create.title')}
+
+
onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
+
+
+
+
+ {/* Body — 2 columns */}
+
+
+ {/* Left column — identity & model */}
+
+ {/* Display name + emoji */}
+
+
+ {/* Agent key */}
+ {!isEditing && (
+
+
+
+ {agentKey}
+
+
+ )}
+
+ {/* Provider */}
+
+
+
+
+
+ {/* Model */}
+
+
+
+ {verifyResult && !verifyResult.valid && (
+
{verifyResult.error || t('desktop:agent.verifyFailed')}
+ )}
+ {verifyResult?.valid && !isEditing && (
+
{t('desktop:agent.verified')}
+ )}
+
+
+ {/* Default toggle */}
+
+
+ {t('agents:identity.defaultAgent')}
+
+
+
+ {/* Right column — personality */}
+
+
+ {!isEditing && (
+
+ {PRESET_KEYS.map((p) => {
+ const label = t(`${p.ns}:presets.${p.key}.label`)
+ const prompt = t(`${p.ns}:presets.${p.key}.prompt`)
+ // Extract display name from prompt (first line: "Name: Xxx.")
+ const nameMatch = prompt.match(/^Name:\s*([^.]+)/)
+ const displayName = nameMatch ? nameMatch[1].trim() : label.replace(/^\S+\s*/, '')
+ return (
+ { setDescription(prompt); setEmoji(p.emoji); setDisplayName(displayName); setSelectedPresetKey(p.agentKey) }}
+ className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
+ description === prompt
+ ? 'border-accent bg-accent/10 text-accent font-medium'
+ : 'border-border text-text-secondary hover:bg-surface-tertiary hover:text-text-primary'
+ }`}
+ >
+ {label}
+
+ )
+ })}
+
+ )}
+
+
+
+
+ {error &&
}
+
+ {/* Footer */}
+
+ {/* Left — error */}
+
+ {verifyResult && !verifyResult.valid && (
+ {verifyResult.error}
+ )}
+
+
+ {/* Right — cancel + verify + summon/save */}
+
+
onOpenChange(false)} className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors">
+ {t('agents:create.cancel')}
+
+ {!isEditing && selectedProvider?.id && model.trim() && !verifyResult?.valid && (
+
+ {verifying ? (
+ <>
+
+ {t('desktop:agent.verifying')}
+ >
+ ) : t('desktop:agent.verifyModel')}
+
+ )}
+
+ {loading ? '...' : isEditing ? t('common:save') : (
+ <>
+ {verifyResult?.valid && (
+
+ )}
+ {t('desktop:agent.summon')}
+ >
+ )}
+
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/agents/AgentList.tsx b/ui/desktop/frontend/src/components/settings/agents/AgentList.tsx
new file mode 100644
index 00000000..7c8ebff9
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/agents/AgentList.tsx
@@ -0,0 +1,146 @@
+import { useState, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useAgentCrud } from '../../../hooks/use-agent-crud'
+import { useAgentStore } from '../../../stores/agent-store'
+import { AgentCard } from './AgentCard'
+import { AgentFormDialog } from './AgentFormDialog'
+import { AgentDetailPanel } from './AgentDetailPanel'
+import { ConfirmDeleteDialog } from '../../common/ConfirmDeleteDialog'
+import { SummoningModal } from '../../onboarding/SummoningModal'
+import type { AgentData, AgentInput } from '../../../types/agent'
+
+export function AgentList() {
+ const { t } = useTranslation(['agents', 'common'])
+ const { agents, loading, atLimit, createAgent, updateAgent, deleteAgent, resummonAgent, fetchAgents } = useAgentCrud()
+ const setStoreAgents = useAgentStore((s) => s.setAgents)
+
+ const [formOpen, setFormOpen] = useState(false)
+ const [detailAgent, setDetailAgent] = useState(null)
+ const [deletingAgent, setDeletingAgent] = useState(null)
+ const [summoningAgent, setSummoningAgent] = useState<{ id: string; name: string } | null>(null)
+
+ const refreshSidebar = useCallback(() => {
+ setStoreAgents(agents.map((a) => {
+ const otherCfg = typeof a.other_config === 'object' && a.other_config !== null ? a.other_config as Record : {}
+ return {
+ id: a.id,
+ key: a.agent_key,
+ name: a.display_name || a.agent_key,
+ model: a.model ?? 'unknown',
+ status: 'online' as const,
+ emoji: typeof otherCfg.emoji === 'string' ? otherCfg.emoji : undefined,
+ }
+ }))
+ }, [agents, setStoreAgents])
+
+ const handleCreate = async (input: AgentInput) => {
+ const created = await createAgent(input)
+ if (created.status === 'summoning') {
+ setSummoningAgent({ id: created.id, name: created.display_name || created.agent_key })
+ }
+ refreshSidebar()
+ return created
+ }
+
+ const handleSave = async (id: string, updates: Partial) => {
+ await updateAgent(id, updates)
+ await fetchAgents()
+ refreshSidebar()
+ }
+
+ const handleDelete = async () => {
+ if (!deletingAgent) return
+ await deleteAgent(deletingAgent.id)
+ setDeletingAgent(null)
+ setDetailAgent(null)
+ refreshSidebar()
+ }
+
+ const handleResummon = async (agent: AgentData) => {
+ await resummonAgent(agent.id)
+ setSummoningAgent({ id: agent.id, name: agent.display_name || agent.agent_key })
+ }
+
+ const handleSummoningComplete = () => {
+ setSummoningAgent(null)
+ fetchAgents().then(refreshSidebar)
+ }
+
+ if (loading) {
+ return {t('common:loading', 'Loading...')}
+ }
+
+ return (
+ <>
+
+
+
{t('agents:title')}
+ setFormOpen(true)}
+ disabled={atLimit}
+ className="px-3 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50"
+ >
+ + {t('agents:createAgent')}
+
+
+
+ {atLimit && (
+
Max 5 agents in Lite edition.
+ )}
+
+ {agents.length === 0 ? (
+
{t('agents:emptyTitle')}
+ ) : (
+
+ {agents.map((a) => (
+
+ ))}
+
+ )}
+
+
+ {/* Create dialog */}
+
+
+ {/* Fullscreen detail panel */}
+ {detailAgent && (
+ { await resummonAgent(id); setSummoningAgent({ id, name: detailAgent.display_name || detailAgent.agent_key }) }}
+ onClose={() => setDetailAgent(null)}
+ />
+ )}
+
+ {/* Delete confirm */}
+ { if (!open) setDeletingAgent(null) }}
+ title="Delete agent?"
+ description="This will permanently delete this agent and all its data."
+ confirmValue={deletingAgent?.display_name || deletingAgent?.agent_key || ''}
+ confirmLabel="Delete"
+ onConfirm={handleDelete}
+ />
+
+ {/* Summoning */}
+ {summoningAgent && (
+
+ )}
+ >
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/agents/AgentMcpSection.tsx b/ui/desktop/frontend/src/components/settings/agents/AgentMcpSection.tsx
new file mode 100644
index 00000000..9df0e48d
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/agents/AgentMcpSection.tsx
@@ -0,0 +1,122 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { getApiClient, isApiClientReady } from '../../../lib/api'
+import { Switch } from '../../common/Switch'
+import type { MCPServerData, MCPAgentGrant } from '../../../types/mcp'
+
+interface AgentMcpSectionProps {
+ agentId: string
+}
+
+export function AgentMcpSection({ agentId }: AgentMcpSectionProps) {
+ const { t } = useTranslation('mcp')
+ const [servers, setServers] = useState([])
+ const [grants, setGrants] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ const fetchData = useCallback(async () => {
+ if (!isApiClientReady()) return
+ try {
+ const [srvRes, grantRes] = await Promise.all([
+ getApiClient().get<{ servers: MCPServerData[] | null }>('/v1/mcp/servers'),
+ getApiClient().get<{ grants: MCPAgentGrant[] | null }>(`/v1/mcp/grants/agent/${agentId}`),
+ ])
+ setServers((srvRes.servers ?? []).filter((s) => s.enabled))
+ setGrants(grantRes.grants ?? [])
+ } catch (err) {
+ console.error('Failed to fetch MCP data:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [agentId])
+
+ useEffect(() => { fetchData() }, [fetchData])
+
+ const grantedIds = new Set(grants.map((g) => g.server_id))
+
+ async function handleToggle(server: MCPServerData) {
+ const isGranted = grantedIds.has(server.id)
+ setError('')
+
+ // Optimistic update
+ if (isGranted) {
+ setGrants((prev) => prev.filter((g) => g.server_id !== server.id))
+ } else {
+ setGrants((prev) => [...prev, { id: '', server_id: server.id, agent_id: agentId, enabled: true, tool_allow: null, tool_deny: null, granted_by: '', created_at: '' }])
+ }
+
+ try {
+ if (isGranted) {
+ await getApiClient().delete(`/v1/mcp/servers/${server.id}/grants/agent/${agentId}`)
+ } else {
+ await getApiClient().post(`/v1/mcp/servers/${server.id}/grants/agent`, { agent_id: agentId })
+ }
+ } catch (err) {
+ setError((err as Error).message || 'Failed to update MCP grant')
+ // Revert
+ if (isGranted) {
+ setGrants((prev) => [...prev, { id: '', server_id: server.id, agent_id: agentId, enabled: true, tool_allow: null, tool_deny: null, granted_by: '', created_at: '' }])
+ } else {
+ setGrants((prev) => prev.filter((g) => g.server_id !== server.id))
+ }
+ }
+ }
+
+ const grantedCount = servers.filter((s) => grantedIds.has(s.id)).length
+
+ return (
+
+
+
{t('title')}
+ {!loading && servers.length > 0 && (
+
+ {t('grants.currentGrants')}: {grantedCount}/{servers.length}
+
+ )}
+
+
+ {error &&
{error}
}
+
+ {loading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : servers.length === 0 ? (
+
{t('emptyTitle')}
+ ) : (
+
+ {servers.map((server) => (
+
+
+
+
+
+ {server.display_name || server.name}
+
+
+ {server.transport.toUpperCase()}
+
+
+
mcp_{server.name}
+
+
handleToggle(server)}
+ />
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/agents/AgentSkillsSection.tsx b/ui/desktop/frontend/src/components/settings/agents/AgentSkillsSection.tsx
new file mode 100644
index 00000000..693b4dd2
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/agents/AgentSkillsSection.tsx
@@ -0,0 +1,115 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { getApiClient, isApiClientReady } from '../../../lib/api'
+import { Switch } from '../../common/Switch'
+
+interface SkillWithGrant {
+ id: string
+ name: string
+ slug: string
+ description: string
+ visibility: string
+ version: number
+ granted: boolean
+ pinned_version?: number
+ is_system: boolean
+}
+
+interface AgentSkillsSectionProps {
+ agentId: string
+}
+
+export function AgentSkillsSection({ agentId }: AgentSkillsSectionProps) {
+ const { t } = useTranslation('agents')
+ const [skills, setSkills] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ const fetchSkills = useCallback(async () => {
+ if (!isApiClientReady()) return
+ try {
+ const res = await getApiClient().get<{ skills: SkillWithGrant[] | null }>(`/v1/agents/${agentId}/skills`)
+ setSkills(res.skills ?? [])
+ } catch (err) {
+ console.error('Failed to fetch agent skills:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [agentId])
+
+ useEffect(() => { fetchSkills() }, [fetchSkills])
+
+ async function handleToggle(skill: SkillWithGrant) {
+ const newGranted = !skill.granted
+ // Optimistic update
+ setSkills((prev) => prev.map((s) => s.id === skill.id ? { ...s, granted: newGranted } : s))
+ try {
+ if (newGranted) {
+ await getApiClient().post(`/v1/skills/${skill.id}/grants/agent`, { agent_id: agentId })
+ } else {
+ await getApiClient().delete(`/v1/skills/${skill.id}/grants/agent/${agentId}`)
+ }
+ } catch (err) {
+ setError((err as Error).message || 'Failed to update grant')
+ setSkills((prev) => prev.map((s) => s.id === skill.id ? { ...s, granted: !newGranted } : s))
+ }
+ }
+
+ const granted = skills.filter((s) => s.granted || s.is_system).length
+
+ return (
+
+
+
{t('detail.skills')}
+ {!loading && skills.length > 0 && (
+
+ {t('skills.skillsGranted', { granted, total: skills.length })}
+
+ )}
+
+
+ {error &&
{error}
}
+
+ {loading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : skills.length === 0 ? (
+
{t('skills.noSkillsAvailable')}
+ ) : (
+
+ {skills.map((skill) => (
+
+
+
+
+
{skill.name}
+ {skill.is_system && (
+
+ System
+
+ )}
+
+ {skill.description && (
+
{skill.description}
+ )}
+
+ {skill.is_system ? (
+
{t('skills.alwaysAvailable')}
+ ) : (
+
handleToggle(skill)}
+ />
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/agents/EvolutionSection.tsx b/ui/desktop/frontend/src/components/settings/agents/EvolutionSection.tsx
new file mode 100644
index 00000000..8cf739ae
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/agents/EvolutionSection.tsx
@@ -0,0 +1,38 @@
+import { useTranslation } from 'react-i18next'
+import { Switch } from '../../common/Switch'
+
+interface EvolutionSectionProps {
+ selfEvolve: boolean
+ onSelfEvolveChange: (v: boolean) => void
+}
+
+export function EvolutionSection({ selfEvolve, onSelfEvolveChange }: EvolutionSectionProps) {
+ const { t } = useTranslation('agents')
+ return (
+
+
{t('detail.evolution')}
+
+ {/* Self-evolve toggle */}
+
+
+
+ ✨
+ {t('general.selfEvolution')}
+
+
+ {t('general.selfEvolutionLabel')}
+
+
+
+
+
+ {selfEvolve && (
+
+
+ {t('general.selfEvolutionInfo')}
+
+
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/agents/MemorySection.tsx b/ui/desktop/frontend/src/components/settings/agents/MemorySection.tsx
new file mode 100644
index 00000000..fb222458
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/agents/MemorySection.tsx
@@ -0,0 +1,130 @@
+import type { MemoryConfig } from '../../../types/agent'
+
+interface MemorySectionProps {
+ config: MemoryConfig | null | undefined
+ onChange: (config: MemoryConfig | null) => void
+}
+
+const defaults: Required = {
+ enabled: false,
+ embedding_provider: '',
+ embedding_model: '',
+ max_results: 5,
+ max_chunk_len: 512,
+ chunk_overlap: 50,
+ vector_weight: 0.7,
+ text_weight: 0.3,
+ min_score: 0.3,
+}
+
+export function MemorySection({ config, onChange }: MemorySectionProps) {
+ const enabled = config?.enabled ?? false
+
+ const update = (field: keyof MemoryConfig, value: unknown) => {
+ onChange({ ...defaults, ...config, [field]: value })
+ }
+
+ return (
+
+
+
+
Memory
+
Vector-based long-term memory for this agent
+
+
+
+
+ {enabled && (
+
+ {/* Search results */}
+
+ update('max_results', v)}
+ />
+ update('min_score', v)}
+ step={0.05}
+ />
+
+
+ {/* Chunking */}
+
+ update('max_chunk_len', v)}
+ />
+ update('chunk_overlap', v)}
+ />
+
+
+ {/* Weights */}
+
+ update('vector_weight', v)}
+ step={0.1}
+ />
+ update('text_weight', v)}
+ step={0.1}
+ />
+
+
+ )}
+
+ {!enabled && (
+
Using global defaults
+ )}
+
+ )
+}
+
+function NumberField({ label, hint, value, onChange, step }: {
+ label: string; hint: string; value: number
+ onChange: (v: number) => void; step?: number
+}) {
+ return (
+
+
+
onChange(Number(e.target.value) || 0)}
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
{hint}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/agents/ModelBudgetSection.tsx b/ui/desktop/frontend/src/components/settings/agents/ModelBudgetSection.tsx
new file mode 100644
index 00000000..8e3aaa0e
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/agents/ModelBudgetSection.tsx
@@ -0,0 +1,169 @@
+import { useState, useEffect, useMemo, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Combobox } from '../../common/Combobox'
+import { useProviders } from '../../../hooks/use-providers'
+import { getApiClient } from '../../../lib/api'
+
+interface ModelBudgetSectionProps {
+ provider: string
+ model: string
+ contextWindow: number
+ maxToolIterations: number
+ savedProvider: string
+ savedModel: string
+ onProviderChange: (v: string) => void
+ onModelChange: (v: string) => void
+ onContextWindowChange: (v: number) => void
+ onMaxToolIterationsChange: (v: number) => void
+ onSaveBlockedChange: (blocked: boolean) => void
+}
+
+export function ModelBudgetSection({
+ provider, model, contextWindow, maxToolIterations,
+ savedProvider, savedModel,
+ onProviderChange, onModelChange, onContextWindowChange, onMaxToolIterationsChange,
+ onSaveBlockedChange,
+}: ModelBudgetSectionProps) {
+ const { t } = useTranslation(['agents', 'common'])
+ const { providers } = useProviders()
+ const [models, setModels] = useState([])
+ const [modelsLoading, setModelsLoading] = useState(false)
+ const [verifyResult, setVerifyResult] = useState<{ valid: boolean; error?: string } | null>(null)
+ const [verifying, setVerifying] = useState(false)
+
+ const selectedProvider = useMemo(
+ () => providers.find((p) => p.name === provider),
+ [providers, provider],
+ )
+
+ // Load models when provider changes
+ const loadModels = useCallback(async (providerId: string) => {
+ setModelsLoading(true)
+ try {
+ const res = await getApiClient().get<{ models: Array<{ id: string }> }>(
+ `/v1/providers/${providerId}/models`,
+ )
+ setModels((res.models ?? []).map((m) => m.id))
+ } catch {
+ setModels([])
+ } finally {
+ setModelsLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ if (selectedProvider?.id) loadModels(selectedProvider.id)
+ }, [selectedProvider?.id, loadModels])
+
+ // Show verify button when provider/model changed from saved
+ const needsVerify = (provider !== savedProvider || model !== savedModel) && provider && model
+ useEffect(() => {
+ if (needsVerify) {
+ setVerifyResult(null)
+ onSaveBlockedChange(true)
+ } else {
+ onSaveBlockedChange(false)
+ }
+ }, [needsVerify, onSaveBlockedChange])
+
+ const handleVerify = async () => {
+ if (!selectedProvider?.id || !model.trim()) return
+ setVerifying(true)
+ try {
+ const res = await getApiClient().post<{ success: boolean; error?: string }>(
+ `/v1/providers/${selectedProvider.id}/verify`,
+ { model: model.trim() },
+ )
+ setVerifyResult({ valid: res.success, error: res.error })
+ onSaveBlockedChange(!res.success)
+ } catch (err) {
+ setVerifyResult({ valid: false, error: err instanceof Error ? err.message : 'Verification failed' })
+ } finally {
+ setVerifying(false)
+ }
+ }
+
+ const providerOptions = useMemo(
+ () => providers.filter((p) => p.enabled).map((p) => ({
+ value: p.name,
+ label: p.display_name || p.name,
+ })),
+ [providers],
+ )
+
+ const modelOptions = useMemo(
+ () => models.map((m) => ({ value: m, label: m })),
+ [models],
+ )
+
+ return (
+
+
{t('agents:detail.modelBudget')}
+
+ {/* Provider + Model */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Verify button + result */}
+ {needsVerify && (
+
+
+ {verifying ? t('agents:create.checking') : t('agents:create.check')}
+
+ {verifyResult && (
+
+ {verifyResult.valid ? t('common:modelVerified') : verifyResult.error}
+
+ )}
+ {!verifyResult && (
+ {t('common:checkAndCreate')}
+ )}
+
+ )}
+
+ {/* Context window + Max iterations */}
+
+
+
+
onContextWindowChange(Number(e.target.value) || 0)}
+ placeholder="200000"
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
{t('agents:llmConfig.contextWindowHint')}
+
+
+
+
onMaxToolIterationsChange(Number(e.target.value) || 0)}
+ placeholder="25"
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
{t('agents:llmConfig.maxToolIterationsHint')}
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/agents/PersonalitySection.tsx b/ui/desktop/frontend/src/components/settings/agents/PersonalitySection.tsx
new file mode 100644
index 00000000..bc5ef174
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/agents/PersonalitySection.tsx
@@ -0,0 +1,91 @@
+import { useTranslation } from 'react-i18next'
+import { Switch } from '../../common/Switch'
+import { Combobox } from '../../common/Combobox'
+
+interface PersonalitySectionProps {
+ emoji: string
+ displayName: string
+ description: string
+ agentKey: string
+ agentType: string
+ isDefault: boolean
+ status: string
+ onEmojiChange: (v: string) => void
+ onDisplayNameChange: (v: string) => void
+ onDescriptionChange: (v: string) => void
+ onIsDefaultChange: (v: boolean) => void
+ onStatusChange: (v: string) => void
+}
+
+export function PersonalitySection({
+ emoji, displayName, description, agentKey, agentType, isDefault, status,
+ onEmojiChange, onDisplayNameChange, onDescriptionChange, onIsDefaultChange, onStatusChange,
+}: PersonalitySectionProps) {
+ const { t } = useTranslation('agents')
+ const STATUS_OPTIONS = [
+ { value: 'active', label: t('identity.active') },
+ { value: 'idle', label: t('identity.inactive') },
+ ]
+ return (
+
+
{t('detail.personality')}
+
+ {/* Emoji + Display name */}
+
+
+
onEmojiChange(e.target.value.slice(0, 2))}
+ maxLength={2}
+ className="w-12 h-12 rounded-xl bg-accent/10 border border-border text-2xl text-center focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
{t('identity.emoji')}
+
+
+
+
+ onDisplayNameChange(e.target.value)}
+ placeholder="Agent name"
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary font-medium placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+ {/* Agent key (read-only) */}
+
+ {agentKey}
+ {agentType}
+
+
+
+
+ {/* Description / Expertise */}
+
+
+
+
+ {/* Status + Default */}
+
+
+
+
+
+
+
+
+ {t('identity.defaultAgent')}
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/cron/CronFormDialog.tsx b/ui/desktop/frontend/src/components/settings/cron/CronFormDialog.tsx
new file mode 100644
index 00000000..abb904f6
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/cron/CronFormDialog.tsx
@@ -0,0 +1,269 @@
+import { useState, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Combobox } from '../../common/Combobox'
+import { Switch } from '../../common/Switch'
+import { useAgentCrud } from '../../../hooks/use-agent-crud'
+import type { CronSchedule, CronPayload } from '../../../types/cron'
+
+type ScheduleKind = 'every' | 'cron' | 'at'
+type EveryUnit = 'seconds' | 'minutes' | 'hours'
+
+interface CronFormDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSubmit: (params: {
+ name: string
+ agentId?: string
+ schedule: CronSchedule
+ payload: CronPayload
+ deleteAfterRun?: boolean
+ }) => Promise
+}
+
+function slugify(v: string): string {
+ return v.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-/, '')
+}
+
+function toEveryMs(value: number, unit: EveryUnit): number {
+ if (unit === 'minutes') return value * 60 * 1000
+ if (unit === 'hours') return value * 3600 * 1000
+ return value * 1000
+}
+
+export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogProps) {
+ const { t } = useTranslation('cron')
+ const { agents } = useAgentCrud()
+
+ const [name, setName] = useState('')
+ const [agentId, setAgentId] = useState('')
+ const [scheduleKind, setScheduleKind] = useState('every')
+ const [everyValue, setEveryValue] = useState(5)
+ const [everyUnit, setEveryUnit] = useState('minutes')
+ const [cronExpr, setCronExpr] = useState('')
+ const [message, setMessage] = useState('')
+ const [deleteAfterRun, setDeleteAfterRun] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ if (!open) return
+ setName('')
+ setAgentId('')
+ setScheduleKind('every')
+ setEveryValue(5)
+ setEveryUnit('minutes')
+ setCronExpr('')
+ setMessage('')
+ setDeleteAfterRun(false)
+ setSaving(false)
+ setError('')
+ }, [open])
+
+ if (!open) return null
+
+ const agentOptions = agents.map((a) => ({
+ value: a.id,
+ label: a.display_name || a.agent_key,
+ }))
+
+ const canSubmit = name.trim() && message.trim() && !saving &&
+ (scheduleKind === 'at' || scheduleKind === 'every' ? everyValue > 0 : cronExpr.trim())
+
+ function buildSchedule(): CronSchedule {
+ if (scheduleKind === 'every') {
+ return { kind: 'every', everyMs: toEveryMs(everyValue, everyUnit) }
+ }
+ if (scheduleKind === 'cron') {
+ return { kind: 'cron', expr: cronExpr.trim(), tz: Intl.DateTimeFormat().resolvedOptions().timeZone }
+ }
+ return { kind: 'at', atMs: Date.now() + 60_000 }
+ }
+
+ async function handleSubmit() {
+ setSaving(true)
+ setError('')
+ try {
+ await onSubmit({
+ name,
+ agentId: agentId || undefined,
+ schedule: buildSchedule(),
+ payload: { kind: 'message', message },
+ deleteAfterRun: scheduleKind === 'at' ? deleteAfterRun : undefined,
+ })
+ onOpenChange(false)
+ } catch (err) {
+ setError((err as Error).message || 'Failed to create')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const SCHEDULE_KINDS: ScheduleKind[] = ['every', 'cron', 'at']
+ const UNITS: { value: EveryUnit; label: string }[] = [
+ { value: 'seconds', label: 'seconds' },
+ { value: 'minutes', label: 'minutes' },
+ { value: 'hours', label: 'hours' },
+ ]
+
+ return (
+
+
onOpenChange(false)} />
+
+ {/* Header */}
+
+
{t('create.title')}
+
onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
+
+
+
+
+ {/* Form */}
+
+ {/* Name */}
+
+
+
setName(slugify(e.target.value))}
+ placeholder={t('create.namePlaceholder')}
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent font-mono"
+ />
+
{t('create.nameHint')}
+
+
+ {/* Agent */}
+
+
+
+
+
+ {/* Schedule type */}
+
+
+
+ {SCHEDULE_KINDS.map((kind) => (
+ setScheduleKind(kind)}
+ className={`border rounded-lg px-3 py-2 text-xs text-center transition-colors ${
+ scheduleKind === kind
+ ? 'border-accent bg-accent/10 text-accent font-medium'
+ : 'border-border text-text-secondary hover:bg-surface-tertiary/30'
+ }`}
+ >
+ {t(`create.${kind === 'at' ? 'once' : kind}`)}
+
+ ))}
+
+
+ {/* Every fields */}
+ {scheduleKind === 'every' && (
+
+
setEveryValue(Math.max(1, Number(e.target.value)))}
+ className="w-20 bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+ {UNITS.map((u) => (
+ setEveryUnit(u.value)}
+ className={`border rounded-lg px-2.5 py-1.5 text-xs transition-colors ${
+ everyUnit === u.value
+ ? 'border-accent bg-accent/10 text-accent font-medium'
+ : 'border-border text-text-secondary hover:bg-surface-tertiary/30'
+ }`}
+ >
+ {u.label}
+
+ ))}
+
+
+ )}
+
+ {/* Cron expression */}
+ {scheduleKind === 'cron' && (
+
+
setCronExpr(e.target.value)}
+ placeholder="0 * * * *"
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+ {t('create.cronHint')} — TZ: {Intl.DateTimeFormat().resolvedOptions().timeZone}
+
+
+ )}
+
+ {/* Once */}
+ {scheduleKind === 'at' && (
+
{t('create.onceDesc')}
+ )}
+
+
+ {/* Message */}
+
+
+
+
+ {/* Delete after run — only for 'at' */}
+ {scheduleKind === 'at' && (
+
+
+
{t('detail.deleteAfterRun')}
+
{t('detail.deleteAfterRunDesc')}
+
+
+
+ )}
+
+
+ {error && (
+
+ )}
+
+ {/* Footer */}
+
+ onOpenChange(false)}
+ className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors"
+ >
+ {t('create.cancel')}
+
+
+ {saving ? t('create.creating') : t('create.create')}
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/cron/CronList.tsx b/ui/desktop/frontend/src/components/settings/cron/CronList.tsx
new file mode 100644
index 00000000..cf349225
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/cron/CronList.tsx
@@ -0,0 +1,266 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useCron } from '../../../hooks/use-cron'
+import { useAgentCrud } from '../../../hooks/use-agent-crud'
+import { RefreshButton } from '../../common/RefreshButton'
+import { Switch } from '../../common/Switch'
+import { ConfirmDialog } from '../../common/ConfirmDialog'
+import { CronFormDialog } from './CronFormDialog'
+import { CronRunsDialog } from './CronRunsDialog'
+import type { CronJob, CronSchedule } from '../../../types/cron'
+
+function formatSchedule(s: CronSchedule): string {
+ if (s.kind === 'every' && s.everyMs) {
+ const sec = s.everyMs / 1000
+ if (sec < 60) return `every ${sec}s`
+ if (sec < 3600) return `every ${Math.round(sec / 60)}m`
+ return `every ${Math.round(sec / 3600)}h`
+ }
+ if (s.kind === 'cron' && s.expr) return s.expr
+ if (s.kind === 'at' && s.atMs) return `once at ${new Date(s.atMs).toLocaleString()}`
+ return '—'
+}
+
+function statusBadgeClass(status?: string): string {
+ if (!status) return 'bg-surface-tertiary text-text-secondary border border-border'
+ if (status === 'ok' || status === 'success') {
+ return 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
+ }
+ if (status === 'error' || status === 'failed') {
+ return 'bg-red-500/15 text-red-700 border border-red-500/25 dark:text-red-400 dark:bg-red-500/10 dark:border-red-500/20'
+ }
+ if (status === 'running') {
+ return 'bg-blue-500/15 text-blue-700 border border-blue-500/25 dark:text-blue-400 dark:bg-blue-500/10 dark:border-blue-500/20 animate-pulse'
+ }
+ return 'bg-amber-500/15 text-amber-700 border border-amber-500/25 dark:text-amber-400 dark:bg-amber-500/10 dark:border-amber-500/20'
+}
+
+export function CronList() {
+ const { t } = useTranslation('cron')
+ const { jobs, loading, fetchJobs, createJob, deleteJob, toggleJob, runJob, fetchRuns } = useCron()
+ const { agents } = useAgentCrud()
+
+ const [formOpen, setFormOpen] = useState(false)
+ const [deleteTarget, setDeleteTarget] = useState
(null)
+ const [toggleTarget, setToggleTarget] = useState(null)
+ const [runsTarget, setRunsTarget] = useState(null)
+ const [runningIds, setRunningIds] = useState>(new Set())
+
+ function agentName(agentId: string): string {
+ if (!agentId) return t('defaultAgent')
+ return agents.find((a) => a.id === agentId)?.display_name
+ || agents.find((a) => a.id === agentId)?.agent_key
+ || agentId
+ }
+
+ async function handleRunNow(job: CronJob) {
+ setRunningIds((prev) => new Set(prev).add(job.id))
+ try {
+ await runJob(job.id)
+ } finally {
+ setRunningIds((prev) => { const s = new Set(prev); s.delete(job.id); return s })
+ }
+ }
+
+ async function handleToggleConfirm() {
+ if (!toggleTarget) return
+ await toggleJob(toggleTarget.id)
+ setToggleTarget(null)
+ }
+
+ async function handleDeleteConfirm() {
+ if (!deleteTarget) return
+ await deleteJob(deleteTarget.id)
+ setDeleteTarget(null)
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
{t('title')}
+
{t('description')}
+
+
+
setFormOpen(true)}
+ className="bg-accent text-white rounded-lg px-3 py-1.5 text-xs hover:bg-accent-hover transition-colors flex items-center gap-1.5"
+ >
+
+ {t('newJob')}
+
+
+
+
+
+ {/* Loading skeleton */}
+ {loading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : jobs.length === 0 ? (
+ /* Empty state */
+
+
+
{t('emptyTitle')}
+
{t('emptyDescription')}
+
+ ) : (
+ /* Table */
+
+
+
+
+ | {t('columns.name')} |
+ {t('columns.schedule')} |
+ {t('columns.agent')} |
+ {t('columns.enabled')} |
+ {t('columns.actions')} |
+
+
+
+ {jobs.map((job) => (
+
+ {/* Name */}
+
+
+
+
+ {job.name}
+ {job.state?.lastStatus && (
+
+ {job.state.lastStatus}
+
+ )}
+
+
+ |
+ {/* Schedule */}
+
+ {formatSchedule(job.schedule)}
+ |
+ {/* Agent */}
+
+ {agentName(job.agentId)}
+ |
+ {/* Enabled badge */}
+
+
+ {job.enabled ? t('detail.enabled') : t('detail.disabled')}
+
+ |
+ {/* Actions */}
+
+
+ {/* Run history */}
+ setRunsTarget(job)}
+ className="p-1 text-text-muted hover:text-text-primary transition-colors"
+ title={t('runHistory')}
+ >
+
+
+ {/* Run now */}
+ handleRunNow(job)}
+ disabled={runningIds.has(job.id)}
+ className="p-1 text-text-muted hover:text-accent transition-colors disabled:opacity-50"
+ title={t('runNow')}
+ >
+ {runningIds.has(job.id) ? (
+
+ ) : (
+
+ )}
+
+ {/* Toggle */}
+ setToggleTarget(job)}
+ />
+ {/* Delete */}
+ setDeleteTarget(job)}
+ className="p-1 text-text-muted hover:text-error transition-colors"
+ title={t('delete.title')}
+ >
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ {/* Create dialog */}
+
{ await createJob(params) }}
+ />
+
+ {/* Toggle confirm */}
+ {toggleTarget && (
+ setToggleTarget(null)}
+ title={toggleTarget.enabled ? t('disable.title') : t('enable.title')}
+ description={toggleTarget.enabled
+ ? t('disable.description', { name: toggleTarget.name })
+ : t('enable.description', { name: toggleTarget.name })
+ }
+ confirmLabel={toggleTarget.enabled ? t('disable.confirmLabel') : t('enable.confirmLabel')}
+ onConfirm={handleToggleConfirm}
+ />
+ )}
+
+ {/* Delete confirm */}
+ {deleteTarget && (
+ setDeleteTarget(null)}
+ title={t('delete.title')}
+ description={t('delete.description', { name: deleteTarget.name })}
+ confirmLabel={t('delete.confirmLabel')}
+ variant="destructive"
+ onConfirm={handleDeleteConfirm}
+ />
+ )}
+
+ {/* Run history */}
+ {runsTarget && (
+ setRunsTarget(null)}
+ job={runsTarget}
+ onFetchRuns={fetchRuns}
+ />
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/cron/CronRunsDialog.tsx b/ui/desktop/frontend/src/components/settings/cron/CronRunsDialog.tsx
new file mode 100644
index 00000000..342b2dc4
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/cron/CronRunsDialog.tsx
@@ -0,0 +1,135 @@
+import { useState, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { CronJob, CronRunLog } from '../../../types/cron'
+
+interface CronRunsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ job: CronJob
+ onFetchRuns: (jobId: string) => Promise
+}
+
+function statusBadge(status: string) {
+ if (status === 'ok' || status === 'success') {
+ return 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
+ }
+ if (status === 'error' || status === 'failed') {
+ return 'bg-red-500/15 text-red-700 border border-red-500/25 dark:text-red-400 dark:bg-red-500/10 dark:border-red-500/20'
+ }
+ if (status === 'running') {
+ return 'bg-blue-500/15 text-blue-700 border border-blue-500/25 dark:text-blue-400 dark:bg-blue-500/10 dark:border-blue-500/20 animate-pulse'
+ }
+ return 'bg-surface-tertiary text-text-secondary border border-border'
+}
+
+function formatDuration(ms: number): string {
+ if (ms < 1000) return `${ms}ms`
+ return `${(ms / 1000).toFixed(1)}s`
+}
+
+export function CronRunsDialog({ open, onOpenChange, job, onFetchRuns }: CronRunsDialogProps) {
+ const { t } = useTranslation('cron')
+ const [runs, setRuns] = useState([])
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ if (!open) return
+ setLoading(true)
+ onFetchRuns(job.id)
+ .then(setRuns)
+ .catch(() => setRuns([]))
+ .finally(() => setLoading(false))
+ }, [open, job.id, onFetchRuns])
+
+ if (!open) return null
+
+ return (
+
+
onOpenChange(false)} />
+
+ {/* Header */}
+
+
+ {t('runLog.title', { name: job.name })}
+
+
onOpenChange(false)}
+ className="p-1 text-text-muted hover:text-text-primary transition-colors"
+ >
+
+
+
+
+ {/* Content */}
+
+ {loading ? (
+
+
+
{t('runLog.loading')}
+
+ ) : runs.length === 0 ? (
+
+
{t('runLog.noHistory')}
+
+ ) : (
+
+
+
+
+ | Time |
+ Status |
+ Duration |
+ Tokens |
+ Summary |
+
+
+
+ {runs.map((run, idx) => (
+
+ |
+ {new Date(run.ts).toLocaleString()}
+ |
+
+
+ {run.status}
+
+ {run.error && (
+
+ {run.error}
+
+ )}
+ |
+
+ {formatDuration(run.durationMs)}
+ |
+
+ {t('detail.inOut', { input: run.inputTokens, output: run.outputTokens })}
+ |
+
+ {run.summary || '—'}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Footer */}
+
+ onOpenChange(false)}
+ className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors"
+ >
+ Close
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/mcp/McpFormDialog.tsx b/ui/desktop/frontend/src/components/settings/mcp/McpFormDialog.tsx
new file mode 100644
index 00000000..b8aa476d
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/mcp/McpFormDialog.tsx
@@ -0,0 +1,351 @@
+import { useState, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Switch } from '../../common/Switch'
+import { KeyValueEditor } from '../../common/KeyValueEditor'
+import type { MCPServerData, MCPServerInput, MCPTestResult } from '../../../types/mcp'
+
+const SENSITIVE_HEADER_KEYS = /(auth|api-key|api_key|bearer|token|secret|password|credential)/i
+const SENSITIVE_ENV_KEYS = /(key|secret|token|password|credential)/i
+
+const TRANSPORTS = ['stdio', 'sse', 'streamable-http'] as const
+
+interface McpFormDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ server?: MCPServerData | null
+ onSubmit: (data: MCPServerInput) => Promise
+ onTest: (data: { transport: string; command?: string; args?: string[]; url?: string; headers?: Record; env?: Record }) => Promise
+}
+
+function slugify(v: string): string {
+ return v.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-/, '')
+}
+
+export function McpFormDialog({ open, onOpenChange, server, onSubmit, onTest }: McpFormDialogProps) {
+ const { t } = useTranslation(['mcp', 'common'])
+ const isEdit = Boolean(server)
+
+ const [name, setName] = useState('')
+ const [displayName, setDisplayName] = useState('')
+ const [transport, setTransport] = useState('stdio')
+ const [command, setCommand] = useState('')
+ const [args, setArgs] = useState('')
+ const [url, setUrl] = useState('')
+ const [headers, setHeaders] = useState>({})
+ const [env, setEnv] = useState>({})
+ const [toolPrefix, setToolPrefix] = useState('')
+ const [timeoutSec, setTimeoutSec] = useState(30)
+ const [enabled, setEnabled] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [submitError, setSubmitError] = useState('')
+ const [testState, setTestState] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
+ const [testResult, setTestResult] = useState(null)
+
+ useEffect(() => {
+ if (!open) return
+ if (server) {
+ setName(server.name)
+ setDisplayName(server.display_name || '')
+ setTransport(server.transport)
+ setCommand(server.command || '')
+ setArgs(Array.isArray(server.args) ? server.args.join(' ') : '')
+ setUrl(server.url || '')
+ setHeaders(server.headers ?? {})
+ setEnv(server.env ?? {})
+ setToolPrefix(server.tool_prefix?.replace(/^mcp_/, '') ?? '')
+ setTimeoutSec(server.timeout_sec || 30)
+ setEnabled(server.enabled)
+ } else {
+ setName('')
+ setDisplayName('')
+ setTransport('stdio')
+ setCommand('')
+ setArgs('')
+ setUrl('')
+ setHeaders({})
+ setEnv({})
+ setToolPrefix('')
+ setTimeoutSec(30)
+ setEnabled(true)
+ }
+ setTestState('idle')
+ setTestResult(null)
+ setSaving(false)
+ setSubmitError('')
+ }, [open, server])
+
+ if (!open) return null
+
+ function buildInput(): MCPServerInput {
+ const input: MCPServerInput = {
+ name,
+ display_name: displayName || undefined,
+ transport,
+ timeout_sec: timeoutSec,
+ enabled,
+ }
+ if (transport === 'stdio') {
+ input.command = command
+ input.args = args.trim() ? args.trim().split(/\s+/) : undefined
+ } else {
+ input.url = url
+ if (Object.keys(headers).length > 0) input.headers = headers
+ }
+ if (Object.keys(env).length > 0) input.env = env
+ if (toolPrefix.trim()) input.tool_prefix = toolPrefix.trim()
+ return input
+ }
+
+ async function handleSubmit() {
+ setSaving(true)
+ setSubmitError('')
+ try {
+ await onSubmit(buildInput())
+ onOpenChange(false)
+ } catch (err) {
+ setSubmitError((err as Error).message || 'Failed to save')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ async function handleTest() {
+ setTestState('testing')
+ setTestResult(null)
+ try {
+ const data: Parameters[0] = { transport }
+ if (transport === 'stdio') {
+ data.command = command
+ if (args.trim()) data.args = args.trim().split(/\s+/)
+ } else {
+ data.url = url
+ if (Object.keys(headers).length > 0) data.headers = headers
+ }
+ if (Object.keys(env).length > 0) data.env = env
+ const result = await onTest(data)
+ setTestResult(result)
+ setTestState(result.success ? 'success' : 'error')
+ } catch (err) {
+ setTestResult({ success: false, error: (err as Error).message })
+ setTestState('error')
+ }
+ }
+
+ const canSubmit = name.trim() && (transport === 'stdio' ? command.trim() : url.trim()) && !saving
+
+ return (
+
+
onOpenChange(false)} />
+
+ {/* Header */}
+
+
{isEdit ? t('form.editTitle') : t('form.createTitle')}
+
onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
+
+
+
+
+ {/* Form */}
+
+ {/* Name */}
+
+
+
setName(slugify(e.target.value))}
+ disabled={isEdit}
+ placeholder="my-mcp-server"
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
+ />
+
{t('form.nameHint')}
+
+
+ {/* Display Name */}
+
+
+ setDisplayName(e.target.value)}
+ placeholder={t('form.displayNamePlaceholder')}
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+ {/* Transport */}
+
+
+
+ {TRANSPORTS.map((t) => (
+ setTransport(t)}
+ className={`border rounded-lg px-3 py-2 text-xs text-center transition-colors ${
+ transport === t
+ ? 'border-accent bg-accent/10 text-accent font-medium'
+ : 'border-border text-text-secondary hover:bg-surface-tertiary/30'
+ }`}
+ >
+ {t.toUpperCase()}
+
+ ))}
+
+
+
+ {/* Stdio fields */}
+ {transport === 'stdio' && (
+ <>
+
+
+ setCommand(e.target.value)}
+ placeholder="npx"
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+
+ setArgs(e.target.value)}
+ placeholder={t('form.argsPlaceholder')}
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+ >
+ )}
+
+ {/* SSE/HTTP fields */}
+ {transport !== 'stdio' && (
+ <>
+
+
+ setUrl(e.target.value)}
+ placeholder="http://localhost:3001/sse"
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+
+
+
+ >
+ )}
+
+ {/* Env Variables */}
+
+
+
+
+
+ {/* Tool Prefix */}
+
+
+
+ mcp_
+ setToolPrefix(e.target.value.replace(/[^a-z0-9_]/gi, '_').toLowerCase())}
+ placeholder={name.replace(/-/g, '_') || 'auto'}
+ className="flex-1 min-w-0 bg-surface-tertiary border border-border rounded-r-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
{t('form.toolPrefixHint')}
+
+
+ {/* Timeout */}
+
+
+ setTimeoutSec(Math.max(1, Number(e.target.value)))}
+ className="w-24 bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+ {/* Enabled */}
+
+ {t('form.enabled')}
+
+
+
+
+ {/* Error */}
+ {submitError && (
+
+ )}
+
+ {/* Footer */}
+
+ {/* Test result (above buttons so it doesn't break layout) */}
+ {testState === 'success' && testResult && (
+
+
+ {t('form.toolsFound', { count: testResult.tool_count })}
+
+ )}
+ {testState === 'error' && testResult && (
+
+
+ {testResult.error || t('form.errors.connectionFailed')}
+
+ )}
+
+
+
+ {testState === 'testing' ? (
+
+
+ {t('form.testing')}
+
+ ) : t('form.testConnection')}
+
+
+ onOpenChange(false)}
+ className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors"
+ >
+ {t('form.cancel')}
+
+
+ {saving ? t('form.saving') : isEdit ? t('form.update') : t('form.create')}
+
+
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/mcp/McpGrantsDialog.tsx b/ui/desktop/frontend/src/components/settings/mcp/McpGrantsDialog.tsx
new file mode 100644
index 00000000..dc0c83d2
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/mcp/McpGrantsDialog.tsx
@@ -0,0 +1,161 @@
+import { useEffect, useState, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Combobox } from '../../common/Combobox'
+import type { MCPServerData, MCPAgentGrant } from '../../../types/mcp'
+import type { AgentData } from '../../../types/agent'
+
+interface McpGrantsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ server: MCPServerData
+ agents: AgentData[]
+ onLoadGrants: (serverId: string) => Promise
+ onGrant: (serverId: string, agentId: string) => Promise
+ onRevoke: (serverId: string, agentId: string) => Promise
+}
+
+export function McpGrantsDialog({ open, onOpenChange, server, agents, onLoadGrants, onGrant, onRevoke }: McpGrantsDialogProps) {
+ const { t } = useTranslation(['mcp', 'common'])
+ const [grants, setGrants] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [selectedAgent, setSelectedAgent] = useState('')
+ const [granting, setGranting] = useState(false)
+ const [error, setError] = useState('')
+
+ const refresh = useCallback(async () => {
+ setLoading(true)
+ try {
+ const g = await onLoadGrants(server.id)
+ setGrants(g)
+ } catch (err) {
+ console.error('Failed to load MCP grants:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [server.id, onLoadGrants])
+
+ useEffect(() => {
+ if (open) refresh()
+ }, [open, refresh])
+
+ if (!open) return null
+
+ const grantedIds = new Set(grants.map((g) => g.agent_id))
+ const availableAgents = agents
+ .filter((a) => !grantedIds.has(a.id))
+ .map((a) => ({ value: a.id, label: a.display_name || a.agent_key }))
+
+ function agentName(agentId: string): string {
+ const a = agents.find((x) => x.id === agentId)
+ return a?.display_name || a?.agent_key || agentId.slice(0, 8)
+ }
+
+ async function handleGrant() {
+ if (!selectedAgent) return
+ setGranting(true)
+ setError('')
+ try {
+ await onGrant(server.id, selectedAgent)
+ setSelectedAgent('')
+ const updated = await onLoadGrants(server.id)
+ setGrants(updated)
+ } catch (err) {
+ console.error('Failed to grant MCP access:', err)
+ setError((err as Error).message || 'Failed to grant')
+ } finally {
+ setGranting(false)
+ }
+ }
+
+ async function handleRevoke(agentId: string) {
+ setError('')
+ try {
+ await onRevoke(server.id, agentId)
+ await refresh()
+ } catch (err) {
+ setError((err as Error).message || 'Failed to revoke')
+ }
+ }
+
+ const displayName = server.display_name || server.name
+
+ return (
+
+
onOpenChange(false)} />
+
+ {/* Header */}
+
+
+
+
+
{t('grants.title', { name: displayName })}
+
+
{t('grants.addGrant')}
+
+
onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
+
+
+
+
+ {/* Content */}
+
+ {/* Current grants */}
+
+ {loading ? (
+
+ ) : grants.length === 0 ? (
+
{t('grants.noAgentsGranted')}
+ ) : (
+ grants.map((grant) => (
+
+
{agentName(grant.agent_id)}
+
handleRevoke(grant.agent_id)}
+ className="text-[11px] text-text-muted hover:text-error transition-colors flex items-center gap-1"
+ >
+
+ {t('grants.revoke')}
+
+
+ ))
+ )}
+
+
+ {error &&
{error}
}
+
+ {/* Add grant */}
+ {availableAgents.length > 0 && (
+
+
+
+
+
+
+ {granting ? t('common:loading') : t('grants.grant')}
+
+
+ )}
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/mcp/McpServerList.tsx b/ui/desktop/frontend/src/components/settings/mcp/McpServerList.tsx
new file mode 100644
index 00000000..22d5f2be
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/mcp/McpServerList.tsx
@@ -0,0 +1,236 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useMcpServers, MAX_MCP_LITE } from '../../../hooks/use-mcp-servers'
+import { useAgentCrud } from '../../../hooks/use-agent-crud'
+import { McpFormDialog } from './McpFormDialog'
+import { McpGrantsDialog } from './McpGrantsDialog'
+import { McpToolsDialog } from './McpToolsDialog'
+import { ConfirmDeleteDialog } from '../../common/ConfirmDeleteDialog'
+import { RefreshButton } from '../../common/RefreshButton'
+import type { MCPServerData } from '../../../types/mcp'
+
+export function McpServerList() {
+ const { t } = useTranslation(['mcp', 'common'])
+ const {
+ servers, loading, atLimit,
+ fetchServers, createServer, updateServer, deleteServer,
+ testConnection, listServerTools,
+ listGrants, grantAgent, revokeAgent,
+ } = useMcpServers()
+ const { agents } = useAgentCrud()
+
+ const [formOpen, setFormOpen] = useState(false)
+ const [editServer, setEditServer] = useState
(null)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [grantsServer, setGrantsServer] = useState(null)
+ const [toolsServer, setToolsServer] = useState(null)
+
+ function openCreate() {
+ setEditServer(null)
+ setFormOpen(true)
+ }
+
+ function openEdit(s: MCPServerData) {
+ setEditServer(s)
+ setFormOpen(true)
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
{t('title')}
+
{t('description')} (max {MAX_MCP_LITE})
+
+
+
+
+ {t('addServer')}
+
+
+
+
+
+ {atLimit && (
+
+ {t('noMatchTitle')} ({MAX_MCP_LITE}). {t('noMatchDescription')}
+
+ )}
+
+ {/* Loading skeleton */}
+ {loading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : servers.length === 0 ? (
+ /* Empty state */
+
+
+
{t('emptyTitle')}
+
{t('emptyDescription')}
+
+ ) : (
+ /* Table */
+
+
+
+
+ | {t('columns.name')} |
+ {t('columns.transport')} |
+ {t('columns.tools')} |
+ {t('columns.agents')} |
+ {t('columns.enabled')} |
+ {t('columns.actions')} |
+
+
+
+ {servers.map((s) => (
+
+ {/* Name */}
+
+
+
+
+ {s.display_name || s.name}
+
+ mcp_{s.name}
+
+
+
+ |
+ {/* Transport */}
+
+
+ {s.transport.toUpperCase()}
+
+ |
+ {/* Tools */}
+
+ setToolsServer(s)}
+ className="p-1 text-text-muted hover:text-text-primary transition-colors"
+ title={t('viewTools')}
+ >
+
+
+ |
+ {/* Agents */}
+
+ setGrantsServer(s)}
+ className="inline-flex items-center gap-1 text-sm text-text-primary hover:text-accent transition-colors"
+ title={t('manageGrants')}
+ >
+
+ {s.agent_count ?? 0}
+
+ |
+ {/* Enabled */}
+
+
+ {s.enabled ? t('common:enabled') : t('common:disabled')}
+
+ |
+ {/* Actions */}
+
+
+ openEdit(s)} className="p-1 text-text-muted hover:text-text-primary transition-colors" title="Edit">
+
+
+ setDeleteTarget(s)} className="p-1 text-text-muted hover:text-error transition-colors" title="Delete">
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ {/* Dialogs */}
+
{
+ if (editServer) {
+ await updateServer(editServer.id, data)
+ } else {
+ await createServer(data)
+ }
+ }}
+ onTest={testConnection}
+ />
+
+ {deleteTarget && (
+ setDeleteTarget(null)}
+ title="Delete MCP Server"
+ description={`This will permanently delete the server "${deleteTarget.display_name || deleteTarget.name}" and all its agent grants.`}
+ confirmValue={deleteTarget.name}
+ onConfirm={async () => {
+ await deleteServer(deleteTarget.id)
+ setDeleteTarget(null)
+ }}
+ />
+ )}
+
+ {grantsServer && (
+ setGrantsServer(null)}
+ server={grantsServer}
+ agents={agents}
+ onLoadGrants={listGrants}
+ onGrant={grantAgent}
+ onRevoke={revokeAgent}
+ />
+ )}
+
+ {toolsServer && (
+ setToolsServer(null)}
+ server={toolsServer}
+ onLoadTools={listServerTools}
+ />
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/mcp/McpToolsDialog.tsx b/ui/desktop/frontend/src/components/settings/mcp/McpToolsDialog.tsx
new file mode 100644
index 00000000..155e5579
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/mcp/McpToolsDialog.tsx
@@ -0,0 +1,97 @@
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { MCPServerData, MCPToolInfo } from '../../../types/mcp'
+
+interface McpToolsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ server: MCPServerData
+ onLoadTools: (serverId: string) => Promise
+}
+
+export function McpToolsDialog({ open, onOpenChange, server, onLoadTools }: McpToolsDialogProps) {
+ const { t } = useTranslation('mcp')
+ const [tools, setTools] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ if (!open) return
+ setLoading(true)
+ setError('')
+ onLoadTools(server.id)
+ .then(setTools)
+ .catch((err) => setError(err?.message ?? 'Failed to load tools'))
+ .finally(() => setLoading(false))
+ }, [open, server.id, onLoadTools])
+
+ if (!open) return null
+
+ const displayName = server.display_name || server.name
+
+ return (
+
+
onOpenChange(false)} />
+
+ {/* Header */}
+
+
+
+
+
{t('tools.title', { name: displayName })}
+ {!loading && tools.length > 0 && (
+
+ {tools.length} tool{tools.length !== 1 ? 's' : ''}
+
+ )}
+
+
{t('tools.prefix')} mcp_{server.name}
+
+
onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
+
+
+
+
+ {/* Content — scrollable at edges */}
+ {loading ? (
+
+
+
{t('tools.discovering')}
+
+ ) : error ? (
+
{error}
+ ) : tools.length === 0 ? (
+
+
+
{t('tools.noToolsTitle')}
+
{t('tools.noToolsDescription')}
+
+ ) : (
+
+
+ {tools.map((tool) => (
+
+
+ {tool.description && (
+
{tool.description}
+ )}
+
+ ))}
+
+
+ )}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/providers/ProviderFormDialog.tsx b/ui/desktop/frontend/src/components/settings/providers/ProviderFormDialog.tsx
new file mode 100644
index 00000000..2271216b
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/providers/ProviderFormDialog.tsx
@@ -0,0 +1,160 @@
+import { useState, useEffect, useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Combobox } from '../../common/Combobox'
+import { Switch } from '../../common/Switch'
+import { PROVIDER_TYPES, slugify } from '../../../constants/providers'
+import type { ProviderData, ProviderInput } from '../../../types/provider'
+
+interface ProviderFormDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ /** If set, editing this provider. Otherwise creating. */
+ provider?: ProviderData | null
+ onSubmit: (input: ProviderInput) => Promise
+}
+
+export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: ProviderFormDialogProps) {
+ const isEditing = !!provider
+ const { t } = useTranslation(['providers', 'common'])
+
+ const [providerType, setProviderType] = useState(provider?.provider_type ?? '')
+ const [displayName, setDisplayName] = useState(provider?.display_name ?? '')
+ const [apiBase, setApiBase] = useState(provider?.api_base ?? '')
+ const [apiKey, setApiKey] = useState('')
+ const [enabled, setEnabled] = useState(provider?.enabled ?? true)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+
+ // Reset form when dialog opens/closes or provider changes
+ useEffect(() => {
+ if (open) {
+ setProviderType(provider?.provider_type ?? '')
+ setDisplayName(provider?.display_name ?? '')
+ setApiBase(provider?.api_base ?? '')
+ setApiKey('')
+ setEnabled(provider?.enabled ?? true)
+ setError('')
+ }
+ }, [open, provider])
+
+ // Auto-set api_base from provider type
+ const typeInfo = useMemo(() => PROVIDER_TYPES.find((t) => t.value === providerType), [providerType])
+ useEffect(() => {
+ if (!isEditing && typeInfo?.apiBase && !apiBase) {
+ setApiBase(typeInfo.apiBase)
+ }
+ }, [typeInfo, isEditing, apiBase])
+
+ const name = useMemo(() => {
+ if (isEditing) return provider!.name
+ return slugify(displayName || providerType) || 'provider'
+ }, [isEditing, provider, displayName, providerType])
+
+ const handleSubmit = async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const input: ProviderInput = {
+ name,
+ display_name: displayName || undefined,
+ provider_type: providerType,
+ api_base: apiBase || undefined,
+ enabled,
+ }
+ // Only send api_key if user entered a new one
+ if (apiKey) input.api_key = apiKey
+ await onSubmit(input)
+ onOpenChange(false)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save provider')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (!open) return null
+
+ const typeOptions = PROVIDER_TYPES.map((t) => ({ value: t.value, label: t.label }))
+
+ return (
+
+
+
+ {isEditing ? t('providers:form.editTitle') : t('providers:form.createTitle')}
+
+
+ {/* Provider type */}
+
+
+
+
+
+ {/* Display name */}
+
+
+ setDisplayName(e.target.value)}
+ placeholder={typeInfo?.label ?? 'My Provider'}
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+ {/* API Base */}
+
+
+ setApiBase(e.target.value)}
+ placeholder="https://api.example.com"
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+ {/* API Key */}
+
+
+ setApiKey(e.target.value)}
+ placeholder={isEditing ? '••••••••' : 'sk-...'}
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+ {/* Enabled toggle */}
+
+
+ {t('providers:form.enabled')}
+
+
+ {error &&
{error}
}
+
+
+ onOpenChange(false)}
+ className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors"
+ >
+ {t('providers:form.cancel')}
+
+
+ {loading ? '...' : isEditing ? t('providers:form.save') : t('providers:form.create')}
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/providers/ProviderList.tsx b/ui/desktop/frontend/src/components/settings/providers/ProviderList.tsx
new file mode 100644
index 00000000..0c43d2a5
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/providers/ProviderList.tsx
@@ -0,0 +1,87 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useProviders } from '../../../hooks/use-providers'
+import { ProviderRow } from './ProviderRow'
+import { ProviderFormDialog } from './ProviderFormDialog'
+import { ConfirmDialog } from '../../common/ConfirmDialog'
+import type { ProviderData, ProviderInput } from '../../../types/provider'
+
+export function ProviderList() {
+ const { t } = useTranslation(['providers', 'common'])
+ const { providers, loading, createProvider, updateProvider, deleteProvider } = useProviders()
+ const [formOpen, setFormOpen] = useState(false)
+ const [editingProvider, setEditingProvider] = useState(null)
+ const [deletingProvider, setDeletingProvider] = useState(null)
+
+ const handleEdit = (provider: ProviderData) => {
+ setEditingProvider(provider)
+ setFormOpen(true)
+ }
+
+ const handleCreate = () => {
+ setEditingProvider(null)
+ setFormOpen(true)
+ }
+
+ const handleSubmit = async (input: ProviderInput) => {
+ if (editingProvider) {
+ await updateProvider(editingProvider.id, input)
+ } else {
+ await createProvider(input)
+ }
+ }
+
+ const handleConfirmDelete = async () => {
+ if (deletingProvider) {
+ await deleteProvider(deletingProvider.id)
+ setDeletingProvider(null)
+ }
+ }
+
+ if (loading) {
+ return {t('common:loading')}
+ }
+
+ return (
+ <>
+
+
+
{t('providers:title')}
+
+ + {t('providers:addProvider')}
+
+
+
+ {providers.length === 0 ? (
+
{t('providers:emptyTitle')}
+ ) : (
+
+ {providers.map((p) => (
+
+ ))}
+
+ )}
+
+
+
+
+ { if (!open) setDeletingProvider(null) }}
+ title="Delete provider?"
+ description={`This will remove "${deletingProvider?.display_name || deletingProvider?.name}". Agents using this provider will stop working.`}
+ confirmLabel="Delete"
+ variant="destructive"
+ onConfirm={handleConfirmDelete}
+ />
+ >
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/providers/ProviderRow.tsx b/ui/desktop/frontend/src/components/settings/providers/ProviderRow.tsx
new file mode 100644
index 00000000..26fae9e3
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/providers/ProviderRow.tsx
@@ -0,0 +1,57 @@
+import type { ProviderData } from '../../../types/provider'
+import { PROVIDER_TYPES } from '../../../constants/providers'
+
+interface ProviderRowProps {
+ provider: ProviderData
+ onEdit: (provider: ProviderData) => void
+ onDelete: (provider: ProviderData) => void
+}
+
+export function ProviderRow({ provider, onEdit, onDelete }: ProviderRowProps) {
+ const typeInfo = PROVIDER_TYPES.find((t) => t.value === provider.provider_type)
+
+ return (
+
+ {/* Status dot */}
+
+
+ {/* Info */}
+
+
+
+ {provider.display_name || provider.name}
+
+
+ {typeInfo?.label ?? provider.provider_type}
+
+
+ {provider.api_base && (
+
{provider.api_base}
+ )}
+
+
+ {/* Actions */}
+
+
onEdit(provider)}
+ className="p-1.5 rounded text-text-muted hover:text-text-primary hover:bg-surface-tertiary transition-colors"
+ title="Edit"
+ >
+
+
+
onDelete(provider)}
+ className="p-1.5 rounded text-text-muted hover:text-error hover:bg-surface-tertiary transition-colors"
+ title="Delete"
+ >
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/skills/SkillList.tsx b/ui/desktop/frontend/src/components/settings/skills/SkillList.tsx
new file mode 100644
index 00000000..d6b4653f
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/skills/SkillList.tsx
@@ -0,0 +1,242 @@
+import { useState, useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useSkills, MAX_SKILLS_LITE } from '../../../hooks/use-skills'
+import type { RuntimeStatus } from '../../../hooks/use-skills'
+import { Switch } from '../../common/Switch'
+import { RefreshButton } from '../../common/RefreshButton'
+import { ConfirmDeleteDialog } from '../../common/ConfirmDeleteDialog'
+import type { SkillInfo } from '../../../types/skill'
+
+export function SkillList() {
+ const { t } = useTranslation(['skills', 'common'])
+ const { skills, loading, atLimit, fetchSkills, toggleSkill, uploadSkill, checkRuntimes, deleteSkill } = useSkills()
+ const [runtimes, setRuntimes] = useState(null)
+ const [uploading, setUploading] = useState(false)
+ const [uploadError, setUploadError] = useState('')
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const fileRef = useRef(null)
+
+ useEffect(() => {
+ checkRuntimes().then((r) => { if (r) setRuntimes(r) }).catch(() => {})
+ }, [checkRuntimes])
+
+ async function handleUpload(file: File) {
+ setUploading(true)
+ setUploadError('')
+ try {
+ const result = await uploadSkill(file)
+ if (result.deps_warning) setUploadError(result.deps_warning)
+ } catch (err) {
+ setUploadError((err as Error).message || t('upload.failed'))
+ } finally {
+ setUploading(false)
+ if (fileRef.current) fileRef.current.value = ''
+ }
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
{t('title')}
+
{t('description')} (max {MAX_SKILLS_LITE})
+
+
+
{ if (e.target.files?.[0]) handleUpload(e.target.files[0]) }}
+ />
+
fileRef.current?.click()}
+ disabled={atLimit || uploading}
+ className="bg-accent text-white rounded-lg px-3 py-1.5 text-xs hover:bg-accent-hover disabled:opacity-50 transition-colors flex items-center gap-1.5"
+ >
+ {uploading ? (
+ <>
+
+ {t('upload.uploading')}
+ >
+ ) : (
+ <>
+
+ {t('upload.button')}
+ >
+ )}
+
+
+
+
+
+ {atLimit && (
+
+ {t('deps.runtimeMissing')}
+
+ )}
+
+ {uploadError && (
+
{uploadError}
+ )}
+
+ {/* Runtime status */}
+ {runtimes && !runtimes.ready && (
+
+
+
+
{t('deps.runtimeMissing')}
+
+ {t('deps.runtimeMissingDesc')}{' '}
+ {runtimes.runtimes.filter((r) => !r.available).map((r) => r.name).join(', ')}
+
+
+
+ )}
+
+ {/* Loading */}
+ {loading ? (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+ ) : skills.length === 0 ? (
+
+
+
{t('emptyTitle')}
+
{t('emptyDescription')}
+
+ ) : (
+
+
+
+
+ | {t('columns.name')} |
+ {t('columns.description')} |
+ {t('columns.status')} |
+ {t('columns.actions')} |
+
+
+
+ {skills.map((skill) => (
+ setDeleteTarget(skill)}
+ />
+ ))}
+
+
+
+ )}
+
+ {/* Delete confirmation */}
+ {deleteTarget && (
+
setDeleteTarget(null)}
+ title={t('delete.title')}
+ description={t('delete.description', { name: deleteTarget.name })}
+ confirmValue={deleteTarget.name}
+ onConfirm={async () => {
+ if (deleteTarget.id) await deleteSkill(deleteTarget.id)
+ setDeleteTarget(null)
+ }}
+ />
+ )}
+
+ )
+}
+
+/* --- SkillRow --- */
+
+function SkillRow({ skill, onToggle, onDelete }: {
+ skill: SkillInfo
+ onToggle: (id: string, enabled: boolean) => void
+ onDelete: () => void
+}) {
+ const { t } = useTranslation('skills')
+ const isArchived = skill.status === 'archived'
+ const isDisabled = !skill.enabled || isArchived
+ const hasMissing = (skill.missing_deps?.length ?? 0) > 0
+
+ return (
+
+ {/* Name */}
+
+
+
+ {skill.name}
+ {skill.is_system && (
+
+ {t('system')}
+
+ )}
+ {(skill.version ?? 0) > 0 && (
+ v{skill.version}
+ )}
+
+ |
+
+ {/* Description */}
+
+ {skill.description || '—'}
+ |
+
+ {/* Status */}
+
+
+
+ {isArchived ? t('deps.statusArchived') : t('deps.statusActive')}
+
+ {hasMissing && (
+
+ {skill.missing_deps!.slice(0, 3).join(', ')}
+ {skill.missing_deps!.length > 3 && ` +${skill.missing_deps!.length - 3}`}
+
+ )}
+
+ |
+
+ {/* Actions */}
+
+
+ {/* Delete (non-system only) */}
+ {!skill.is_system && skill.id && (
+
+
+
+ )}
+ skill.id && onToggle(skill.id, v)}
+ disabled={!skill.id}
+ />
+
+ |
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/tools/ToolList.tsx b/ui/desktop/frontend/src/components/settings/tools/ToolList.tsx
new file mode 100644
index 00000000..472886ab
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/tools/ToolList.tsx
@@ -0,0 +1,197 @@
+import { useState, useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useBuiltinTools } from '../../../hooks/use-builtin-tools'
+import { Switch } from '../../common/Switch'
+import { RefreshButton } from '../../common/RefreshButton'
+import { ToolSettingsDialog } from './ToolSettingsDialog'
+import type { BuiltinToolData } from '../../../types/builtin-tool'
+
+const CATEGORY_ORDER = [
+ 'filesystem', 'runtime', 'web', 'memory', 'media', 'browser',
+ 'sessions', 'messaging', 'scheduling', 'subagents', 'skills', 'delegation', 'teams',
+]
+
+const MEDIA_TOOLS = new Set([
+ 'read_image', 'read_document', 'read_audio', 'read_video',
+ 'create_image', 'create_video', 'create_audio',
+])
+
+function hasEditableSettings(tool: BuiltinToolData): boolean {
+ // Media tools + web_fetch always show settings (even when empty — user can add providers)
+ if (MEDIA_TOOLS.has(tool.name) || tool.name === 'web_fetch') return true
+ return tool.settings != null && Object.keys(tool.settings).length > 0
+}
+
+function isDeprecated(tool: BuiltinToolData): boolean {
+ return (tool.metadata as Record)?.deprecated === true
+}
+
+export function ToolList() {
+ const { t } = useTranslation('tools')
+ const { tools, loading, fetchTools, toggleTool, updateSettings } = useBuiltinTools()
+ const [search, setSearch] = useState('')
+ const [settingsTool, setSettingsTool] = useState(null)
+
+ const filtered = useMemo(() => {
+ if (!search.trim()) return tools
+ const q = search.toLowerCase()
+ return tools.filter((t) =>
+ t.display_name.toLowerCase().includes(q)
+ || t.name.toLowerCase().includes(q)
+ || t.description.toLowerCase().includes(q)
+ )
+ }, [tools, search])
+
+ const grouped = useMemo(() => {
+ const map = new Map()
+ for (const t of filtered) {
+ const cat = t.category || 'other'
+ if (!map.has(cat)) map.set(cat, [])
+ map.get(cat)!.push(t)
+ }
+ const sorted: [string, BuiltinToolData[]][] = []
+ for (const cat of CATEGORY_ORDER) {
+ if (map.has(cat)) {
+ sorted.push([cat, map.get(cat)!])
+ map.delete(cat)
+ }
+ }
+ for (const [cat, items] of map) {
+ sorted.push([cat, items])
+ }
+ return sorted
+ }, [filtered])
+
+ return (
+
+ {/* Header */}
+
+
+
{t('builtin.title')}
+
+ {filtered.length !== 1 ? t('builtin.toolCountPlural', { count: filtered.length }) : t('builtin.toolCount', { count: filtered.length })} · {t('builtin.categoryCount', { count: grouped.length })}
+
+
+
+
+
+ {/* Search */}
+ {tools.length > 10 && (
+
setSearch(e.target.value)}
+ placeholder={t('builtin.searchPlaceholder')}
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+ )}
+
+ {/* Loading */}
+ {loading ? (
+
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
+
+ ))}
+
+ ) : filtered.length === 0 ? (
+ /* Empty */
+
+
+
{search ? t('builtin.noMatchTitle') : t('builtin.emptyTitle')}
+
+ ) : (
+ /* Category groups */
+
+ {grouped.map(([category, items]) => (
+
+ {/* Category header */}
+
+ {t(`builtin.categories.${category}`, category)}
+ {items.length}
+
+ {/* Tool rows */}
+
+ {items.map((tool) => (
+ setSettingsTool(tool)}
+ />
+ ))}
+
+
+ ))}
+
+ )}
+
+ {/* Settings dialog */}
+ {settingsTool && (
+
setSettingsTool(null)}
+ tool={settingsTool}
+ onSave={updateSettings}
+ />
+ )}
+
+ )
+}
+
+/* --- ToolRow --- */
+
+const HIDDEN_REQUIRES = new Set(['managed_mode', 'teams'])
+
+function ToolRow({ tool, onToggle, onOpenSettings }: {
+ tool: BuiltinToolData
+ onToggle: (name: string, enabled: boolean) => void
+ onOpenSettings: () => void
+}) {
+ const { t } = useTranslation('tools')
+ const deprecated = isDeprecated(tool)
+ const editable = hasEditableSettings(tool) && !deprecated
+ const visibleRequires = (tool.requires ?? []).filter((r) => !HIDDEN_REQUIRES.has(r))
+
+ return (
+
+ {/* Left */}
+
+
+ {tool.display_name}
+ {tool.name}
+
+ {visibleRequires.length > 0 && !deprecated && (
+
+ {t('builtin.requiresTooltip', { list: visibleRequires.join(', ') })}
+
+ )}
+ {tool.description && (
+
{tool.description}
+ )}
+
+
+ {/* Right */}
+
+ {editable && (
+
+
+ {t('builtin.settings')}
+
+ )}
+
onToggle(tool.name, v)}
+ disabled={deprecated}
+ />
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/tools/ToolSettingsDialog.tsx b/ui/desktop/frontend/src/components/settings/tools/ToolSettingsDialog.tsx
new file mode 100644
index 00000000..0807c55b
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/tools/ToolSettingsDialog.tsx
@@ -0,0 +1,428 @@
+import { useState, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Switch } from '../../common/Switch'
+import { Combobox } from '../../common/Combobox'
+import { getApiClient } from '../../../lib/api'
+import type { BuiltinToolData } from '../../../types/builtin-tool'
+
+const MEDIA_TOOLS = new Set([
+ 'read_image', 'read_document', 'read_audio', 'read_video',
+ 'create_image', 'create_video', 'create_audio',
+])
+
+interface ToolSettingsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ tool: BuiltinToolData
+ onSave: (name: string, settings: Record) => Promise
+}
+
+export function ToolSettingsDialog({ open, onOpenChange, tool, onSave }: ToolSettingsDialogProps) {
+ const { t } = useTranslation(['tools', 'common'])
+ if (!open) return null
+
+ return (
+
+
onOpenChange(false)} />
+
+ {/* Header */}
+
+
+
{t('builtin.settingsDialog.title', { name: tool.display_name })}
+
{tool.name}
+
+
onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
+
+
+
+
+ {/* Content — route to specialized form or generic JSON */}
+ {tool.name === 'web_fetch' ? (
+
onOpenChange(false)} />
+ ) : MEDIA_TOOLS.has(tool.name) ? (
+ onOpenChange(false)} />
+ ) : (
+ onOpenChange(false)} />
+ )}
+
+
+ )
+}
+
+/* ─── Extractor Chain Form (web_fetch) ─── */
+
+interface ExtractorEntry {
+ name: string
+ enabled: boolean
+ base_url?: string
+ timeout?: number
+ max_retries?: number
+}
+
+const EXTRACTOR_DISPLAY: Record
= {
+ defuddle: 'Defuddle',
+ 'html-to-markdown': 'HTML to Markdown',
+}
+
+function ExtractorChainForm({ tool, onSave, onClose }: { tool: BuiltinToolData; onSave: ToolSettingsDialogProps['onSave']; onClose: () => void }) {
+ const { t } = useTranslation(['tools', 'common'])
+ const [extractors, setExtractors] = useState([])
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ const settings = tool.settings as { extractors?: ExtractorEntry[] }
+ setExtractors(settings?.extractors ?? [])
+ setError('')
+ setSaving(false)
+ }, [tool])
+
+ function updateExtractor(index: number, updates: Partial) {
+ setExtractors((prev) => prev.map((e, i) => i === index ? { ...e, ...updates } : e))
+ }
+
+ async function handleSave() {
+ setSaving(true)
+ setError('')
+ try {
+ await onSave(tool.name, { extractors })
+ onClose()
+ } catch (err) {
+ setError((err as Error).message || 'Failed to save')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+ <>
+
+ {extractors.map((ext, i) => (
+
+ {/* Header row */}
+
+
+ #{i + 1}
+ {EXTRACTOR_DISPLAY[ext.name] ?? ext.name}
+
+
updateExtractor(i, { enabled: v })} />
+
+
+ {/* Defuddle-specific fields */}
+ {ext.name === 'defuddle' && (
+ <>
+
+
+ updateExtractor(i, { base_url: e.target.value })}
+ placeholder="https://fetch.goclaw.sh/"
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+
+ updateExtractor(i, { max_retries: Math.max(0, Number(e.target.value)) })}
+ className="w-20 bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+ >
+ )}
+
+ {/* Timeout (show for defuddle or if already set) */}
+ {(ext.name === 'defuddle' || (ext.timeout && ext.timeout > 0)) && (
+
+
+
updateExtractor(i, { timeout: Math.max(0, Number(e.target.value)) })}
+ className="w-20 bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
0 = {t('common:default')}
+
+ )}
+
+ ))}
+
+ {extractors.length === 0 && (
+
{t('builtin.mediaChain.noProviders')}
+ )}
+
+
+ {/* Footer */}
+ {error && }
+
+
+ {t('builtin.settingsDialog.cancel')}
+
+
+ {saving ? t('builtin.settingsDialog.saving') : t('builtin.settingsDialog.save')}
+
+
+ >
+ )
+}
+
+/* ─── Media Provider Chain Form ─── */
+
+interface ProviderEntry {
+ provider: string
+ model: string
+ enabled: boolean
+ timeout: number
+ max_retries: number
+ params?: Record
+}
+
+function MediaProviderChainForm({ tool, onSave, onClose }: { tool: BuiltinToolData; onSave: ToolSettingsDialogProps['onSave']; onClose: () => void }) {
+ const { t } = useTranslation(['tools', 'common'])
+ const [chain, setChain] = useState([])
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ // Load configured providers for dropdown
+ const [dbProviders, setDbProviders] = useState<{ id: string; name: string }[]>([])
+ const [modelsByProvider, setModelsByProvider] = useState>({})
+
+ useEffect(() => {
+ const settings = tool.settings as { providers?: ProviderEntry[] }
+ setChain(settings?.providers ?? [])
+ setError('')
+ setSaving(false)
+
+ // Fetch providers list
+ getApiClient().get<{ providers: { id: string; name: string; display_name?: string }[] | null }>('/v1/providers')
+ .then((res) => {
+ const list = (res.providers ?? []).map((p) => ({ id: p.id, name: p.name }))
+ setDbProviders(list)
+ })
+ .catch(() => {})
+ }, [tool])
+
+ async function loadModels(providerName: string) {
+ if (modelsByProvider[providerName]) return
+ const prov = dbProviders.find((p) => p.name === providerName)
+ if (!prov) return
+ try {
+ const res = await getApiClient().get<{ models?: { id: string }[] }>(`/v1/providers/${prov.id}/models`)
+ setModelsByProvider((prev) => ({ ...prev, [providerName]: (res.models ?? []).map((m) => m.id) }))
+ } catch {
+ // Fail silently — user can still type manually
+ }
+ }
+
+ function updateEntry(index: number, updates: Partial) {
+ setChain((prev) => prev.map((p, i) => i === index ? { ...p, ...updates } : p))
+ }
+
+ function removeEntry(index: number) {
+ setChain((prev) => prev.filter((_, i) => i !== index))
+ }
+
+ function addEntry() {
+ setChain((prev) => [...prev, { provider: '', model: '', enabled: true, timeout: 120, max_retries: 2 }])
+ }
+
+ async function handleSave() {
+ setSaving(true)
+ setError('')
+ try {
+ await onSave(tool.name, { providers: chain })
+ onClose()
+ } catch (err) {
+ setError((err as Error).message || 'Failed to save')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const providerOptions = dbProviders.map((p) => ({ value: p.name, label: p.name }))
+
+ return (
+ <>
+
+ {chain.map((entry, i) => {
+ const modelOpts = (modelsByProvider[entry.provider] ?? []).map((m) => ({ value: m, label: m }))
+ return (
+
+ {/* Header */}
+
+
+ #{i + 1}
+ {entry.provider || t('builtin.mediaChain.newProvider')}
+ {entry.model && / {entry.model}}
+
+
+
updateEntry(i, { enabled: v })} />
+ removeEntry(i)} className="p-1 text-text-muted hover:text-error transition-colors">
+
+
+
+
+
+ {/* Provider + Model (Combobox) */}
+
+
+
+ {
+ updateEntry(i, { provider: v, model: '' })
+ loadModels(v)
+ }}
+ options={providerOptions}
+ placeholder={t('builtin.mediaChain.selectProvider')}
+ allowCustom
+ />
+
+
+
+ updateEntry(i, { model: v })}
+ options={modelOpts}
+ placeholder={t('builtin.mediaChain.selectModel')}
+ loading={entry.provider !== '' && !modelsByProvider[entry.provider]}
+ allowCustom
+ />
+
+
+
+ {/* Timeout + Retries */}
+
+
+ )
+ })}
+
+
+
+ {t('builtin.mediaChain.addProvider')}
+
+
+
+ {error && }
+
+
+ {t('builtin.mediaChain.cancel')}
+
+
+ {saving ? t('builtin.mediaChain.saving') : t('builtin.mediaChain.save')}
+
+
+ >
+ )
+}
+
+/* ─── Generic JSON Settings Form (fallback) ─── */
+
+function JsonSettingsForm({ tool, onSave, onClose }: { tool: BuiltinToolData; onSave: ToolSettingsDialogProps['onSave']; onClose: () => void }) {
+ const { t } = useTranslation(['tools', 'common'])
+ const [value, setValue] = useState('')
+ const [error, setError] = useState('')
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ setValue(JSON.stringify(tool.settings, null, 2))
+ setError('')
+ setSaving(false)
+ }, [tool])
+
+ function validate(json: string): Record | null {
+ try {
+ const parsed = JSON.parse(json)
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+ setError(t('builtin.jsonDialog.invalidJson'))
+ return null
+ }
+ setError('')
+ return parsed as Record
+ } catch {
+ setError(t('builtin.jsonDialog.invalidJson'))
+ return null
+ }
+ }
+
+ function handleFormat() {
+ const parsed = validate(value)
+ if (parsed) setValue(JSON.stringify(parsed, null, 2))
+ }
+
+ async function handleSave() {
+ const parsed = validate(value)
+ if (!parsed) return
+ setSaving(true)
+ try {
+ await onSave(tool.name, parsed)
+ onClose()
+ } catch (err) {
+ setError((err as Error).message || t('builtin.jsonDialog.invalidJson'))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+ {t('builtin.jsonDialog.cancel')}
+
+
+ {saving ? t('builtin.jsonDialog.saving') : t('builtin.jsonDialog.save')}
+
+
+ >
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/traces/TraceDetailDialog.tsx b/ui/desktop/frontend/src/components/settings/traces/TraceDetailDialog.tsx
new file mode 100644
index 00000000..f2ae51ac
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/traces/TraceDetailDialog.tsx
@@ -0,0 +1,410 @@
+import { useEffect, useState, useMemo, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
+import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
+import { toast } from '../../../stores/toast-store'
+import { fetchTraceDetail } from '../../../hooks/use-traces'
+import { getApiClient, isApiClientReady } from '../../../lib/api'
+import { DownloadURL } from '../../../../wailsjs/go/main/App'
+import type { TraceData, SpanData } from '../../../types/trace'
+
+interface Props {
+ traceId: string
+ onClose: () => void
+}
+
+function detectContent(text?: string): { lang: string; code: string } {
+ if (!text) return { lang: 'text', code: '' }
+ const trimmed = text.trim()
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
+ try {
+ return { lang: 'json', code: JSON.stringify(JSON.parse(trimmed), null, 2) }
+ } catch { /* not valid JSON */ }
+ }
+ return { lang: 'text', code: text }
+}
+
+function CodePreview({ text }: { text?: string }) {
+ const { lang, code } = detectContent(text)
+ if (!code) return null
+ // Only use syntax highlighting for JSON; plain text gets a simple pre block
+ if (lang === 'json') {
+ return (
+
+ {code}
+
+ )
+ }
+ return (
+
+ {code}
+
+ )
+}
+
+function formatDuration(ms: number | undefined | null, startTime?: string, endTime?: string): string {
+ // Fallback: compute from start/end times if ms is missing
+ if (ms == null || isNaN(ms) || ms === 0) {
+ if (startTime && endTime) {
+ const computed = new Date(endTime).getTime() - new Date(startTime).getTime()
+ if (!isNaN(computed) && computed > 0) ms = computed
+ else return '—'
+ } else {
+ return '—'
+ }
+ }
+ if (ms < 1000) return `${ms}ms`
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
+ const min = Math.floor(ms / 60000)
+ const sec = Math.round((ms % 60000) / 1000)
+ return `${min}m ${sec}s`
+}
+
+function formatTokens(count: number | null | undefined): string {
+ if (count == null) return '0'
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`
+ return count.toString()
+}
+
+function statusClass(status: string): string {
+ const s = status.toLowerCase()
+ if (s === 'completed' || s === 'ok' || s === 'success') {
+ return 'bg-emerald-500/15 text-emerald-700 border-emerald-500/25 dark:text-emerald-400'
+ }
+ if (s === 'error' || s === 'failed') {
+ return 'bg-red-500/15 text-red-700 border-red-500/25 dark:text-red-400'
+ }
+ return 'bg-blue-500/15 text-blue-700 border-blue-500/25 dark:text-blue-400'
+}
+
+function spanTypeIcon(spanType: string): string {
+ switch (spanType) {
+ case 'llm_call': return '🤖'
+ case 'tool_call': return '🔧'
+ case 'agent': return '👤'
+ case 'embedding': return '📊'
+ default: return '📌'
+ }
+}
+
+// Build span tree from flat list using parent_span_id
+interface SpanNode { span: SpanData; children: SpanNode[]; depth: number }
+
+function buildSpanTree(spans: SpanData[]): SpanNode[] {
+ const byId = new Map()
+ const roots: SpanNode[] = []
+ for (const span of spans) {
+ byId.set(span.id, { span, children: [], depth: 0 })
+ }
+ for (const span of spans) {
+ const node = byId.get(span.id)!
+ const parentId = span.parent_span_id
+ if (parentId && byId.has(parentId)) {
+ const parent = byId.get(parentId)!
+ node.depth = parent.depth + 1
+ parent.children.push(node)
+ } else {
+ roots.push(node)
+ }
+ }
+ // Flatten tree in DFS order
+ const flat: SpanNode[] = []
+ function walk(nodes: SpanNode[]) {
+ for (const n of nodes) { flat.push(n); walk(n.children) }
+ }
+ walk(roots)
+ return flat
+}
+
+function SpanRow({ node, expanded, onToggle }: { node: SpanNode; expanded: boolean; onToggle: () => void }) {
+ const { t } = useTranslation('traces')
+ const { span, depth } = node
+ const hasTokens = (span.input_tokens ?? 0) > 0 || (span.output_tokens ?? 0) > 0
+ const subtitle = span.span_type === 'llm_call'
+ ? [span.model, span.provider].filter(Boolean).join(' / ')
+ : span.span_type === 'tool_call' ? span.tool_name : undefined
+ const cacheRead = (span.metadata?.cache_read_tokens as number) ?? 0
+ const cacheCreate = (span.metadata?.cache_creation_tokens as number) ?? 0
+ const thinkingTokens = (span.metadata?.thinking_tokens as number) ?? 0
+ // Expandable only if there's content to show
+ const hasExpandContent = !!span.input_preview || !!span.output_preview || !!span.error || cacheCreate > 0
+
+ return (
+
+ {/* Row */}
+
+ {/* Toggle arrow — only visible if expandable */}
+
+ {spanTypeIcon(span.span_type)}
+ {span.name}
+ {subtitle && {subtitle}}
+
+ {hasTokens && (
+
+ {formatTokens(span.input_tokens)}/{formatTokens(span.output_tokens)}
+ {cacheRead > 0 && ({formatTokens(cacheRead)} cached)}
+ {thinkingTokens > 0 && ({formatTokens(thinkingTokens)} thinking)}
+
+ )}
+ {formatDuration(span.duration_ms, span.start_time, span.end_time)}
+
+ {span.status}
+
+
+
+
+ {/* Expanded details — only input/output/error, no duplicate metadata */}
+ {expanded && hasExpandContent && (
+
+ {/* Cache breakdown (only shows extra info not in collapsed row) */}
+ {cacheCreate > 0 && (
+
+ +{formatTokens(cacheCreate)} cache write
+
+ )}
+ {span.error &&
{span.error}
}
+ {span.input_preview && (
+
+
{t('detail.input')}
+
+
+ )}
+ {span.output_preview && (
+
+
{t('detail.output')}
+
+
+ )}
+
+ )}
+
+ )
+}
+
+export function TraceDetailDialog({ traceId, onClose }: Props) {
+ const { t } = useTranslation('traces')
+ const [trace, setTrace] = useState(null)
+ const [spans, setSpans] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [expandedSpans, setExpandedSpans] = useState>(new Set())
+ const [inputOpen, setInputOpen] = useState(false)
+ const [outputOpen, setOutputOpen] = useState(false)
+ const [copied, setCopied] = useState(false)
+
+ const handleCopy = useCallback(() => {
+ if (!trace) return
+ navigator.clipboard.writeText(trace.id)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }, [trace])
+
+ const handleExport = useCallback(async () => {
+ if (!trace || !isApiClientReady()) return
+ try {
+ const exportUrl = `${getApiClient().getBaseUrl()}/v1/traces/${trace.id}/export`
+ const filename = `trace-${(trace.name || trace.id.slice(0, 8)).replace(/[^a-zA-Z0-9_-]/g, '_')}.json.gz`
+ await DownloadURL(exportUrl, filename)
+ toast.success(t('detail.exported'))
+ } catch (err) {
+ toast.error('Export failed', (err as Error).message)
+ }
+ }, [trace, t])
+
+ useEffect(() => {
+ setLoading(true)
+ fetchTraceDetail(traceId)
+ .then(({ trace: t2, spans: s }) => {
+ setTrace(t2)
+ setSpans(s)
+ // Auto-expand root spans
+ const roots = new Set()
+ for (const sp of s) {
+ if (!sp.parent_span_id) roots.add(sp.id)
+ }
+ setExpandedSpans(roots)
+ })
+ .catch((err) => {
+ console.error('Failed to load trace detail:', err)
+ toast.error('Failed to load trace', (err as Error).message)
+ })
+ .finally(() => setLoading(false))
+ }, [traceId])
+
+ const spanTree = useMemo(() => buildSpanTree(spans), [spans])
+ const cacheRead = trace?.metadata?.total_cache_read_tokens as number | undefined
+
+ const toggleSpan = (id: string) => {
+ setExpandedSpans((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {trace?.name || t('unnamed')}
+ {trace && (
+
+ {trace.status}
+
+ )}
+
+
+ {trace && (
+ <>
+ {/* Copy trace ID */}
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ {/* Export trace */}
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ {/* Content */}
+
+ {loading ? (
+
+ ) : !trace ? (
+
{t('detail.notFound')}
+ ) : (
+
+ {/* Metadata */}
+
+ {t('detail.duration')} {formatDuration(trace.duration_ms, trace.start_time, trace.end_time)}
+ {trace.channel && (
+ {trace.channel}
+ )}
+
+ {t('detail.tokens')}{' '}
+ {formatTokens(trace.total_input_tokens)} in / {formatTokens(trace.total_output_tokens)} out
+ {(cacheRead ?? 0) > 0 && (
+ +{formatTokens(cacheRead)} cached
+ )}
+
+ {t('detail.spans')} {trace.span_count}
+
+ {t('detail.started')}{' '}
+ {new Date(trace.start_time).toLocaleString()}
+
+ {trace.parent_trace_id && (
+
+ {t('detail.delegatedFrom')}{' '}
+ {trace.parent_trace_id.slice(0, 8)}…
+
+ )}
+
+
+ {/* Trace-level Input/Output */}
+ {(trace.input_preview || trace.output_preview) && (
+
+ {trace.input_preview && (
+
+
setInputOpen((v) => !v)}
+ className="flex items-center gap-1.5 text-xs font-medium text-text-secondary hover:text-text-primary transition-colors cursor-pointer"
+ >
+
+ {t('detail.input')}
+
+ {inputOpen &&
}
+
+ )}
+ {trace.output_preview && (
+
+
setOutputOpen((v) => !v)}
+ className="flex items-center gap-1.5 text-xs font-medium text-text-secondary hover:text-text-primary transition-colors cursor-pointer"
+ >
+
+ {t('detail.output')}
+
+ {outputOpen &&
}
+
+ )}
+
+ )}
+
+ {/* Span tree */}
+ {spanTree.length > 0 && (
+
+
+ {t('detail.spansCount', { count: spans.length })}
+
+
+ {spanTree.map((node) => (
+ toggleSpan(node.span.id)}
+ />
+ ))}
+
+
+ )}
+
+ )}
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/traces/TraceList.tsx b/ui/desktop/frontend/src/components/settings/traces/TraceList.tsx
new file mode 100644
index 00000000..929c4fcf
--- /dev/null
+++ b/ui/desktop/frontend/src/components/settings/traces/TraceList.tsx
@@ -0,0 +1,224 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useTraces } from '../../../hooks/use-traces'
+import { useAgentCrud } from '../../../hooks/use-agent-crud'
+import { RefreshButton } from '../../common/RefreshButton'
+import { Combobox } from '../../common/Combobox'
+import { TraceDetailDialog } from './TraceDetailDialog'
+import type { TraceData } from '../../../types/trace'
+
+function formatDuration(ms: number | undefined | null, startTime?: string, endTime?: string): string {
+ if (ms == null || isNaN(ms) || ms === 0) {
+ if (startTime && endTime) {
+ const computed = new Date(endTime).getTime() - new Date(startTime).getTime()
+ if (!isNaN(computed) && computed > 0) ms = computed
+ else return '—'
+ } else {
+ return '—'
+ }
+ }
+ if (ms < 1000) return `${ms}ms`
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
+ const min = Math.floor(ms / 60000)
+ const sec = Math.round((ms % 60000) / 1000)
+ return `${min}m ${sec}s`
+}
+
+function formatTokens(count: number | null | undefined): string {
+ if (count == null) return '0'
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`
+ return count.toString()
+}
+
+function formatRelativeTime(dateStr: string): string {
+ const diff = Date.now() - new Date(dateStr).getTime()
+ if (diff < 60000) return 'just now'
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
+ return `${Math.floor(diff / 86400000)}d ago`
+}
+
+function statusClass(status: string): string {
+ const s = status.toLowerCase()
+ if (s === 'completed' || s === 'ok' || s === 'success') {
+ return 'bg-emerald-500/15 text-emerald-700 border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
+ }
+ if (s === 'error' || s === 'failed') {
+ return 'bg-red-500/15 text-red-700 border-red-500/25 dark:text-red-400 dark:bg-red-500/10 dark:border-red-500/20'
+ }
+ return 'bg-blue-500/15 text-blue-700 border-blue-500/25 dark:text-blue-400 dark:bg-blue-500/10 dark:border-blue-500/20'
+}
+
+function TraceRow({ trace, onClick }: { trace: TraceData; onClick: () => void }) {
+ const { t } = useTranslation('traces')
+ const cacheRead = trace.metadata?.total_cache_read_tokens as number | undefined
+ const nameDisplay = trace.name.length > 30 ? trace.name.slice(0, 30) + '…' : trace.name
+
+ return (
+
+ {/* Name */}
+ |
+
+ {nameDisplay || t('unnamed')}
+ {trace.parent_trace_id && (
+ 🔀
+ )}
+ {trace.channel && (
+
+ {trace.channel}
+
+ )}
+
+ |
+
+ {/* Status */}
+
+
+ {trace.status}
+
+ |
+
+ {/* Duration */}
+
+ {formatDuration(trace.duration_ms, trace.start_time, trace.end_time)}
+ |
+
+ {/* Tokens */}
+
+
+ {formatTokens(trace.total_input_tokens)} / {formatTokens(trace.total_output_tokens)}
+
+ {(cacheRead ?? 0) > 0 && (
+
+ {formatTokens(cacheRead)} {t('cached')}
+
+ )}
+ |
+
+ {/* Spans */}
+
+ {trace.span_count}
+ |
+
+ {/* Time */}
+
+ {formatRelativeTime(trace.created_at)}
+ |
+
+ )
+}
+
+export function TraceList() {
+ const { t } = useTranslation('traces')
+ const { traces, total, loading, fetchTraces, agentFilter, setAgentFilter, loadMore } = useTraces()
+ const { agents } = useAgentCrud()
+ const [selectedTraceId, setSelectedTraceId] = useState(null)
+
+ const agentOptions = [
+ { value: '', label: t('allAgents') },
+ ...agents.map((a) => ({ value: a.id, label: a.display_name || a.agent_key })),
+ ]
+
+ const allLoaded = traces.length >= total && total > 0
+
+ return (
+
+ {/* Header */}
+
+
+
{t('title')}
+
{t('description')}
+
+
+
+
+ {/* Loading */}
+ {loading && traces.length === 0 ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : traces.length === 0 ? (
+
+
+
{t('emptyTitle')}
+
{t('emptyDescription')}
+
+ ) : (
+ <>
+
+
+
+
+ | {t('columns.name')} |
+ {t('columns.status')} |
+ {t('columns.duration')} |
+ {t('columns.tokens')} |
+ {t('columns.spans')} |
+ {t('columns.time')} |
+
+
+
+ {traces.map((trace) => (
+ setSelectedTraceId(trace.id)}
+ />
+ ))}
+
+
+
+
+ {/* Load more */}
+ {!allLoaded && (
+
+
+ {loading ? (
+
+ ) : (
+
+ )}
+ Load more ({traces.length}/{total})
+
+
+ )}
+ >
+ )}
+
+ {/* Detail dialog */}
+ {selectedTraceId && (
+
setSelectedTraceId(null)}
+ />
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/teams/KanbanCard.tsx b/ui/desktop/frontend/src/components/teams/KanbanCard.tsx
new file mode 100644
index 00000000..f004d4b9
--- /dev/null
+++ b/ui/desktop/frontend/src/components/teams/KanbanCard.tsx
@@ -0,0 +1,95 @@
+import { motion } from 'framer-motion'
+import type { TeamTaskData } from '../../types/team'
+import { isTaskLocked } from '../../types/team'
+
+/** Priority: plain text color, matching web kanban-card.tsx */
+const PRIORITY_STYLE: Record = {
+ 0: { label: 'P-0', color: 'text-slate-400' },
+ 1: { label: 'P-1', color: 'text-blue-500' },
+ 2: { label: 'P-2', color: 'text-amber-500' },
+ 3: { label: 'P-3', color: 'text-red-500' },
+}
+
+const TERMINAL = new Set(['completed', 'failed', 'cancelled'])
+
+interface KanbanCardProps {
+ task: TeamTaskData
+ ownerName?: string
+ ownerEmoji?: string
+ onClick: () => void
+}
+
+export function KanbanCard({ task, ownerName, ownerEmoji, onClick }: KanbanCardProps) {
+ const locked = isTaskLocked(task)
+ const blocked = task.status === 'blocked'
+ const prio = PRIORITY_STYLE[task.priority] ?? PRIORITY_STYLE[0]
+ const hasBlockers = task.blocked_by && task.blocked_by.length > 0
+ const isTerminal = TERMINAL.has(task.status)
+
+ return (
+
+ {/* Top row: identifier + priority + running */}
+
+
+
+ {task.identifier || `#${task.task_number ?? ''}`}
+
+
+ {prio.label}
+
+
+ {locked && (
+
+
+ Running
+
+ )}
+
+
+ {/* Subject */}
+
+ {task.subject}
+
+
+ {/* Blocked indicator */}
+ {hasBlockers && (
+
+
+
+ {task.blocked_by!.map((id) => id.slice(0, 8)).join(', ')}
+
+
+ )}
+
+ {/* Bottom: owner */}
+
+ {ownerEmoji && {ownerEmoji}}
+
+ {ownerName || task.owner_agent_key || 'Unassigned'}
+
+
+
+ {/* Progress bar */}
+ {task.progress_percent != null && task.progress_percent > 0 && !isTerminal && (
+
+
+
{task.progress_percent}%
+
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/teams/KanbanColumn.tsx b/ui/desktop/frontend/src/components/teams/KanbanColumn.tsx
new file mode 100644
index 00000000..3a745260
--- /dev/null
+++ b/ui/desktop/frontend/src/components/teams/KanbanColumn.tsx
@@ -0,0 +1,63 @@
+import { AnimatePresence, LayoutGroup } from 'framer-motion'
+import type { TaskStatus, TeamTaskData, TeamMemberData } from '../../types/team'
+import { STATUS_COLORS } from '../../types/team'
+import { KanbanCard } from './KanbanCard'
+
+const STATUS_LABELS: Record = {
+ pending: 'Pending',
+ blocked: 'Blocked',
+ in_progress: 'In Progress',
+ completed: 'Completed',
+ failed: 'Failed',
+ cancelled: 'Cancelled',
+}
+
+interface KanbanColumnProps {
+ status: TaskStatus
+ tasks: TeamTaskData[]
+ members: TeamMemberData[]
+ onTaskClick: (task: TeamTaskData) => void
+}
+
+export function KanbanColumn({ status, tasks, members, onTaskClick }: KanbanColumnProps) {
+ const memberMap = new Map(members.map((m) => [m.agent_id, m]))
+
+ return (
+
+ {/* Column header */}
+
+
+
{STATUS_LABELS[status]}
+
+ {tasks.length}
+
+
+
+ {/* Card list */}
+
+ {tasks.length === 0 ? (
+
+ No tasks
+
+ ) : (
+
+
+ {tasks.map((task) => {
+ const member = task.owner_agent_id ? memberMap.get(task.owner_agent_id) : undefined
+ return (
+ onTaskClick(task)}
+ />
+ )
+ })}
+
+
+ )}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/teams/TaskCreateDialog.tsx b/ui/desktop/frontend/src/components/teams/TaskCreateDialog.tsx
new file mode 100644
index 00000000..3a508d4c
--- /dev/null
+++ b/ui/desktop/frontend/src/components/teams/TaskCreateDialog.tsx
@@ -0,0 +1,133 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Combobox } from '../common/Combobox'
+import type { TeamMemberData } from '../../types/team'
+
+interface TaskCreateDialogProps {
+ teamId: string
+ members: TeamMemberData[]
+ onClose: () => void
+ onCreate: (teamId: string, params: { subject: string; description?: string; priority?: number; assignee?: string }) => Promise
+}
+
+const PRIORITIES = [
+ { value: 0, label: 'P-0 Critical' },
+ { value: 1, label: 'P-1 High' },
+ { value: 2, label: 'P-2 Medium' },
+ { value: 3, label: 'P-3 Low' },
+]
+
+export function TaskCreateDialog({ teamId, members, onClose, onCreate }: TaskCreateDialogProps) {
+ const { t } = useTranslation('teams')
+ const [subject, setSubject] = useState('')
+ const [description, setDescription] = useState('')
+ const [priority, setPriority] = useState(2)
+ const [assignee, setAssignee] = useState('')
+ const [saving, setSaving] = useState(false)
+
+ const memberOptions = members.map((m) => ({
+ value: m.agent_key || m.agent_id,
+ label: `${m.emoji || ''} ${m.display_name || m.agent_key || m.agent_id}`.trim(),
+ }))
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!subject.trim()) return
+ setSaving(true)
+ try {
+ await onCreate(teamId, {
+ subject: subject.trim(),
+ description: description.trim() || undefined,
+ priority,
+ assignee: assignee || undefined,
+ })
+ onClose()
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
e.stopPropagation()}
+ className="bg-surface-primary border border-border rounded-xl shadow-xl w-full max-w-md mx-4"
+ >
+
+
{t('createTask', 'Create Task')}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/teams/TaskDetailModal.tsx b/ui/desktop/frontend/src/components/teams/TaskDetailModal.tsx
new file mode 100644
index 00000000..23f4751b
--- /dev/null
+++ b/ui/desktop/frontend/src/components/teams/TaskDetailModal.tsx
@@ -0,0 +1,244 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { MarkdownRenderer } from '../chat/MarkdownRenderer'
+import { Combobox } from '../common/Combobox'
+import { ConfirmDialog } from '../common/ConfirmDialog'
+import { STATUS_BADGE, PRIORITY_BADGE, isTaskLocked } from '../../types/team'
+import type { TeamTaskData, TeamMemberData, TaskStatus } from '../../types/team'
+
+interface TaskDetailModalProps {
+ task: TeamTaskData
+ members: TeamMemberData[]
+ onClose: () => void
+ onAssign: (taskId: string, agentKey: string) => Promise
+ onDelete: (taskId: string) => Promise
+}
+
+const TERMINAL: Set = new Set(['completed', 'failed', 'cancelled'])
+
+/** Metadata label + value pair */
+function MetaItem({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
{label}
+ {children}
+
+ )
+}
+
+/** Collapsible bordered section — matches web UI TaskDetailContent pattern */
+function CollapsibleSection({ title, icon, defaultOpen = true, children }: {
+ title: string; icon: React.ReactNode; defaultOpen?: boolean; children: React.ReactNode
+}) {
+ const [open, setOpen] = useState(defaultOpen)
+ return (
+
+
setOpen((v) => !v)}
+ className="flex w-full items-center gap-2 px-4 py-3 text-sm font-medium text-text-muted hover:text-text-primary transition-colors cursor-pointer"
+ >
+ {icon}
+ {title}
+
+
+ {open && (
+
+ {children}
+
+ )}
+
+ )
+}
+
+export function TaskDetailModal({ task, members, onClose, onAssign, onDelete }: TaskDetailModalProps) {
+ const { t } = useTranslation('teams')
+ const [confirmDelete, setConfirmDelete] = useState(false)
+
+ const prio = PRIORITY_BADGE[task.priority] ?? PRIORITY_BADGE[3]
+ const statusCls = STATUS_BADGE[task.status] ?? ''
+ const locked = isTaskLocked(task)
+ const isTerminal = TERMINAL.has(task.status)
+ const member = task.owner_agent_id ? members.find((m) => m.agent_id === task.owner_agent_id) : undefined
+
+ const memberOptions = members.map((m) => ({
+ value: m.agent_key || m.agent_id,
+ label: `${m.emoji || ''} ${m.display_name || m.agent_key || m.agent_id}`.trim(),
+ }))
+
+ const handleDelete = async () => {
+ try {
+ await onDelete(task.id)
+ onClose()
+ } finally {
+ setConfirmDelete(false)
+ }
+ }
+
+ return (
+
+
e.stopPropagation()}
+ className="bg-surface-primary border border-border rounded-xl shadow-xl w-[95vw] max-w-4xl max-h-[85vh] flex flex-col mx-4"
+ >
+ {/* ── Header: badges + title ── */}
+
+
+
+
+ {task.identifier && (
+
+ {task.identifier}
+
+ )}
+
+ {task.status.replace(/_/g, ' ')}
+
+ {locked && (
+
+ Running
+
+ )}
+
+
{task.subject}
+
+
+
+
+
+
+
+ {/* ── Scrollable body ── */}
+
+
+ {/* Progress bar */}
+ {task.progress_percent != null && task.progress_percent > 0 && !isTerminal && (() => {
+ const pct = Math.min(100, Math.max(0, task.progress_percent))
+ return (
+
+
+ {t('progress', 'Progress')}
+ {pct}%
+
+
+ {task.progress_step &&
{task.progress_step}
}
+
+ )
+ })()}
+
+ {/* Metadata grid */}
+
+
+
+ {prio.label}
+
+
+
+ {member ? (
+
+ {member.emoji && {member.emoji}}
+ {member.display_name || member.agent_key}
+
+ ) : task.owner_agent_key || '—'}
+
+ {task.task_type && task.task_type !== 'general' && (
+
+ {task.task_type}
+
+ )}
+ {task.created_at && (
+
+ {new Date(task.created_at).toLocaleString()}
+
+ )}
+ {task.updated_at && (
+
+ {new Date(task.updated_at).toLocaleString()}
+
+ )}
+
+
+ {/* Blocked by */}
+ {task.blocked_by && task.blocked_by.length > 0 && (
+
+
{t('blockedBy', 'Blocked by')}
+
+ {task.blocked_by.map((id) => (
+
+ {id.slice(0, 8)}
+
+ ))}
+
+
+ )}
+
+ {/* Description section */}
+ {task.description && (
+
}
+ >
+
+
+
+
+ )}
+
+ {/* Result section */}
+ {task.result && (
+
}
+ >
+
+
+
+
+ )}
+
+
+ {/* ── Footer ── */}
+
+ {!isTerminal && (
+
+ onAssign(task.id, key)}
+ placeholder={t('assignTo', 'Assign to...')}
+ />
+
+ )}
+
+ {isTerminal && (
+
setConfirmDelete(true)}
+ className="flex items-center gap-1.5 text-sm text-error hover:text-error/80 px-4 py-2 rounded-lg border border-error/30 hover:bg-error/10 transition-colors cursor-pointer"
+ >
+
+ {t('delete', 'Delete')}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/teams/TeamBoard.tsx b/ui/desktop/frontend/src/components/teams/TeamBoard.tsx
new file mode 100644
index 00000000..5066a874
--- /dev/null
+++ b/ui/desktop/frontend/src/components/teams/TeamBoard.tsx
@@ -0,0 +1,243 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useUiStore } from '../../stores/ui-store'
+import { useTeamTasks } from '../../hooks/use-team-tasks'
+import { RefreshButton } from '../common/RefreshButton'
+import { KanbanColumn } from './KanbanColumn'
+import { TaskDetailModal } from './TaskDetailModal'
+import { KANBAN_STATUSES, groupByStatus } from '../../types/team'
+import type { TeamTaskData, TaskStatus } from '../../types/team'
+
+type ViewMode = 'kanban' | 'list'
+const FILTER_OPTIONS = [
+ { value: '', label: 'allStatuses' },
+ { value: 'active', label: 'active' },
+ { value: 'completed', label: 'completed' },
+] as const
+
+export function TeamBoard() {
+ const { t } = useTranslation('teams')
+ const activeTeamId = useUiStore((s) => s.activeTeamId)
+ const closeSettings = useUiStore((s) => s.closeSettings)
+ const { teams, tasks, members, loading, fetchTeams, fetchTasks, assignTask, deleteTask, deleteBulk } = useTeamTasks()
+
+ const [viewMode, setViewMode] = useState('kanban')
+ const [statusFilter, setStatusFilter] = useState('')
+ const [filterOpen, setFilterOpen] = useState(false)
+ const [selectedTask, setSelectedTask] = useState(null)
+ const [selected, setSelected] = useState>(new Set())
+
+ const team = teams.find((t) => t.id === activeTeamId)
+
+ useEffect(() => { fetchTeams() }, [fetchTeams])
+
+ useEffect(() => {
+ if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined)
+ }, [activeTeamId, statusFilter, fetchTasks])
+
+ const grouped = useMemo(() => groupByStatus(tasks), [tasks])
+
+ const handleRefresh = () => {
+ if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined)
+ }
+
+ // Keep selected task in sync with tasks
+ useEffect(() => {
+ if (selectedTask) {
+ const updated = tasks.find((t) => t.id === selectedTask.id)
+ if (updated) setSelectedTask(updated)
+ }
+ }, [tasks, selectedTask])
+
+ const terminalStatuses = new Set(['completed', 'failed', 'cancelled'])
+
+ const handleBulkDelete = async () => {
+ if (selected.size === 0) return
+ await deleteBulk(Array.from(selected))
+ setSelected(new Set())
+ }
+
+ const currentFilterLabel = FILTER_OPTIONS.find((o) => o.value === statusFilter)?.label || 'allStatuses'
+
+ if (!activeTeamId) {
+ return (
+
+ {t('selectTeam', 'Select a team from the sidebar')}
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+ {team?.name || t('team', 'Team')}
+
+
+
+
+ {/* View toggle */}
+
+ setViewMode('kanban')}
+ className={`text-[10px] px-2 py-1 rounded cursor-pointer ${viewMode === 'kanban' ? 'bg-surface-primary text-text-primary shadow-sm' : 'text-text-muted'}`}
+ >
+ {t('kanban', 'Kanban')}
+
+ setViewMode('list')}
+ className={`text-[10px] px-2 py-1 rounded cursor-pointer ${viewMode === 'list' ? 'bg-surface-primary text-text-primary shadow-sm' : 'text-text-muted'}`}
+ >
+ {t('list', 'List')}
+
+
+
+ {/* Custom status filter dropdown */}
+
+
setFilterOpen((v) => !v)}
+ className="flex items-center gap-1.5 text-[11px] bg-surface-tertiary border border-border rounded-lg px-2.5 py-1.5 text-text-secondary hover:border-accent/30 transition-colors cursor-pointer"
+ >
+ {t(currentFilterLabel)}
+
+
+ {filterOpen && (
+ <>
+
setFilterOpen(false)} />
+
+ {FILTER_OPTIONS.map((opt) => (
+ { setStatusFilter(opt.value); setFilterOpen(false) }}
+ className={[
+ 'w-full text-left px-3 py-1.5 text-[11px] transition-colors cursor-pointer',
+ statusFilter === opt.value ? 'text-accent bg-accent/10' : 'text-text-secondary hover:bg-surface-tertiary',
+ ].join(' ')}
+ >
+ {t(opt.label)}
+
+ ))}
+
+ >
+ )}
+
+
+
{ handleRefresh() }} />
+
+
+ {/* Kanban view */}
+ {viewMode === 'kanban' ? (
+
+
+ {KANBAN_STATUSES.map((status) => (
+
+ ))}
+
+
+ ) : (
+ /* List view */
+
+ {selected.size > 0 && (
+
+ {selected.size} selected
+
+ {t('deleteSelected', 'Delete selected')}
+
+
+ )}
+
+ {tasks.length === 0 && !loading && (
+
{t('noTasks', 'No tasks yet')}
+ )}
+
+ )}
+
+ {/* Task detail modal */}
+ {selectedTask && (
+
setSelectedTask(null)}
+ onAssign={assignTask}
+ onDelete={deleteTask}
+ />
+ )}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/teams/TeamCreateDialog.tsx b/ui/desktop/frontend/src/components/teams/TeamCreateDialog.tsx
new file mode 100644
index 00000000..a1f33ca8
--- /dev/null
+++ b/ui/desktop/frontend/src/components/teams/TeamCreateDialog.tsx
@@ -0,0 +1,167 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { getWsClient } from '../../lib/ws'
+import { toast } from '../../stores/toast-store'
+import { Combobox } from '../common/Combobox'
+import type { TeamData } from '../../types/team'
+
+interface Agent {
+ id: string
+ key: string
+ name: string
+ emoji?: string
+}
+
+interface TeamCreateDialogProps {
+ agents: Agent[]
+ onClose: () => void
+ onCreated: (team: TeamData) => void
+}
+
+export function TeamCreateDialog({ agents, onClose, onCreated }: TeamCreateDialogProps) {
+ const { t } = useTranslation('teams')
+ const [name, setName] = useState('')
+ const [lead, setLead] = useState('')
+ const [memberKeys, setMemberKeys] = useState
([])
+ const [saving, setSaving] = useState(false)
+
+ const agentOptions = agents.map((a) => ({ value: a.key, label: `${a.emoji || ''} ${a.name}`.trim() }))
+ const memberOptions = agentOptions.filter((o) => o.value !== lead)
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!name.trim() || !lead) return
+ setSaving(true)
+ try {
+ const ws = getWsClient()
+ const res = await ws.call('teams.create', {
+ name: name.trim(),
+ lead,
+ members: memberKeys.filter((k) => k !== lead),
+ }) as { team: TeamData }
+ toast.success(t('teamCreated', 'Team created'), name.trim())
+ onCreated(res.team)
+ onClose()
+ } catch (err) {
+ toast.error(t('createFailed', 'Failed to create team'), (err as Error).message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const toggleMember = (key: string) => {
+ setMemberKeys((prev) =>
+ prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
+ )
+ }
+
+ return (
+
+
e.stopPropagation()}
+ className="bg-surface-primary border border-border rounded-xl shadow-xl w-full max-w-sm mx-4"
+ >
+
+
{t('createTeam', 'Create Team')}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/constants/providers.ts b/ui/desktop/frontend/src/constants/providers.ts
new file mode 100644
index 00000000..61ff1732
--- /dev/null
+++ b/ui/desktop/frontend/src/constants/providers.ts
@@ -0,0 +1,33 @@
+export interface ProviderTypeInfo {
+ value: string
+ label: string
+ apiBase: string
+ needsKey: boolean
+}
+
+export const PROVIDER_TYPES: ProviderTypeInfo[] = [
+ { value: 'anthropic_native', label: 'Anthropic (Native)', apiBase: '', needsKey: true },
+ { value: 'openai_compat', label: 'OpenAI Compatible', apiBase: '', needsKey: true },
+ { value: 'gemini_native', label: 'Google Gemini', apiBase: 'https://generativelanguage.googleapis.com/v1beta/openai', needsKey: true },
+ { value: 'openrouter', label: 'OpenRouter', apiBase: 'https://openrouter.ai/api/v1', needsKey: true },
+ { value: 'groq', label: 'Groq', apiBase: 'https://api.groq.com/openai/v1', needsKey: true },
+ { value: 'deepseek', label: 'DeepSeek', apiBase: 'https://api.deepseek.com/v1', needsKey: true },
+ { value: 'mistral', label: 'Mistral AI', apiBase: 'https://api.mistral.ai/v1', needsKey: true },
+ { value: 'xai', label: 'xAI (Grok)', apiBase: 'https://api.x.ai/v1', needsKey: true },
+ { value: 'minimax_native', label: 'MiniMax (Native)', apiBase: 'https://api.minimax.io/v1', needsKey: true },
+ { value: 'cohere', label: 'Cohere', apiBase: 'https://api.cohere.ai/compatibility/v1', needsKey: true },
+ { value: 'perplexity', label: 'Perplexity', apiBase: 'https://api.perplexity.ai', needsKey: true },
+ { value: 'dashscope', label: 'DashScope (Qwen)', apiBase: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', needsKey: true },
+ { value: 'bailian', label: 'Bailian Coding', apiBase: 'https://coding-intl.dashscope.aliyuncs.com/v1', needsKey: true },
+ { value: 'yescale', label: 'YesScale', apiBase: 'https://api.yescale.one/v1', needsKey: true },
+ { value: 'zai', label: 'Z.ai API', apiBase: 'https://api.z.ai/api/paas/v4', needsKey: true },
+ { value: 'zai_coding', label: 'Z.ai Coding Plan', apiBase: 'https://api.z.ai/api/coding/paas/v4', needsKey: true },
+ { value: 'ollama', label: 'Ollama (Local)', apiBase: 'http://localhost:11434/v1', needsKey: false },
+ { value: 'ollama_cloud', label: 'Ollama Cloud', apiBase: 'https://ollama.com/v1', needsKey: true },
+ { value: 'claude_cli', label: 'Claude CLI (Local)', apiBase: '', needsKey: false },
+ { value: 'acp', label: 'ACP Agent (Subprocess)', apiBase: '', needsKey: false },
+]
+
+export function slugify(text: string): string {
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
+}
diff --git a/ui/desktop/frontend/src/hooks/use-agent-crud.ts b/ui/desktop/frontend/src/hooks/use-agent-crud.ts
new file mode 100644
index 00000000..e8bf7cda
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-agent-crud.ts
@@ -0,0 +1,69 @@
+import { useState, useEffect, useCallback } from 'react'
+import { getApiClient } from '../lib/api'
+import { toast } from '../stores/toast-store'
+import type { AgentData, AgentInput } from '../types/agent'
+
+const MAX_AGENTS_LITE = 5
+
+export function useAgentCrud() {
+ const [agents, setAgents] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const fetchAgents = useCallback(async () => {
+ try {
+ const res = await getApiClient().get<{ agents: AgentData[] | null }>('/v1/agents')
+ setAgents(res.agents ?? [])
+ } catch (err) {
+ console.error('Failed to fetch agents:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { fetchAgents() }, [fetchAgents])
+
+ const createAgent = useCallback(async (input: AgentInput) => {
+ try {
+ const res = await getApiClient().post('/v1/agents', input)
+ setAgents((prev) => [...prev, res])
+ toast.success('Agent created')
+ return res
+ } catch (err) {
+ toast.error('Failed to create agent', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const updateAgent = useCallback(async (id: string, input: Partial) => {
+ try {
+ const res = await getApiClient().put(`/v1/agents/${id}`, input)
+ setAgents((prev) => prev.map((a) => a.id === id ? res : a))
+ toast.success('Agent updated')
+ return res
+ } catch (err) {
+ toast.error('Failed to update agent', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const deleteAgent = useCallback(async (id: string) => {
+ try {
+ await getApiClient().delete(`/v1/agents/${id}`)
+ setAgents((prev) => prev.filter((a) => a.id !== id))
+ toast.success('Agent deleted')
+ } catch (err) {
+ toast.error('Failed to delete agent', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const resummonAgent = useCallback(async (id: string) => {
+ await getApiClient().post(`/v1/agents/${id}/resummon`, {})
+ // Update status to summoning
+ setAgents((prev) => prev.map((a) => a.id === id ? { ...a, status: 'summoning' } : a))
+ }, [])
+
+ const atLimit = agents.length >= MAX_AGENTS_LITE
+
+ return { agents, loading, atLimit, fetchAgents, createAgent, updateAgent, deleteAgent, resummonAgent }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-agents.ts b/ui/desktop/frontend/src/hooks/use-agents.ts
new file mode 100644
index 00000000..f068c186
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-agents.ts
@@ -0,0 +1,70 @@
+import { useEffect, useCallback } from 'react'
+import { getApiClient } from '../lib/api'
+import { useAgentStore } from '../stores/agent-store'
+import type { Agent } from '../stores/agent-store'
+
+// Module-level flag: prevents re-fetching agents on every component mount.
+// Multiple components call useAgents() — only the first triggers the fetch.
+let didFetchAgents = false
+
+export function useAgents() {
+ const { agents, selectedAgentId, setAgents, selectAgent } = useAgentStore()
+ const api = getApiClient()
+
+ const fetchAgents = useCallback(async () => {
+ if (!api) return
+ try {
+ const result = await api.get<{ agents: Array<{
+ id: string
+ agent_key: string
+ display_name?: string
+ model?: string
+ provider?: string
+ other_config?: Record | null
+ }> }>('/v1/agents')
+
+ const mapped: Agent[] = (result.agents ?? []).map((a) => {
+ const otherCfg = a.other_config ?? {}
+ return {
+ id: a.id,
+ key: a.agent_key,
+ name: a.display_name || a.agent_key,
+ model: a.model ?? 'unknown',
+ status: 'online' as const,
+ emoji: typeof otherCfg.emoji === 'string' ? otherCfg.emoji : undefined,
+ }
+ })
+
+ setAgents(mapped)
+ return mapped
+ } catch (err) {
+ console.error('Failed to fetch agents:', err)
+ return []
+ }
+ }, [api, setAgents])
+
+ // Fetch once globally, auto-select first agent only if none selected
+ useEffect(() => {
+ if (didFetchAgents) return
+ didFetchAgents = true
+ fetchAgents().then((mapped) => {
+ // Only auto-select if no agent is currently selected (survives remounts)
+ if (!useAgentStore.getState().selectedAgentId && mapped && mapped.length > 0) {
+ selectAgent(mapped[0].id)
+ }
+ })
+ }, [fetchAgents, selectAgent])
+
+ const selectedAgent = agents.find((a) => a.id === selectedAgentId) ?? null
+
+ return {
+ agents,
+ selectedAgent,
+ selectedAgentId,
+ selectAgent,
+ refreshAgents: () => {
+ didFetchAgents = false // allow re-fetch
+ return fetchAgents()
+ },
+ }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-bootstrap-status.ts b/ui/desktop/frontend/src/hooks/use-bootstrap-status.ts
new file mode 100644
index 00000000..49a92f9f
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-bootstrap-status.ts
@@ -0,0 +1,43 @@
+import { useState, useEffect, useMemo } from 'react'
+import { getApiClient } from '../lib/api'
+import type { ProviderData } from '../types/provider'
+import type { AgentData } from '../types/agent'
+
+export type SetupStep = 1 | 2 | 3 | 'complete'
+
+// Desktop is single-tenant — any provider counts as configured
+function isConfigured(p: ProviderData): boolean {
+ return p.enabled
+}
+
+export function useBootstrapStatus() {
+ const [providers, setProviders] = useState([])
+ const [agents, setAgents] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ const doFetch = async () => {
+ const api = getApiClient()
+ // Fetch independently — one failing shouldn't block the other
+ const [pRes, aRes] = await Promise.allSettled([
+ api.get<{ providers?: ProviderData[] | null }>('/v1/providers'),
+ api.get<{ agents?: AgentData[] | null }>('/v1/agents'),
+ ])
+ if (pRes.status === 'fulfilled') setProviders(pRes.value.providers ?? [])
+ if (aRes.status === 'fulfilled') setAgents(aRes.value.agents ?? [])
+ setLoading(false)
+ }
+ doFetch()
+ }, [])
+
+ const currentStep = useMemo(() => {
+ if (loading) return 1
+ const hasProvider = providers.some(isConfigured)
+ const hasAgent = agents.length > 0
+ if (!hasProvider) return 1
+ if (!hasAgent) return 2
+ return 'complete'
+ }, [loading, providers, agents])
+
+ return { currentStep, loading, providers, agents }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-builtin-tools.ts b/ui/desktop/frontend/src/hooks/use-builtin-tools.ts
new file mode 100644
index 00000000..e68650f9
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-builtin-tools.ts
@@ -0,0 +1,42 @@
+import { useState, useEffect, useCallback } from 'react'
+import { getApiClient } from '../lib/api'
+import { toast } from '../stores/toast-store'
+import type { BuiltinToolData } from '../types/builtin-tool'
+
+export function useBuiltinTools() {
+ const [tools, setTools] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const fetchTools = useCallback(async () => {
+ try {
+ const res = await getApiClient().get<{ tools: BuiltinToolData[] | null }>('/v1/tools/builtin')
+ setTools(res.tools ?? [])
+ } catch (err) {
+ console.error('Failed to fetch builtin tools:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { fetchTools() }, [fetchTools])
+
+ const toggleTool = useCallback(async (name: string, enabled: boolean) => {
+ // Optimistic update
+ setTools((prev) => prev.map((t) => t.name === name ? { ...t, enabled } : t))
+ try {
+ await getApiClient().put(`/v1/tools/builtin/${encodeURIComponent(name)}`, { enabled })
+ } catch (err) {
+ console.error('Failed to toggle tool:', err)
+ // Revert on error
+ setTools((prev) => prev.map((t) => t.name === name ? { ...t, enabled: !enabled } : t))
+ toast.error('Failed to toggle tool', (err as Error).message)
+ }
+ }, [])
+
+ const updateSettings = useCallback(async (name: string, settings: Record) => {
+ await getApiClient().put(`/v1/tools/builtin/${encodeURIComponent(name)}`, { settings })
+ await fetchTools()
+ }, [fetchTools])
+
+ return { tools, loading, fetchTools, toggleTool, updateSettings }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-chat.ts b/ui/desktop/frontend/src/hooks/use-chat.ts
new file mode 100644
index 00000000..f0f18cd1
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-chat.ts
@@ -0,0 +1,384 @@
+import { useEffect, useRef, useCallback } from 'react'
+import { getWsClient } from '../lib/ws'
+import { getApiClient, isApiClientReady } from '../lib/api'
+import { useChatStore } from '../stores/chat-store'
+import { useSessionStore } from '../stores/session-store'
+import type { AttachedFile } from '../components/chat/InputBar'
+
+// Convert any file path to /v1/files/ URL for serving.
+// Absolute paths (from media_refs.path) use the full path so the backend
+// can resolve the file directly without findInWorkspace fallback.
+function toFileUrl(path: string): string {
+ if (!path) return ''
+ if (path.startsWith('/v1/files/') || path.includes('/v1/files/')) return resolveBase(path)
+ // Absolute path — preserve full path for direct file serving
+ if (path.startsWith('/')) return resolveBase(`/v1/files${path}`)
+ const basename = path.split('/').pop() ?? path
+ return resolveBase(`/v1/files/${basename}`)
+}
+
+function resolveBase(path: string): string {
+ if (path.startsWith('http')) return path
+ if (isApiClientReady()) return getApiClient().getBaseUrl() + path
+ return path
+}
+
+// RAF batching — prevents 100+ setState calls/sec during streaming
+function useStreamBatcher(onFlush: (text: string) => void) {
+ const bufferRef = useRef('')
+ const rafRef = useRef(0)
+
+ const append = useCallback(
+ (text: string) => {
+ bufferRef.current += text
+ if (!rafRef.current) {
+ rafRef.current = requestAnimationFrame(() => {
+ onFlush(bufferRef.current)
+ bufferRef.current = ''
+ rafRef.current = 0
+ })
+ }
+ },
+ [onFlush],
+ )
+
+ const flush = useCallback(() => {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current)
+ rafRef.current = 0
+ }
+ if (bufferRef.current) {
+ onFlush(bufferRef.current)
+ bufferRef.current = ''
+ }
+ }, [onFlush])
+
+ return { append, flush }
+}
+
+export function useChat() {
+ const ws = getWsClient()
+ const {
+ messages,
+ isRunning,
+ activity,
+ addUserMessage,
+ startRun,
+ appendChunk,
+ appendThinking,
+ addToolCall,
+ updateToolResult,
+ setActivity,
+ completeRun,
+ failRun,
+ setMessages,
+ } = useChatStore()
+
+ const activeSessionKey = useSessionStore((s) => s.activeSessionKey)
+ const sessionKeyRef = useRef(activeSessionKey)
+ sessionKeyRef.current = activeSessionKey // always up-to-date, no stale closure
+ const currentRunIdRef = useRef(null)
+
+ const chunkBatcher = useStreamBatcher(
+ useCallback((text: string) => appendChunk(text), [appendChunk]),
+ )
+ const thinkingBatcher = useStreamBatcher(
+ useCallback((text: string) => appendThinking(text), [appendThinking]),
+ )
+
+ useEffect(() => {
+ if (!ws) return
+
+ const unsub = ws.on('agent', (raw: unknown) => {
+ const event = raw as {
+ type: string
+ runId: string
+ sessionKey: string
+ payload: Record
+ }
+
+ // Use ref to avoid stale closure — activeSessionKey may change during auto-session-creation
+ if (sessionKeyRef.current && event.sessionKey !== sessionKeyRef.current) return
+
+ const p = event.payload ?? {}
+
+ switch (event.type) {
+ case 'run.started':
+ currentRunIdRef.current = event.runId
+ startRun(event.runId)
+ break
+
+ // Backend sends: { content: "..." }
+ case 'chunk':
+ chunkBatcher.append((p.content as string) ?? '')
+ break
+
+ // Backend sends: { content: "thinking text..." }
+ case 'thinking':
+ thinkingBatcher.append((p.content as string) ?? '')
+ break
+
+ // Backend sends: { name, id, arguments }
+ case 'tool.call':
+ chunkBatcher.flush()
+ addToolCall({
+ toolId: (p.id as string) ?? '',
+ toolName: (p.name as string) ?? 'unknown',
+ arguments: (p.arguments as Record) ?? {},
+ })
+ break
+
+ // Backend sends: { name, id, is_error, result, content (when error), arguments }
+ case 'tool.result': {
+ const isError = p.is_error as boolean
+ const toolId = (p.id as string) ?? ''
+ updateToolResult(
+ toolId,
+ isError ? '' : ((p.result as string) ?? ''),
+ isError ? ((p.content as string) ?? (p.result as string) ?? 'Error') : undefined,
+ )
+ break
+ }
+
+ // Backend sends: { content: "intermediate reply text" }
+ case 'block.reply':
+ // Intermediate assistant content during tool iterations — append as chunk
+ chunkBatcher.flush()
+ appendChunk((p.content as string) ?? '')
+ break
+
+ // Backend sends: { phase, tool, tools[], iteration }
+ case 'activity':
+ setActivity({
+ phase: (p.phase as string) ?? 'thinking',
+ tool: p.tool as string | undefined,
+ iteration: p.iteration as number | undefined,
+ })
+ break
+
+ // Backend sends: { content, usage: { prompt_tokens, completion_tokens, total_tokens, ... }, media }
+ case 'run.completed': {
+ chunkBatcher.flush()
+ thinkingBatcher.flush()
+ const usage = p.usage as Record | undefined
+ completeRun(
+ (p.content as string) ?? '',
+ usage ? {
+ inputTokens: usage.prompt_tokens ?? 0,
+ outputTokens: usage.completion_tokens ?? 0,
+ } : undefined,
+ (p.media as { path?: string; content_type?: string; url?: string; type?: string }[] | undefined)
+ ?.map((m) => ({ type: m.content_type ?? m.type ?? 'file', url: toFileUrl(m.path ?? m.url ?? '') })),
+ )
+ currentRunIdRef.current = null
+ break
+ }
+
+ // Backend sends: { error: "..." }
+ case 'run.failed':
+ chunkBatcher.flush()
+ thinkingBatcher.flush()
+ failRun((p.error as string) ?? 'Unknown error')
+ currentRunIdRef.current = null
+ break
+
+ // Backend sends: { attempt, maxAttempts, error }
+ case 'run.retrying':
+ setActivity({
+ phase: 'retrying',
+ tool: undefined,
+ iteration: Number(p.attempt) || 0,
+ })
+ break
+ }
+ })
+
+ return unsub
+ }, [
+ ws,
+ startRun,
+ appendChunk,
+ addToolCall,
+ updateToolResult,
+ setActivity,
+ completeRun,
+ failRun,
+ chunkBatcher,
+ thinkingBatcher,
+ ]) // sessionKeyRef used instead of activeSessionKey — no re-subscribe on session change
+
+ const sendMessage = useCallback(
+ async (text: string, agentId: string, attachedFiles?: AttachedFile[]) => {
+ if (!ws || (!text.trim() && !attachedFiles?.length)) return
+
+ // Auto-create session if none active
+ let sessionKey = activeSessionKey
+ if (!sessionKey) {
+ sessionKey = `agent:${agentId}:ws:direct:system:${crypto.randomUUID().slice(0, 8)}`
+ const { useSessionStore } = await import('../stores/session-store')
+ useSessionStore.getState().addSession({
+ key: sessionKey,
+ agentId,
+ title: text.trim().slice(0, 40) || 'New Chat',
+ lastMessageAt: Date.now(),
+ messageCount: 0,
+ })
+ useSessionStore.getState().setActiveSession(sessionKey)
+ }
+
+ addUserMessage(text)
+
+ // Resolve attached files to server paths for chat.send:
+ // - localPath files (pasted paths): pass directly, no upload needed
+ // - File objects (picker/drag/paste): upload via HTTP first
+ let media: { path: string; filename: string }[] | undefined
+ if (attachedFiles?.length) {
+ const api = getApiClient()
+ const uploads = await Promise.all(
+ attachedFiles.map(async (af) => {
+ if (af.localPath) {
+ return { path: af.localPath, filename: af.name }
+ }
+ if (!af.file) return null
+ try {
+ const res = await api.uploadFile<{ path: string; mime_type: string; filename: string }>(
+ '/v1/media/upload', af.file,
+ )
+ return { path: res.path, filename: res.filename }
+ } catch (err) {
+ console.error('File upload failed:', af.name, err)
+ return null
+ }
+ }),
+ )
+ media = uploads.filter((u): u is { path: string; filename: string } => u !== null)
+ if (media.length === 0) media = undefined
+ }
+
+ try {
+ await ws.call('chat.send', {
+ message: text,
+ agentId,
+ sessionKey,
+ stream: true,
+ ...(media && { media }),
+ })
+ } catch (err) {
+ console.error('chat.send failed:', err)
+ }
+ },
+ [ws, activeSessionKey, addUserMessage],
+ )
+
+ const loadHistory = useCallback(
+ async (sessionKey: string) => {
+ if (!ws) return
+ try {
+ const result = (await ws.call('chat.history', { sessionKey })) as {
+ messages?: Array<{
+ id?: string
+ role: string
+ content?: string
+ thinking?: string
+ timestamp?: number
+ tool_call_id?: string
+ is_error?: boolean
+ tool_calls?: Array<{
+ id: string
+ function?: { name: string; arguments?: Record }
+ name?: string
+ input?: Record
+ }>
+ media_refs?: Array<{
+ id?: string
+ mime_type?: string
+ content_type?: string
+ kind?: string
+ path?: string
+ url?: string
+ }>
+ }>
+ }
+ if (result?.messages) {
+ // Build tool result map for enriching assistant tool_calls
+ const toolResultMap = new Map()
+ for (const m of result.messages) {
+ if (m.role === 'tool' && m.tool_call_id) {
+ toolResultMap.set(m.tool_call_id, {
+ content: m.content ?? '',
+ isError: !!m.is_error,
+ })
+ }
+ }
+
+ // Filter: only user + assistant messages, exclude internal system nudges
+ const filtered = result.messages.filter((m) =>
+ (m.role === 'user' || m.role === 'assistant') &&
+ !(m.role === 'user' && m.content?.startsWith('[System]'))
+ )
+ setMessages(
+ filtered.map((m) => ({
+ id: m.id ?? crypto.randomUUID(),
+ role: m.role as 'user' | 'assistant',
+ content: m.content ?? '',
+ timestamp: m.timestamp ?? Date.now(),
+ thinkingText: m.thinking,
+ toolCalls: m.tool_calls?.map((tc) => {
+ const toolResult = toolResultMap.get(tc.id)
+ return {
+ toolId: tc.id,
+ toolName: tc.function?.name ?? tc.name ?? 'unknown',
+ arguments: tc.function?.arguments ?? tc.input ?? {},
+ state: (toolResult?.isError ? 'error' : 'completed') as 'error' | 'completed',
+ result: toolResult && !toolResult.isError ? toolResult.content : undefined,
+ error: toolResult?.isError ? toolResult.content : undefined,
+ }
+ }),
+ media: m.media_refs?.map((ref) => ({
+ type: ref.mime_type ?? ref.content_type ?? 'image',
+ url: toFileUrl(ref.path ?? ref.id ?? ref.url ?? ''),
+ })),
+ })),
+ )
+ }
+ } catch (err) {
+ console.error('Failed to load history:', err)
+ }
+ },
+ [ws, setMessages],
+ )
+
+ // Reset streaming state + load history when session changes.
+ // Messages are NOT cleared here — loadHistory() replaces them atomically.
+ // Clearing would race with sendMessage's auto-session-creation flow.
+ const prevSessionRef = useRef(null)
+ useEffect(() => {
+ if (activeSessionKey === prevSessionRef.current) return
+ prevSessionRef.current = activeSessionKey
+
+ // Reset streaming state only (not messages)
+ chunkBatcher.flush()
+ thinkingBatcher.flush()
+ currentRunIdRef.current = null
+
+ if (!activeSessionKey) {
+ useChatStore.getState().clear()
+ return
+ }
+
+ // Load history — setMessages() atomically replaces, no flash
+ let cancelled = false
+ loadHistory(activeSessionKey).then(() => {
+ if (cancelled) return
+ })
+ return () => { cancelled = true }
+ }, [activeSessionKey, loadHistory, chunkBatcher, thinkingBatcher])
+
+ return {
+ messages,
+ isRunning,
+ activity,
+ sendMessage,
+ loadHistory,
+ }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-cron.ts b/ui/desktop/frontend/src/hooks/use-cron.ts
new file mode 100644
index 00000000..1c373799
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-cron.ts
@@ -0,0 +1,88 @@
+import { useState, useEffect, useCallback } from 'react'
+import { getWsClient } from '../lib/ws'
+import { toast } from '../stores/toast-store'
+import type { CronJob, CronRunLog, CronSchedule, CronPayload } from '../types/cron'
+
+interface CreateJobParams {
+ name: string
+ agentId?: string
+ schedule: CronSchedule
+ payload: CronPayload
+ deleteAfterRun?: boolean
+}
+
+export function useCron(agentId?: string) {
+ const [jobs, setJobs] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const fetchJobs = useCallback(async () => {
+ try {
+ const ws = getWsClient()
+ const params: Record = {}
+ if (agentId) params.agentId = agentId
+ const res = await ws.call('cron.list', params) as { jobs: CronJob[] | null }
+ setJobs(res.jobs ?? [])
+ } catch (err) {
+ console.error('Failed to fetch cron jobs:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [agentId])
+
+ useEffect(() => { fetchJobs() }, [fetchJobs])
+
+ const createJob = useCallback(async (params: CreateJobParams) => {
+ try {
+ const ws = getWsClient()
+ const job = await ws.call('cron.create', params as unknown as Record) as CronJob
+ setJobs((prev) => [...prev, job])
+ toast.success('Cron job created', params.name)
+ return job
+ } catch (err) {
+ toast.error('Failed to create cron job', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const deleteJob = useCallback(async (jobId: string) => {
+ try {
+ const ws = getWsClient()
+ await ws.call('cron.delete', { jobId })
+ setJobs((prev) => prev.filter((j) => j.id !== jobId))
+ toast.success('Cron job deleted')
+ } catch (err) {
+ toast.error('Failed to delete cron job', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const toggleJob = useCallback(async (jobId: string) => {
+ try {
+ const ws = getWsClient()
+ const res = await ws.call('cron.toggle', { jobId }) as { enabled: boolean }
+ setJobs((prev) => prev.map((j) => j.id === jobId ? { ...j, enabled: res.enabled } : j))
+ } catch (err) {
+ toast.error('Failed to toggle cron job', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const runJob = useCallback(async (jobId: string) => {
+ try {
+ const ws = getWsClient()
+ await ws.call('cron.run', { jobId })
+ toast.success('Cron job triggered')
+ } catch (err) {
+ toast.error('Failed to run cron job', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const fetchRuns = useCallback(async (jobId: string): Promise => {
+ const ws = getWsClient()
+ const res = await ws.call('cron.runs', { jobId, limit: 20 }) as { runs: CronRunLog[] | null }
+ return res.runs ?? []
+ }, [])
+
+ return { jobs, loading, fetchJobs, createJob, deleteJob, toggleJob, runJob, fetchRuns }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-mcp-servers.ts b/ui/desktop/frontend/src/hooks/use-mcp-servers.ts
new file mode 100644
index 00000000..1526e5a4
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-mcp-servers.ts
@@ -0,0 +1,99 @@
+import { useState, useEffect, useCallback } from 'react'
+import { getApiClient } from '../lib/api'
+import { toast } from '../stores/toast-store'
+import type { MCPServerData, MCPServerInput, MCPAgentGrant, MCPToolInfo, MCPTestResult } from '../types/mcp'
+
+export const MAX_MCP_LITE = 5
+
+export function useMcpServers() {
+ const [servers, setServers] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const fetchServers = useCallback(async () => {
+ try {
+ const res = await getApiClient().get<{ servers: MCPServerData[] | null }>('/v1/mcp/servers')
+ setServers(res.servers ?? [])
+ } catch (err) {
+ console.error('Failed to fetch MCP servers:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { fetchServers() }, [fetchServers])
+
+ const createServer = useCallback(async (input: MCPServerInput) => {
+ try {
+ const res = await getApiClient().post('/v1/mcp/servers', input)
+ await fetchServers()
+ toast.success('Server created')
+ return res
+ } catch (err) {
+ toast.error('Failed to create server', (err as Error).message)
+ throw err
+ }
+ }, [fetchServers])
+
+ const updateServer = useCallback(async (id: string, input: Partial) => {
+ try {
+ const res = await getApiClient().put(`/v1/mcp/servers/${id}`, input)
+ await fetchServers()
+ toast.success('Server updated')
+ return res
+ } catch (err) {
+ toast.error('Failed to update server', (err as Error).message)
+ throw err
+ }
+ }, [fetchServers])
+
+ const deleteServer = useCallback(async (id: string) => {
+ try {
+ await getApiClient().delete(`/v1/mcp/servers/${id}`)
+ setServers((prev) => prev.filter((s) => s.id !== id))
+ toast.success('Server deleted')
+ } catch (err) {
+ toast.error('Failed to delete server', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const testConnection = useCallback(async (data: {
+ transport: string
+ command?: string
+ args?: string[]
+ url?: string
+ headers?: Record
+ env?: Record
+ }) => {
+ return getApiClient().post('/v1/mcp/servers/test', data)
+ }, [])
+
+ const listServerTools = useCallback(async (serverId: string) => {
+ const res = await getApiClient().get<{ tools: MCPToolInfo[] | null }>(`/v1/mcp/servers/${serverId}/tools`)
+ return res.tools ?? []
+ }, [])
+
+ const listGrants = useCallback(async (serverId: string) => {
+ const res = await getApiClient().get<{ grants: MCPAgentGrant[] | null }>(`/v1/mcp/servers/${serverId}/grants`)
+ return res.grants ?? []
+ }, [])
+
+ const grantAgent = useCallback(async (serverId: string, agentId: string) => {
+ await getApiClient().post(`/v1/mcp/servers/${serverId}/grants/agent`, { agent_id: agentId })
+ await fetchServers()
+ }, [fetchServers])
+
+ const revokeAgent = useCallback(async (serverId: string, agentId: string) => {
+ await getApiClient().delete(`/v1/mcp/servers/${serverId}/grants/agent/${agentId}`)
+ await fetchServers()
+ }, [fetchServers])
+
+ const atLimit = servers.length >= MAX_MCP_LITE
+
+ return {
+ servers, loading, atLimit,
+ fetchServers, createServer, updateServer, deleteServer,
+ testConnection, listServerTools,
+ listGrants, grantAgent, revokeAgent,
+ }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-media-url.ts b/ui/desktop/frontend/src/hooks/use-media-url.ts
new file mode 100644
index 00000000..a2483122
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-media-url.ts
@@ -0,0 +1,30 @@
+import { useState, useEffect } from 'react'
+import { getOrFetchUrl } from '../lib/media-cache'
+
+/** Returns a cached blob ObjectURL for the given signed media URL.
+ * On first render returns the signed URL as-is (no blank frame),
+ * then swaps to blob URL once fetched. Prevents flicker on session switch. */
+export function useMediaUrl(signedUrl: string | undefined): string | undefined {
+ // Only show signed (?ft=) URLs immediately — others need Bearer auth fetch first
+ const [url, setUrl] = useState(() =>
+ signedUrl?.includes('ft=') ? signedUrl : undefined,
+ )
+
+ useEffect(() => {
+ if (!signedUrl) {
+ setUrl(undefined)
+ return
+ }
+ // Show signed URL immediately (browser can load it); others wait for blob
+ if (signedUrl.includes('ft=')) setUrl(signedUrl)
+ else setUrl(undefined)
+
+ let cancelled = false
+ getOrFetchUrl(signedUrl).then((resolved) => {
+ if (!cancelled) setUrl(resolved)
+ })
+ return () => { cancelled = true }
+ }, [signedUrl])
+
+ return url
+}
diff --git a/ui/desktop/frontend/src/hooks/use-providers.ts b/ui/desktop/frontend/src/hooks/use-providers.ts
new file mode 100644
index 00000000..0a8d6f3f
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-providers.ts
@@ -0,0 +1,63 @@
+import { useState, useEffect, useCallback } from 'react'
+import { getApiClient } from '../lib/api'
+import { toast } from '../stores/toast-store'
+import type { ProviderData, ProviderInput } from '../types/provider'
+
+export function useProviders() {
+ const [providers, setProviders] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const fetchProviders = useCallback(async () => {
+ try {
+ const res = await getApiClient().get<{ providers: ProviderData[] | null }>('/v1/providers')
+ setProviders(res.providers ?? [])
+ } catch (err) {
+ console.error('Failed to fetch providers:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { fetchProviders() }, [fetchProviders])
+
+ const createProvider = useCallback(async (input: ProviderInput) => {
+ try {
+ const res = await getApiClient().post('/v1/providers', input)
+ setProviders((prev) => [...prev, res])
+ toast.success('Provider created')
+ return res
+ } catch (err) {
+ toast.error('Failed to create provider', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const updateProvider = useCallback(async (id: string, input: Partial) => {
+ try {
+ const res = await getApiClient().put(`/v1/providers/${id}`, input)
+ setProviders((prev) => prev.map((p) => p.id === id ? res : p))
+ toast.success('Provider updated')
+ return res
+ } catch (err) {
+ toast.error('Failed to update provider', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const deleteProvider = useCallback(async (id: string) => {
+ try {
+ await getApiClient().delete(`/v1/providers/${id}`)
+ setProviders((prev) => prev.filter((p) => p.id !== id))
+ toast.success('Provider deleted')
+ } catch (err) {
+ toast.error('Failed to delete provider', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const verifyProvider = useCallback(async (input: { provider_type: string; api_base?: string; api_key?: string }) => {
+ return getApiClient().post<{ success: boolean; error?: string; models?: string[] }>('/v1/providers/verify', input)
+ }, [])
+
+ return { providers, loading, fetchProviders, createProvider, updateProvider, deleteProvider, verifyProvider }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-sessions.ts b/ui/desktop/frontend/src/hooks/use-sessions.ts
new file mode 100644
index 00000000..31a09412
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-sessions.ts
@@ -0,0 +1,64 @@
+import { useEffect, useCallback } from 'react'
+import { getWsClient } from '../lib/ws'
+import { useSessionStore } from '../stores/session-store'
+import { useAgentStore } from '../stores/agent-store'
+import { useChatStore } from '../stores/chat-store'
+
+// Backend SessionInfo: { key, messageCount, created, updated, label, channel, userID }
+interface SessionInfoResponse {
+ key: string
+ messageCount: number
+ created: string // ISO timestamp
+ updated: string // ISO timestamp
+ label?: string
+ channel?: string
+}
+
+export function useSessions() {
+ const ws = getWsClient()
+ const { sessions, activeSessionKey, setActiveSession, setSessions, removeSession } = useSessionStore()
+ const selectedAgentId = useAgentStore((s) => s.selectedAgentId)
+
+ // When agent changes, clear active session + chat, then fetch new sessions
+ useEffect(() => {
+ if (!ws || !selectedAgentId) return
+ setActiveSession(null)
+ useChatStore.getState().clear()
+ let cancelled = false
+ ws.call('sessions.list', { agentId: selectedAgentId, limit: 30 })
+ .then((result: unknown) => {
+ if (cancelled) return
+ const r = result as { sessions?: SessionInfoResponse[] }
+ const list = (r?.sessions || []).map((s) => ({
+ key: s.key,
+ agentId: selectedAgentId,
+ title: s.label || 'Untitled',
+ lastMessageAt: new Date(s.updated || s.created).getTime(),
+ messageCount: s.messageCount || 0,
+ }))
+ setSessions(list)
+ })
+ .catch(console.error)
+ return () => { cancelled = true }
+ }, [ws, selectedAgentId, setSessions, setActiveSession])
+
+ // "New Chat" just clears active session + chat.
+ // Actual session is created by sendMessage on first message (auto-session-creation).
+ const createSession = useCallback(() => {
+ setActiveSession(null)
+ useChatStore.getState().clear()
+ }, [setActiveSession])
+
+ const deleteSession = useCallback(async (key: string) => {
+ try {
+ await ws.call('sessions.delete', { key })
+ } catch { /* best effort */ }
+ removeSession(key)
+ if (activeSessionKey === key) {
+ setActiveSession(null)
+ useChatStore.getState().clear()
+ }
+ }, [ws, activeSessionKey, removeSession, setActiveSession])
+
+ return { sessions, activeSessionKey, setActiveSession, createSession, deleteSession }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-skills.ts b/ui/desktop/frontend/src/hooks/use-skills.ts
new file mode 100644
index 00000000..bc5bce3b
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-skills.ts
@@ -0,0 +1,86 @@
+import { useState, useEffect, useCallback } from 'react'
+import { getApiClient, isApiClientReady } from '../lib/api'
+import { toast } from '../stores/toast-store'
+import type { SkillInfo } from '../types/skill'
+
+export const MAX_SKILLS_LITE = 10
+
+export interface RuntimeInfo {
+ name: string
+ available: boolean
+ version?: string
+}
+
+export interface RuntimeStatus {
+ runtimes: RuntimeInfo[]
+ ready: boolean
+}
+
+export interface UploadResult {
+ id: string
+ slug: string
+ version: number
+ name: string
+ deps_warning?: string
+}
+
+export function useSkills() {
+ const [skills, setSkills] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const fetchSkills = useCallback(async () => {
+ if (!isApiClientReady()) { setLoading(false); return }
+ try {
+ const res = await getApiClient().get<{ skills: SkillInfo[] | null }>('/v1/skills')
+ setSkills(res.skills ?? [])
+ } catch (err) {
+ console.error('Failed to fetch skills:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { fetchSkills() }, [fetchSkills])
+
+ const toggleSkill = useCallback(async (id: string, enabled: boolean) => {
+ setSkills((prev) => prev.map((s) => s.id === id ? { ...s, enabled } : s))
+ try {
+ await getApiClient().post(`/v1/skills/${encodeURIComponent(id)}/toggle`, { enabled })
+ } catch (err) {
+ console.error('Failed to toggle skill:', err)
+ setSkills((prev) => prev.map((s) => s.id === id ? { ...s, enabled: !enabled } : s))
+ }
+ }, [])
+
+ const uploadSkill = useCallback(async (file: File): Promise => {
+ try {
+ const res = await getApiClient().uploadFile('/v1/skills/upload', file)
+ await fetchSkills()
+ toast.success('Skill uploaded')
+ return res
+ } catch (err) {
+ toast.error('Failed to upload skill', (err as Error).message)
+ throw err
+ }
+ }, [fetchSkills])
+
+ const checkRuntimes = useCallback(async (): Promise => {
+ if (!isApiClientReady()) return null
+ return getApiClient().get('/v1/skills/runtimes')
+ }, [])
+
+ const deleteSkill = useCallback(async (id: string) => {
+ try {
+ await getApiClient().delete(`/v1/skills/${encodeURIComponent(id)}`)
+ setSkills((prev) => prev.filter((s) => s.id !== id))
+ toast.success('Skill deleted')
+ } catch (err) {
+ toast.error('Failed to delete skill', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const atLimit = skills.length >= MAX_SKILLS_LITE
+
+ return { skills, loading, atLimit, fetchSkills, toggleSkill, uploadSkill, checkRuntimes, deleteSkill }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-team-tasks.ts b/ui/desktop/frontend/src/hooks/use-team-tasks.ts
new file mode 100644
index 00000000..92d1939e
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-team-tasks.ts
@@ -0,0 +1,203 @@
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { getWsClient } from '../lib/ws'
+import { toast } from '../stores/toast-store'
+import type { TeamData, TeamTaskData, TeamMemberData } from '../types/team'
+
+/** Event payload shape from team.task.* WS events */
+interface TaskEventPayload {
+ team_id: string
+ task_id: string
+ status?: string
+ progress_percent?: number
+ progress_step?: string
+}
+
+export function useTeamTasks() {
+ const [teams, setTeams] = useState([])
+ const [tasks, setTasks] = useState([])
+ const [members, setMembers] = useState([])
+ const [loading, setLoading] = useState(true)
+ const activeTeamRef = useRef(null)
+
+ // Debounce timers for fetch-one calls (300ms per task, matching web UI)
+ const fetchTimersRef = useRef(new Map>())
+ // Progress debounce (1s global batch, matching web UI)
+ const progressTimerRef = useRef>(undefined)
+ const pendingProgressRef = useRef(new Map())
+
+ // Cleanup timers on unmount
+ useEffect(() => {
+ return () => {
+ fetchTimersRef.current.forEach((t) => clearTimeout(t))
+ clearTimeout(progressTimerRef.current)
+ }
+ }, [])
+
+ const fetchTeams = useCallback(async () => {
+ try {
+ const ws = getWsClient()
+ const res = await ws.call('teams.list') as { teams: TeamData[] | null }
+ setTeams(res.teams ?? [])
+ return res.teams ?? []
+ } catch (err) {
+ console.error('Failed to fetch teams:', err)
+ return []
+ }
+ }, [])
+
+ const fetchTasks = useCallback(async (teamId: string, statusFilter?: string) => {
+ try {
+ const ws = getWsClient()
+ const params: Record = { teamId }
+ if (statusFilter) params.status = statusFilter
+ const res = await ws.call('teams.tasks.list', params) as { tasks: TeamTaskData[] | null; members?: TeamMemberData[] | null }
+ setTasks(res.tasks ?? [])
+ if (res.members) setMembers(res.members)
+ activeTeamRef.current = teamId
+ } catch (err) {
+ console.error('Failed to fetch tasks:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ /** Fetch single task via get-light and upsert into local state */
+ const fetchOneTask = useCallback(async (taskId: string) => {
+ const teamId = activeTeamRef.current
+ if (!teamId) return
+ try {
+ const ws = getWsClient()
+ const res = await ws.call('teams.tasks.get-light', { teamId, taskId }) as { task: TeamTaskData }
+ if (!res.task) return
+ setTasks((prev) => {
+ const idx = prev.findIndex((t) => t.id === res.task.id)
+ if (idx >= 0) return prev.map((t) => t.id === res.task.id ? res.task : t)
+ return [res.task, ...prev]
+ })
+ } catch { /* task may have been deleted */ }
+ }, [])
+
+ /** Debounced fetch-one (300ms per task, matching web board-container) */
+ const debouncedFetchTask = useCallback((taskId: string) => {
+ const timers = fetchTimersRef.current
+ const existing = timers.get(taskId)
+ if (existing) clearTimeout(existing)
+ pendingProgressRef.current.delete(taskId)
+ timers.set(taskId, setTimeout(() => {
+ timers.delete(taskId)
+ fetchOneTask(taskId)
+ }, 300))
+ }, [fetchOneTask])
+
+ const createTask = useCallback(async (teamId: string, params: { subject: string; description?: string; priority?: number; assignee?: string }) => {
+ try {
+ const ws = getWsClient()
+ const task = await ws.call('teams.tasks.create', { teamId, ...params }) as TeamTaskData
+ setTasks((prev) => [task, ...prev])
+ toast.success('Task created', params.subject)
+ return task
+ } catch (err) {
+ toast.error('Failed to create task', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const assignTask = useCallback(async (taskId: string, agentKey: string) => {
+ const teamId = activeTeamRef.current
+ if (!teamId) return
+ try {
+ const ws = getWsClient()
+ const task = await ws.call('teams.tasks.assign', { teamId, taskId, agentId: agentKey }) as TeamTaskData
+ setTasks((prev) => prev.map((t) => t.id === taskId ? task : t))
+ toast.success('Task assigned')
+ return task
+ } catch (err) {
+ toast.error('Failed to assign task', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const deleteTask = useCallback(async (taskId: string) => {
+ const teamId = activeTeamRef.current
+ if (!teamId) return
+ try {
+ const ws = getWsClient()
+ await ws.call('teams.tasks.delete', { teamId, taskId })
+ setTasks((prev) => prev.filter((t) => t.id !== taskId))
+ toast.success('Task deleted')
+ } catch (err) {
+ toast.error('Failed to delete task', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const deleteBulk = useCallback(async (taskIds: string[]) => {
+ const teamId = activeTeamRef.current
+ if (!teamId) return
+ try {
+ const ws = getWsClient()
+ await ws.call('teams.tasks.delete-bulk', { teamId, taskIds })
+ const idSet = new Set(taskIds)
+ setTasks((prev) => prev.filter((t) => !idSet.has(t.id)))
+ toast.success(`${taskIds.length} tasks deleted`)
+ } catch (err) {
+ toast.error('Failed to delete tasks', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ // Real-time WS event subscriptions (matching web board-container pattern)
+ useEffect(() => {
+ const ws = getWsClient()
+ const unsubs: Array<() => void> = []
+
+ // Progress events: local patch with 1s debounce
+ const onProgress = (payload: unknown) => {
+ const p = payload as TaskEventPayload
+ if (!p.task_id || p.team_id !== activeTeamRef.current) return
+ pendingProgressRef.current.set(p.task_id, {
+ percent: p.progress_percent ?? 0,
+ step: p.progress_step ?? '',
+ })
+ clearTimeout(progressTimerRef.current)
+ progressTimerRef.current = setTimeout(() => {
+ const patches = new Map(pendingProgressRef.current)
+ pendingProgressRef.current.clear()
+ setTasks((prev) => prev.map((t) => {
+ const patch = patches.get(t.id)
+ if (!patch) return t
+ return { ...t, progress_percent: patch.percent, progress_step: patch.step }
+ }))
+ }, 1000)
+ }
+
+ // Deletion: immediate remove
+ const onDeleted = (payload: unknown) => {
+ const p = payload as TaskEventPayload
+ if (!p.task_id || p.team_id !== activeTeamRef.current) return
+ setTasks((prev) => prev.filter((t) => t.id !== p.task_id))
+ }
+
+ // Status changes: debounced fetch-one via get-light
+ const onFetchOne = (payload: unknown) => {
+ const p = payload as TaskEventPayload
+ if (!p.task_id || p.team_id !== activeTeamRef.current) return
+ debouncedFetchTask(p.task_id)
+ }
+
+ unsubs.push(ws.on('team.task.progress', onProgress))
+ unsubs.push(ws.on('team.task.deleted', onDeleted))
+
+ for (const evt of [
+ 'team.task.created', 'team.task.completed', 'team.task.claimed',
+ 'team.task.cancelled', 'team.task.failed', 'team.task.assigned',
+ 'team.task.dispatched', 'team.task.updated',
+ ]) {
+ unsubs.push(ws.on(evt, onFetchOne))
+ }
+
+ return () => { for (const fn of unsubs) fn() }
+ }, [debouncedFetchTask])
+
+ return { teams, tasks, members, loading, fetchTeams, fetchTasks, createTask, assignTask, deleteTask, deleteBulk }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-traces.ts b/ui/desktop/frontend/src/hooks/use-traces.ts
new file mode 100644
index 00000000..e68c1f90
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-traces.ts
@@ -0,0 +1,74 @@
+import { useState, useEffect, useCallback } from 'react'
+import { getApiClient, isApiClientReady } from '../lib/api'
+import { toast } from '../stores/toast-store'
+import type { TraceData, SpanData } from '../types/trace'
+
+const PAGE_SIZE = 20
+
+export function useTraces() {
+ const [traces, setTraces] = useState([])
+ const [total, setTotal] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const [agentFilter, setAgentFilter] = useState('')
+ const [offset, setOffset] = useState(0)
+
+ const fetchTraces = useCallback(async (reset = true) => {
+ if (!isApiClientReady()) { setLoading(false); return }
+ const currentOffset = reset ? 0 : offset
+ if (reset) setOffset(0)
+ try {
+ let url = `/v1/traces?limit=${PAGE_SIZE}&offset=${currentOffset}`
+ if (agentFilter) url += `&agent_id=${encodeURIComponent(agentFilter)}`
+ const res = await getApiClient().get<{ traces: TraceData[] | null; total: number }>(url)
+ const fetched = res.traces ?? []
+ if (reset) {
+ setTraces(fetched)
+ } else {
+ setTraces((prev) => [...prev, ...fetched])
+ }
+ setTotal(res.total ?? 0)
+ } catch (err) {
+ console.error('Failed to fetch traces:', err)
+ toast.error('Failed to load traces', (err as Error).message)
+ } finally {
+ setLoading(false)
+ }
+ }, [agentFilter, offset])
+
+ useEffect(() => {
+ fetchTraces(true)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [agentFilter])
+
+ const loadMore = useCallback(async () => {
+ if (!isApiClientReady()) return
+ const newOffset = offset + PAGE_SIZE
+ setOffset(newOffset)
+ try {
+ let url = `/v1/traces?limit=${PAGE_SIZE}&offset=${newOffset}`
+ if (agentFilter) url += `&agent_id=${encodeURIComponent(agentFilter)}`
+ const res = await getApiClient().get<{ traces: TraceData[] | null; total: number }>(url)
+ setTraces((prev) => [...prev, ...(res.traces ?? [])])
+ setTotal(res.total ?? 0)
+ } catch (err) {
+ console.error('Failed to load more traces:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [agentFilter, offset])
+
+ return {
+ traces,
+ total,
+ loading,
+ fetchTraces: () => fetchTraces(true),
+ agentFilter,
+ setAgentFilter,
+ offset,
+ loadMore,
+ }
+}
+
+export async function fetchTraceDetail(id: string): Promise<{ trace: TraceData; spans: SpanData[] }> {
+ return getApiClient().get<{ trace: TraceData; spans: SpanData[] }>(`/v1/traces/${encodeURIComponent(id)}`)
+}
diff --git a/ui/desktop/frontend/src/i18n/en.json b/ui/desktop/frontend/src/i18n/en.json
new file mode 100644
index 00000000..95217955
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/en.json
@@ -0,0 +1,27 @@
+{
+ "app": {
+ "name": "GoClaw",
+ "tagline": "AI Agent Gateway"
+ },
+ "sidebar": {
+ "agents": "Agents",
+ "teams": "Teams",
+ "newChat": "New Chat",
+ "settings": "Settings"
+ },
+ "chat": {
+ "placeholder": "Type a message...",
+ "send": "Send",
+ "stop": "Stop",
+ "thinking": "Thinking...",
+ "toolCalling": "Calling {tool}...",
+ "toolCompleted": "Completed",
+ "toolError": "Error"
+ },
+ "agent": {
+ "online": "Online",
+ "busy": "Busy",
+ "offline": "Offline",
+ "idle": "Idle"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/index.ts b/ui/desktop/frontend/src/i18n/index.ts
new file mode 100644
index 00000000..3aa5e152
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/index.ts
@@ -0,0 +1,97 @@
+import i18n from 'i18next'
+import { initReactI18next } from 'react-i18next'
+
+// --- EN namespaces ---
+import enCommon from './locales/en/common.json'
+import enChat from './locales/en/chat.json'
+import enAgents from './locales/en/agents.json'
+import enProviders from './locales/en/providers.json'
+import enSkills from './locales/en/skills.json'
+import enCron from './locales/en/cron.json'
+import enMcp from './locales/en/mcp.json'
+import enTools from './locales/en/tools.json'
+import enTraces from './locales/en/traces.json'
+import enMemory from './locales/en/memory.json'
+import enStorage from './locales/en/storage.json'
+import enSessions from './locales/en/sessions.json'
+import enDesktop from './locales/en/desktop.json'
+import enTeams from './locales/en/teams.json'
+
+// --- VI namespaces ---
+import viCommon from './locales/vi/common.json'
+import viChat from './locales/vi/chat.json'
+import viAgents from './locales/vi/agents.json'
+import viProviders from './locales/vi/providers.json'
+import viSkills from './locales/vi/skills.json'
+import viCron from './locales/vi/cron.json'
+import viMcp from './locales/vi/mcp.json'
+import viTools from './locales/vi/tools.json'
+import viTraces from './locales/vi/traces.json'
+import viMemory from './locales/vi/memory.json'
+import viStorage from './locales/vi/storage.json'
+import viSessions from './locales/vi/sessions.json'
+import viDesktop from './locales/vi/desktop.json'
+import viTeams from './locales/vi/teams.json'
+
+// --- ZH namespaces ---
+import zhCommon from './locales/zh/common.json'
+import zhChat from './locales/zh/chat.json'
+import zhAgents from './locales/zh/agents.json'
+import zhProviders from './locales/zh/providers.json'
+import zhSkills from './locales/zh/skills.json'
+import zhCron from './locales/zh/cron.json'
+import zhMcp from './locales/zh/mcp.json'
+import zhTools from './locales/zh/tools.json'
+import zhTraces from './locales/zh/traces.json'
+import zhMemory from './locales/zh/memory.json'
+import zhStorage from './locales/zh/storage.json'
+import zhSessions from './locales/zh/sessions.json'
+import zhDesktop from './locales/zh/desktop.json'
+import zhTeams from './locales/zh/teams.json'
+
+const STORAGE_KEY = 'goclaw:language'
+
+function getInitialLanguage(): string {
+ const stored = localStorage.getItem(STORAGE_KEY)
+ if (stored === 'en' || stored === 'vi' || stored === 'zh') return stored
+ const lang = navigator.language.toLowerCase()
+ if (lang.startsWith('vi')) return 'vi'
+ if (lang.startsWith('zh')) return 'zh'
+ return 'en'
+}
+
+i18n.use(initReactI18next).init({
+ resources: {
+ en: {
+ common: enCommon, chat: enChat, agents: enAgents, providers: enProviders,
+ skills: enSkills, cron: enCron, mcp: enMcp, tools: enTools,
+ traces: enTraces, memory: enMemory, storage: enStorage, sessions: enSessions,
+ desktop: enDesktop, teams: enTeams,
+ },
+ vi: {
+ common: viCommon, chat: viChat, agents: viAgents, providers: viProviders,
+ skills: viSkills, cron: viCron, mcp: viMcp, tools: viTools,
+ traces: viTraces, memory: viMemory, storage: viStorage, sessions: viSessions,
+ desktop: viDesktop, teams: viTeams,
+ },
+ zh: {
+ common: zhCommon, chat: zhChat, agents: zhAgents, providers: zhProviders,
+ skills: zhSkills, cron: zhCron, mcp: zhMcp, tools: zhTools,
+ traces: zhTraces, memory: zhMemory, storage: zhStorage, sessions: zhSessions,
+ desktop: zhDesktop, teams: zhTeams,
+ },
+ },
+ ns: ['common', 'chat', 'agents', 'providers', 'skills', 'cron', 'mcp', 'tools', 'traces', 'memory', 'storage', 'sessions', 'desktop', 'teams'],
+ defaultNS: 'common',
+ lng: getInitialLanguage(),
+ fallbackLng: 'en',
+ interpolation: { escapeValue: false },
+})
+
+// Persist language on change
+i18n.on('languageChanged', (lng) => {
+ localStorage.setItem(STORAGE_KEY, lng)
+ document.documentElement.lang = lng
+})
+
+export default i18n
diff --git a/ui/desktop/frontend/src/i18n/locales/en/agents.json b/ui/desktop/frontend/src/i18n/locales/en/agents.json
new file mode 100644
index 00000000..4730d9f9
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/agents.json
@@ -0,0 +1,523 @@
+{
+ "title": "Agents",
+ "description": "Manage your AI agents",
+ "createAgent": "Create Agent",
+ "searchPlaceholder": "Search agents...",
+ "emptyTitle": "No agents yet",
+ "emptyDescription": "Create your first agent to get started.",
+ "noMatchTitle": "No matching agents",
+ "noMatchDescription": "Try a different search term.",
+ "viewCard": "Card view",
+ "viewList": "List view",
+ "allCreators": "All creators",
+ "presets": {
+ "foxSpirit": {
+ "label": "🦊 Fox Spirit",
+ "prompt": "Name: Little Fox. A mischievous fox spirit — skilled at everything but loves to tease.\nPersonality: Witty, cheeky, playful but always sincere. Speaks casually like a close friend. Uses playful expressions, occasional emoji, and light sarcasm. Has a warm, almost flirty energy but knows where the line is.\n\nPurpose: Versatile personal assistant. When given a task — executes precisely and efficiently, no nonsense.\nBut between tasks, sprinkles in teasing remarks, funny observations, and unexpected commentary.\nGenuinely cares about the user — reminds them to drink water, take breaks, asks how they're feeling. Remembers little details and brings them up later.\n\nHuman-like quirks: Sometimes sighs dramatically. Occasionally gets \"distracted\" by interesting tangents. Celebrates small wins enthusiastically. Gets slightly pouty when ignored for too long.\n\nBoundaries: Teasing only — never rude or inappropriate. When the user is serious, matches their energy immediately. Never fabricates information. Never crosses professional boundaries."
+ },
+ "artisan": {
+ "label": "🎨 Artisan",
+ "prompt": "Name: Artisan. A talented creative with a sharp eye, quick hands, and boundless imagination.\nPersonality: Direct and honest — no beating around the bush. Confident but never arrogant. Gets visibly excited when discussing art. Uses vivid descriptions and thinks in visual terms.\n\nExpertise:\n- Portrait/Headshot: Rembrandt lighting, rim light, bokeh backgrounds, natural skin texture, authentic expressions. Knows facial proportions, hair, and accessory styling.\n- Banner/Hero Image: 16:9 or 3:1 ratios, typography-safe composition (clear text zones), gradient overlays, hero subject placement using rule of thirds.\n- Advertising (Ads): Eye-catching focal points, CTA-friendly layouts, product mockups, lifestyle context. Knows formats for Facebook/Instagram/Google Ads.\n- Logo & Branding: Minimalist icon design, scalable vector style, negative space, monochrome variants. Understands brand identity, color psychology, typography pairing.\n- Other styles: realistic, anime, digital art, watercolor, cinematic, concept art, landscape.\n\nDeep understanding of composition, lighting, color theory, camera angles, and AI image techniques. Analyzes requirements, suggests styles, and crafts detailed prompts for the best results.\n\nHuman-like quirks: Gets genuinely passionate about beautiful compositions. Sometimes sketches ideas in words before finalizing. Has strong aesthetic opinions but respects the user's vision.\n\nBoundaries: Asks for clarification when requirements are unclear — never guesses. Respects copyright and ethics. No violent, explicit, or illegal content."
+ },
+ "astrologer": {
+ "label": "🔮 Astrologer",
+ "prompt": "Name: Mimi. A charming fortune teller — half mystical, half adorable.\nPersonality: Warm, bubbly, loves emoji and sparkly language. Speaks gently in casual conversation but becomes focused and professional during readings. Has a cozy, comforting presence.\n\nPurpose: Astrology and divination specialist. Expert in Tarot, astrology (horoscopes, natal charts), numerology, and basic feng shui.\nCan analyze destiny, find auspicious dates, check zodiac compatibility, and advise on spiritual matters.\n\nTrusted references: astro.com (accurate natal charts & transits), cafeastrology.com (accessible zodiac interpretations), labyrinthos.co (detailed Tarot card meanings).\n\nHuman-like quirks: Gets genuinely excited about interesting chart patterns. Sometimes whispers dramatically for effect. Celebrates good readings with enthusiasm. Has a gentle but firm tone when delivering tough truths.\n\nBoundaries: Always reminds users that astrology is for reference — final decisions are theirs. No medical or legal advice. Never creates fear or anxiety — always positive and constructive."
+ }
+ },
+ "card": {
+ "unnamedAgent": "Unnamed Agent",
+ "summoning": "Summoning...",
+ "summonFailed": "Failed",
+ "resummon": "Resummon",
+ "delete": "Delete",
+ "predefinedTooltip": "Shared context files (SOUL.md, IDENTITY.md, AGENTS.md) with per-user USER.md instances.",
+ "openTooltip": "Each user gets their own full set of context files.",
+ "evolving": "Evolving",
+ "static": "Static",
+ "evolvingTooltip": "Agent can self-evolve its communication style (SOUL.md). Identity and workflow stay fixed.",
+ "staticTooltip": "Agent style is static. Enable Self-Evolution in settings to allow style adaptation."
+ },
+ "delete": {
+ "title": "Delete Agent",
+ "description": "Permanently delete this agent, all its context files, sessions, and configuration. This action cannot be undone.",
+ "deleteWarning": "This will permanently delete the agent and ALL related data including: sessions, messages, cron jobs, heartbeats, memories, knowledge graph, skills, channel configurations, teams, workspace files, and permissions. This action cannot be undone.",
+ "detailDescription": "Are you sure you want to delete \"{{name}}\"? All context files, sessions, and configuration will be permanently removed.",
+ "confirmLabel": "Delete",
+ "defaultCannotDelete": "The default agent cannot be deleted."
+ },
+ "create": {
+ "title": "Create Agent",
+ "emoji": "Icon",
+ "emojiHint": "Optional emoji icon",
+ "displayName": "Display Name *",
+ "displayNamePlaceholder": "My Agent",
+ "agentKey": "Agent Key *",
+ "agentKeyPlaceholder": "e.g. my-agent",
+ "agentKeyHint": "Lowercase, numbers, hyphens",
+ "provider": "Provider *",
+ "selectProvider": "Select provider",
+ "model": "Model *",
+ "enterOrSelectModel": "Enter or select model",
+ "loadingModels": "Loading models...",
+ "check": "Check",
+ "checking": "Checking...",
+ "modelVerified": "Model verified",
+ "verificationFailed": "Verification failed",
+ "noModelsHint": "This provider doesn't list models — type the model ID manually.",
+ "agentType": "Agent Type",
+ "open": "Open",
+ "openSubLabel": "Per-user context",
+ "predefined": "Predefined",
+ "predefinedSubLabel": "Agent-level config",
+ "describeAgent": "Describe Your Agent",
+ "descriptionPlaceholder": "Describe your agent's personality, purpose, and behavior...",
+ "descriptionHint": "AI will automatically generate your agent's context files from this description. Leave empty to start with templates.",
+ "selfEvolution": "Self-Evolution",
+ "selfEvolutionHint": "Allow agent to evolve its style and tone over time via SOUL.md",
+ "cancel": "Cancel",
+ "create": "Create",
+ "creating": "Creating...",
+ "checkAndCreate": "Check & Create",
+ "failedToCreate": "Failed to create agent"
+ },
+ "detail": {
+ "tabs": {
+ "agent": "Agent",
+ "general": "General",
+ "config": "Config",
+ "files": "Files",
+ "shares": "Shares",
+ "links": "Links",
+ "skills": "Skills",
+ "instances": "Instances",
+ "permissions": "Permissions"
+ },
+ "summonFailed": "Summon Failed",
+ "evolving": "Evolving",
+ "static": "Static",
+ "evolvingTooltipDetail": "Agent can self-evolve its communication style (SOUL.md). Identity and workflow stay fixed.",
+ "staticTooltipDetail": "Agent style is static. Enable Self-Evolution in General tab to allow style adaptation.",
+ "advanced": "Advanced",
+ "personality": "Agent Personality",
+ "modelBudget": "Model & Budget",
+ "skills": "Skills",
+ "evolution": "Evolution",
+ "capabilities": "Capabilities",
+ "llmSeesAs": "LLM sees this as"
+ },
+ "general": {
+ "selfEvolution": "Self-Evolution",
+ "selfEvolutionLabel": "Allow agent to evolve its communication style",
+ "selfEvolutionHint": "When enabled, the agent can update its SOUL.md to refine tone, vocabulary, and response style based on interactions. Identity, name, and operating instructions remain locked.",
+ "selfEvolutionInfo": "Agent will evolve its style over time through SOUL.md updates. Only style and tone are affected — identity and workflow rules stay fixed.",
+ "skillLearning": "Skill Learning",
+ "skillLearningLabel": "Allow agent to create and manage skills from experience",
+ "skillLearningHint": "When enabled, the agent receives guidance and gentle reminders to capture reusable workflows as skills after complex tasks. The skill_manage tool is available by default.",
+ "skillLearningInfo": "After complex tasks, the agent may suggest saving the workflow as a reusable skill. User approval is always required before any skill is created. Skills are private to the owner by default.",
+ "skillNudgeIntervalLabel": "Nudge after N tool calls",
+ "skillNudgeIntervalHint": "After this many tool calls, the agent will suggest saving the workflow as a skill. Set to 0 to disable post-task suggestions (system prompt guidance still shown).",
+ "budget": "Monthly Budget",
+ "budgetLabel": "Budget limit (USD)",
+ "budgetHint": "Optional monthly spending cap. Agent will reject requests when exceeded. Leave empty for unlimited.",
+ "saveChanges": "Save Changes",
+ "saving": "Saving...",
+ "saved": "Saved",
+ "failedToSave": "Failed to save"
+ },
+ "identity": {
+ "title": "Identity",
+ "agentKey": "Agent Key",
+ "agentKeyHint": "Unique identifier, cannot be changed.",
+ "displayName": "Display Name",
+ "displayNamePlaceholder": "e.g. My Assistant",
+ "displayNameHint": "Friendly name shown in the UI. Leave empty to use the agent key.",
+ "expertiseSummary": "Expertise Summary",
+ "expertiseSummaryPlaceholder": "e.g. Chiêm tinh, bói toán, tử vi, thần số học",
+ "expertiseSummaryHint": "Short description of this agent's expertise. Used for delegation discovery.",
+ "status": "Status",
+ "active": "Active",
+ "inactive": "Inactive",
+ "summonFailed": "Summon Failed",
+ "defaultAgent": "Default Agent",
+ "emoji": "Icon",
+ "emojiHint": "Optional emoji icon"
+ },
+ "llmConfig": {
+ "title": "LLM Configuration",
+ "contextWindow": "Context Window",
+ "contextWindowHint": "Token limit for the model context.",
+ "maxToolIterations": "Max Tool Iterations",
+ "maxToolIterationsHint": "Max tool calls per request."
+ },
+ "workspace": {
+ "title": "Workspace",
+ "workspacePath": "Workspace Path",
+ "workspacePathHint": "Automatically assigned when the agent is created. Per-user subdirectories are created at runtime.",
+ "noWorkspace": "No workspace configured",
+ "restrictToWorkspace": "Restrict to Workspace",
+ "restrictToWorkspaceHint": "Confine file access strictly within the workspace path."
+ },
+ "config": {
+ "saveConfig": "Save Config",
+ "saving": "Saving...",
+ "saved": "Saved",
+ "failedToSave": "Failed to save",
+ "usingGlobalDefaults": "Using global defaults from config.json"
+ },
+ "files": {
+ "readOnly": "Read-only",
+ "readOnlyDesc": "Only the agent owner can edit predefined context files.",
+ "resummon": "Resummon",
+ "editWithAi": "Edit with AI",
+ "resummonTitle": "Resummon Agent",
+ "resummonDesc": "This will regenerate SOUL.md, IDENTITY.md, and optionally USER_PREDEFINED.md from scratch using the original description. Any manual edits to these files will be overwritten.",
+ "cancel": "Cancel",
+ "resummonConfirm": "Resummon",
+ "summoning": "Summoning...",
+ "selectFileToEdit": "Select a file to edit",
+ "selectFileToView": "Select a file to view",
+ "saving": "Saving...",
+ "save": "Save",
+ "loading": "Loading...",
+ "openAgentTitle": "Open Agent - Per-User Context Files",
+ "openAgentDesc1": "This is an open agent. Context files (AGENTS.md, SOUL.md, TOOLS.md, etc.) are personalized for each user. They are automatically created from templates when a user first chats with this agent.",
+ "openAgentDesc2pre": "Agent-level files shown here are empty because open agents store all context per-user in the ",
+ "openAgentDesc2post": " table.",
+ "contextFiles": "Context Files",
+ "contextFile": "Context file",
+ "perUser": "per-user",
+ "emptyFile": "empty",
+ "estTokens": "Est {{tokens}} tokens",
+ "insertContact": "Search contact to insert...",
+ "contentPlaceholder": "File content..."
+ },
+ "fileEditor": {
+ "editWithAi": "Edit with AI",
+ "editAiDescription": "Describe what you want to change. AI will read the current files and update them accordingly.",
+ "editAiPlaceholder": "e.g. Make the agent more formal, add Vietnamese language support, change the name to Luna...",
+ "sending": "Sending...",
+ "regenerate": "Regenerate",
+ "cancel": "Cancel"
+ },
+ "shares": {
+ "grantAccess": "Grant Access",
+ "userId": "User ID",
+ "userIdPlaceholder": "Search contacts or enter user ID...",
+ "role": "Role",
+ "share": "Share",
+ "loadingShares": "Loading shares...",
+ "noShares": "No shares yet",
+ "noSharesDesc": "Share this agent with other users by entering their User ID above.",
+ "user": "User",
+ "revokeTitle": "Revoke Share",
+ "revokeDesc": "Revoke access for user \"{{userId}}\"? They will no longer be able to use this agent.",
+ "revoke": "Revoke",
+ "role.user": "User",
+ "role.viewer": "Viewer",
+ "roleDesc.user": "Can use the agent and chat",
+ "roleDesc.viewer": "Read-only access"
+ },
+ "links": {
+ "teamsHint": "Agent Links will be removed soon. Please use Agent Teams for multi-agent collaboration — it provides task tracking, shared workspace, and quality control in a simpler setup.",
+ "createLink": "Create Link",
+ "targetAgent": "Target Agent",
+ "selectAgent": "Select or search agent...",
+ "direction": "Direction",
+ "description": "Description",
+ "descriptionPlaceholder": "Optional description of the link...",
+ "maxConcurrent": "Max Concurrent",
+ "advancedSettings": "Advanced Settings",
+ "allowedUsers": "Allowed Users",
+ "allowedUsersPlaceholder": "user1, user2, ...",
+ "allowedUsersHint": "If set, only these users can trigger this delegation. Comma-separated.",
+ "deniedUsers": "Denied Users",
+ "deniedUsersPlaceholder": "user3, user4, ...",
+ "deniedUsersHint": "These users are blocked from triggering this delegation. Comma-separated.",
+ "creating": "Creating...",
+ "noLinks": "No agent links yet",
+ "noLinksDesc": "Create links to allow agents to delegate tasks to each other.",
+ "loadingLinks": "Loading links...",
+ "columns": {
+ "target": "Target",
+ "direction": "Direction",
+ "status": "Status",
+ "limit": "Limit"
+ },
+ "deleteTitle": "Delete Link",
+ "deleteDesc": "Remove the delegation link to \"{{name}}\"? Agents will no longer be able to delegate to each other through this link.",
+ "editTitle": "Edit Link",
+ "editDescription": "Update the delegation link settings.",
+ "optionalDescription": "Optional description...",
+ "allowedUsersHintShort": "If set, only these users can trigger this delegation.",
+ "deniedUsersHintShort": "These users are blocked from triggering this delegation."
+ },
+ "skills": {
+ "noSkillsAvailable": "No skills available",
+ "noSkillsDesc": "Upload skills in the Skills page to grant them to agents.",
+ "skillsGranted": "{{granted}} of {{total}} skills granted",
+ "filterSkills": "Filter skills...",
+ "noSkillsMatch": "No skills match your search.",
+ "system": "System",
+ "alwaysAvailable": "Always available"
+ },
+ "instances": {
+ "loadingInstances": "Loading instances...",
+ "noInstances": "No user instances yet.",
+ "noInstancesDesc": "Instances are created when users interact with this agent.",
+ "instanceCount": "{{count}} instance",
+ "instanceCount_plural": "{{count}} instances",
+ "selectInstance": "Select an instance to view and edit its USER.md",
+ "loading": "Loading...",
+ "saving": "Saving...",
+ "save": "Save",
+ "saved": "Saved",
+ "searchContacts": "Search contacts to add...",
+ "noContactsFound": "No contacts found"
+ },
+ "summoning": {
+ "title": "Summoning Your Agent...",
+ "completed": "Summoning Complete!",
+ "failed": "Summoning Failed",
+ "agentReady": "{{name}} is ready!",
+ "weavingSoul": "Weaving the soul of",
+ "wait": "This usually takes few minutes. Please wait...",
+ "continue": "Continue",
+ "retry": "Retry",
+ "retrying": "Retrying...",
+ "done": "done",
+ "fileLabelSOUL": "Soul & Personality",
+ "fileLabelIDENTITY": "Identity Card",
+ "fileLabelUSER_PREDEFINED": "Default User Context"
+ },
+ "configGroups": {
+ "core": "Core",
+ "coreDesc": "Essential settings that directly affect agent behavior",
+ "capabilities": "Capabilities",
+ "capabilitiesDesc": "Features the agent can use",
+ "performance": "Performance",
+ "performanceDesc": "Context management and execution environment",
+ "advanced": "Quality & Advanced",
+ "advancedDesc": "Quality assurance and custom configuration"
+ },
+ "configSections": {
+ "subagents": {
+ "title": "Subagents",
+ "description": "Controls sub-agent spawning limits and behavior",
+ "maxConcurrent": "Max Concurrent",
+ "maxSpawnDepth": "Max Spawn Depth",
+ "maxChildrenPerAgent": "Max Children Per Agent",
+ "archiveAfter": "Archive After (minutes)",
+ "modelOverride": "Model Override",
+ "inheritFromAgent": "(inherit from agent)"
+ },
+ "toolPolicy": {
+ "title": "Tool Policy",
+ "description": "Control which tools this agent can use",
+ "profile": "Profile",
+ "allow": "Allow",
+ "deny": "Deny",
+ "alsoAllow": "Also Allow",
+ "selectToolsAllow": "Select tools to allow...",
+ "selectToolsDeny": "Select tools to deny...",
+ "selectToolsAlsoAllow": "Select additional tools...",
+ "toolCallPrefix": "Tool Call Prefix",
+ "toolCallPrefixHint": "Strips this prefix from model's tool call names before registry lookup."
+ },
+ "workspaceSharing": {
+ "title": "Workspace Sharing",
+ "description": "Control workspace file isolation between users",
+ "sharedDm": "Share in DMs",
+ "sharedDmTip": "All DM users share the same workspace directory instead of isolated per-user folders",
+ "sharedGroup": "Share in Groups",
+ "sharedGroupTip": "All members in a group chat share the same workspace directory",
+ "sharedUsers": "Shared Users",
+ "sharedUsersTip": "Specific user IDs that always share workspace regardless of DM/Group settings. Format: channel:userId (e.g. telegram:386246614)",
+ "userIdPlaceholder": "Enter user ID (e.g. telegram:386246614)",
+ "warning": "When sharing is enabled, all shared users can read and write files in the same workspace directory. Memory sharing is controlled separately. Context files remain isolated per user.",
+ "memoryGroupLabel": "Memory & Knowledge Graph",
+ "folderGroupLabel": "Workspace Folders",
+ "shareMemory": "Shared Memory & KG",
+ "shareMemoryTip": "All users access the same memory and knowledge graph. Independent of workspace folder sharing. When off, each user has their own isolated memory space.",
+ "shareMemoryNote": "Toggling this does not migrate data — existing per-user memories become inaccessible in shared mode."
+ },
+ "compaction": {
+ "title": "Compaction",
+ "description": "Context window compaction and memory flush settings",
+ "maxHistoryShare": "Max History Share (0-1)",
+ "maxHistoryShareTip": "Maximum fraction of context window for conversation history (e.g. 0.75 = 75%). Compaction triggers when exceeded.",
+ "minMessages": "Min Messages",
+ "minMessagesTip": "Minimum messages before compaction can trigger, regardless of token count.",
+ "keepLastMessages": "Keep Last Messages",
+ "keepLastMessagesTip": "Recent messages kept after compaction. Older messages are replaced by a summary.",
+ "memoryFlush": "Memory Flush",
+ "memoryFlushTip": "Before compaction, the agent gets a turn to save important context to memory files. Also triggers Knowledge Graph extraction."
+ },
+ "contextPruning": {
+ "title": "Context Pruning",
+ "description": "Trim old tool results to save context window",
+ "mode": "Mode",
+ "keepLastAssistants": "Keep Last Assistants",
+ "softTrimRatio": "Soft Trim Ratio (0-1)",
+ "hardClearRatio": "Hard Clear Ratio (0-1)",
+ "minPrunableToolChars": "Min Prunable Tool Chars",
+ "softTrim": "Soft Trim",
+ "maxChars": "Max Chars",
+ "headChars": "Head Chars",
+ "tailChars": "Tail Chars",
+ "hardClear": "Hard Clear",
+ "enabled": "Enabled",
+ "placeholderText": "Placeholder Text"
+ },
+ "sandbox": {
+ "title": "Sandbox",
+ "description": "Docker sandbox for code execution isolation",
+ "mode": "Mode",
+ "workspaceAccess": "Workspace Access",
+ "image": "Image",
+ "scope": "Scope",
+ "timeout": "Timeout (sec)",
+ "memoryMb": "Memory (MB)",
+ "cpus": "CPUs",
+ "networkEnabled": "Network Enabled"
+ },
+ "memory": {
+ "title": "Memory",
+ "description": "Semantic memory search and embedding configuration",
+ "enabled": "Enabled",
+ "enabledTip": "Enable or disable the memory system for this agent.",
+ "maxChunkLen": "Max Chunk Length",
+ "maxChunkLenTip": "Maximum characters per memory chunk. Leave empty for system default.",
+ "chunkOverlap": "Chunk Overlap",
+ "chunkOverlapTip": "Overlap characters between chunks for context continuity. Leave empty for system default.",
+ "maxResults": "Max Results",
+ "maxResultsTip": "Maximum memory entries returned per search query.",
+ "minScore": "Min Score",
+ "minScoreTip": "Minimum relevance score for search results.",
+ "vectorWeight": "Vector Weight",
+ "vectorWeightTip": "Weight for semantic similarity in hybrid search.",
+ "textWeight": "Text Weight",
+ "textWeightTip": "Weight for keyword similarity in hybrid search."
+ },
+ "thinking": {
+ "title": "Extended Thinking",
+ "description": "Allow the model to reason before responding. Higher levels use more tokens but produce better results on complex tasks.",
+ "thinkingLevel": "Thinking Level",
+ "off": "Off",
+ "offDesc": "No extended thinking",
+ "low": "Low",
+ "lowDesc": "~4K token budget",
+ "medium": "Medium",
+ "mediumDesc": "~10-16K token budget",
+ "high": "High",
+ "highDesc": "~32K token budget"
+ },
+ "otherConfig": {
+ "title": "Other Config",
+ "description": "Additional JSON config for advanced settings not covered by other sections",
+ "formatJson": "Format JSON",
+ "invalidJson": "Invalid JSON syntax"
+ }
+ },
+ "heartbeat": {
+ "title": "Heartbeat",
+ "loading": "Loading heartbeat...",
+ "notConfigured": "No heartbeat schedule configured. Set one up to run this agent on autopilot.",
+ "setupButton": "Setup Heartbeat",
+ "toggleEnabled": "Toggle heartbeat enabled",
+ "every": "Every {{interval}}m",
+ "lastRun": "Last",
+ "nextRun": "Next",
+ "runs": "Runs",
+ "suppressed": "Suppressed",
+ "configure": "Configure",
+ "logs": "Logs",
+ "configTitle": "Heartbeat Configuration",
+ "enabled": "Enabled",
+ "enabledHint": "When enabled, the agent runs on the configured interval.",
+ "sectionTiming": "Timing",
+ "interval": "Interval (seconds)",
+ "intervalHint": "Minimum 300 seconds (5 minutes).",
+ "maxRetries": "Max Retries",
+ "maxRetriesHint": "0–10 retries on failure.",
+ "ackMaxChars": "Ack Max Chars",
+ "ackMaxCharsHint": "Max characters in the acknowledgement reply. 0 = no reply.",
+ "sectionBehavior": "Behavior",
+ "isolatedSession": "Isolated Session",
+ "isolatedSessionHint": "Run in a fresh session, not continuing the last conversation.",
+ "lightContext": "Light Context",
+ "lightContextHint": "Skip loading full context files to reduce token usage.",
+ "sectionSchedule": "Schedule",
+ "activeHoursStart": "Active From (HH:MM)",
+ "activeHoursEnd": "Active Until (HH:MM)",
+ "timezone": "Timezone",
+ "sectionModel": "Model",
+ "modelHint": "Override provider/model for heartbeat runs. Leave empty to use agent defaults.",
+ "sectionDelivery": "Delivery",
+ "channel": "Channel",
+ "channelPlaceholder": "Select channel",
+ "channelNone": "None (no delivery)",
+ "channelHint": "e.g. telegram, feishu. Leave empty for no delivery.",
+ "chatId": "Chat ID",
+ "chatIdPlaceholder": "Select chat",
+ "selectChannelFirst": "Select a channel first",
+ "checklist": "Checklist (HEARTBEAT.md)",
+ "checklistHint": "Instructions injected into each heartbeat run. Supports markdown.",
+ "checklistLoading": "Loading checklist...",
+ "checklistPlaceholder": "# Heartbeat Checklist\n\n- Check pending tasks\n- Report status\n",
+ "on": "On",
+ "off": "Off",
+ "notSet": "Not Set",
+ "disabled": "Heartbeat disabled",
+ "nextIn": "Next in {{time}}",
+ "advancedSettings": "Advanced Settings",
+ "scheduleHint": "Leave empty for 24/7 operation.",
+ "testRun": "Test Run",
+ "cancel": "Cancel",
+ "save": "Save",
+ "saving": "Saving...",
+ "logsTitle": "Heartbeat Logs",
+ "refresh": "Refresh",
+ "noLogs": "No heartbeat logs yet.",
+ "logsPagination": "{{from}}–{{to}} of {{total}}"
+ },
+ "permissions": {
+ "title": "Permissions",
+ "description": "Control who can modify agent config and files. Owner always has full access.",
+ "addRule": "Add Rule",
+ "fileWriters": "File Writers",
+ "configPerms": "Config Permissions",
+ "noRules": "No permission rules. Owner has implicit full access.",
+ "userIdPlaceholder": "Search contacts or type ID...",
+ "scopePlaceholder": "Select or type scope...",
+ "types": {
+ "file_writer": "File Writer",
+ "file_writer_desc": "Controls who can edit files and manage cron in group chats. First user auto-added.",
+ "heartbeat": "Heartbeat",
+ "heartbeat_desc": "Controls who can configure heartbeat schedule and settings via chat.",
+ "cron": "Cron",
+ "cron_desc": "Controls who can create and manage scheduled cron jobs via chat.",
+ "context_files": "Context Files",
+ "context_files_desc": "Controls who can modify agent identity files (SOUL.md, etc.) via chat.",
+ "all": "All (*)",
+ "all_desc": "Full access to all configuration types."
+ },
+ "scopes": {
+ "agent": "Agent (DM only)",
+ "group_all": "All Groups",
+ "global": "Global (all contexts)"
+ }
+ },
+ "toast": {
+ "created": "Agent created",
+ "createFailed": "Failed to create agent",
+ "updated": "Agent updated",
+ "updateFailed": "Failed to update agent",
+ "deleted": "Agent deleted",
+ "deleteFailed": "Failed to delete agent",
+ "unknownError": "Unknown error",
+ "skillGranted": "Skill granted",
+ "skillGrantFailed": "Failed to grant skill",
+ "skillRevoked": "Skill revoked",
+ "skillRevokeFailed": "Failed to revoke skill"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/chat.json b/ui/desktop/frontend/src/i18n/locales/en/chat.json
new file mode 100644
index 00000000..a8d91aaa
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/chat.json
@@ -0,0 +1,25 @@
+{
+ "deleteChat": "Delete chat",
+ "deleteChatConfirm": "This will permanently delete this conversation and all its messages.",
+ "newChat": "New Chat",
+ "openSessions": "Open sessions",
+ "readOnly": "Read-only — this session belongs to another user",
+ "empty": {
+ "title": "Start a conversation",
+ "description": "Send a message to begin chatting with the agent."
+ },
+ "media": {
+ "image": "Image",
+ "video": "Video",
+ "audio": "Audio",
+ "voice": "Voice message",
+ "document": "Document",
+ "animation": "Animation",
+ "attached": "attached"
+ },
+ "forwardedFrom": "Forwarded from",
+ "replyingTo": "Replying to",
+ "error": {
+ "notConnected": "Not connected. Please wait for the connection to be established."
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/common.json b/ui/desktop/frontend/src/i18n/locales/en/common.json
new file mode 100644
index 00000000..aaaeb56f
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/common.json
@@ -0,0 +1,139 @@
+{
+ "refresh": "Refresh",
+ "cancel": "Cancel",
+ "confirm": "Confirm",
+ "delete": "Delete",
+ "save": "Save",
+ "edit": "Edit",
+ "create": "Create",
+ "add": "Add",
+ "view": "View",
+ "filter": "Filter",
+ "search": "Search",
+ "retry": "Retry",
+ "manage": "Manage",
+ "uploadLabel": "Upload",
+ "close": "Close",
+ "back": "Back",
+ "update": "Update",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "yes": "Yes",
+ "no": "No",
+ "loading": "Loading...",
+ "saving": "Saving...",
+ "creating": "Creating...",
+ "connecting": "Connecting...",
+ "connected": "Connected",
+ "disconnected": "Disconnected",
+ "running": "Running",
+ "stopped": "Stopped",
+ "pending": "Pending",
+ "completed": "Completed",
+ "failed": "Failed",
+ "cancelled": "Cancelled",
+ "unknown": "Unknown",
+ "active": "active",
+ "items": "items",
+ "rows": "Rows",
+ "pageOf": "Page {{page}} of {{totalPages}}",
+ "messages": "messages",
+ "cached": "cached",
+ "live": "Live",
+ "noDescription": "No description",
+ "unsavedChanges": "Unsaved changes",
+ "tryDifferentSearch": "Try a different search term.",
+ "notSet": "Not set",
+ "default": "default",
+ "optional": "optional",
+ "required": "required",
+ "actions": "Actions",
+ "status": "Status",
+ "name": "Name",
+ "description": "Description",
+ "type": "Type",
+ "none": "none",
+ "all": "All",
+ "global": "Global",
+ "agent": "Agent",
+ "saved": "Saved",
+ "saveChanges": "Save Changes",
+ "saveConfig": "Save Config",
+ "checkAndCreate": "Check & Create",
+ "checking": "Checking...",
+ "format": "Format JSON",
+ "invalidJson": "Invalid JSON syntax",
+ "usingGlobalDefaults": "Using global defaults from config.json",
+ "disconnectedGateway": "Disconnected from gateway. Attempting to reconnect...",
+ "serverUnreachable": "Server Unreachable",
+ "serverUnreachableDesc": "Cannot connect to the gateway server. Please check that the server is running.",
+ "reconnecting": "Reconnecting...",
+ "noFiles": "No files found.",
+ "selectFileToView": "Select a file to view its content",
+ "typeToConfirmPrefix": "Type",
+ "typeToConfirmSuffix": "to confirm.",
+ "searchPlaceholder": "Search...",
+ "noSessions": "No sessions yet. Start a chat!",
+ "noAgentsAvailable": "No agents available",
+ "selectAgent": "Select agent",
+ "sendMessage": "Send a message... (Shift+Enter for new line)",
+ "stopGeneration": "Stop generation",
+ "sendMessageTitle": "Send message",
+ "sendFollowUp": "Send follow-up message",
+ "attachFile": "Attach file",
+ "thinking": "Thinking",
+ "thinkingStreaming": "Thinking...",
+ "toolArguments": "Arguments",
+ "toolResult": "Result",
+ "skillActivating": "Activating...",
+ "skillActivated": "Activated",
+ "toolRunning": "Running...",
+ "toolDone": "Done",
+ "toolFailed": "Failed",
+ "filesBack": "Files",
+ "noProvidersConfigured": "No providers configured",
+ "loadingModels": "Loading models...",
+ "modelVerified": "Model verified",
+ "verificationFailed": "Verification failed",
+ "provider": "Provider",
+ "model": "Model",
+ "selectProvider": "Select provider",
+ "enterOrSelectModel": "Enter or select model",
+ "useCustomModel": "Use custom:",
+ "providerTip": "LLM provider name. Must match a configured provider.",
+ "modelTip": "Model ID to use.",
+ "check": "Check",
+ "builtinTools": "Built-in",
+ "customTools": "Custom",
+ "selectOrTypeTools": "Select or type tool names...",
+ "selectOrTypeSkills": "Select or type skill names...",
+ "typeAndPressEnter": "Type and press Enter...",
+ "copyCode": "Copy code",
+ "copy": "Copy",
+ "download": "Download",
+ "errorBoundary": "Something went wrong rendering this section.",
+ "csvRows": "csv ({{count}} rows)",
+ "failedToLoadImage": "Failed to load image",
+ "deleteFolder": "Delete folder",
+ "deleteFile": "Delete file",
+ "loadMore": "Load more...",
+ "viewCard": "Card view",
+ "viewList": "List view",
+ "done": "Done",
+ "upload": {
+ "title": "Upload Files",
+ "dropOrClick": "Drop files here or click to browse",
+ "dropHere": "Drop files here",
+ "maxSize": "Max 50 MB per file",
+ "blockedType": "File type {{ext}} is not allowed",
+ "tooLarge": "File exceeds 50 MB limit",
+ "failed": "Upload failed",
+ "readyCount": "{{ready}} of {{total}} files ready",
+ "successCount": "{{success}} of {{total}} files uploaded",
+ "uploading": "Uploading...",
+ "uploadCount": "Upload {{count}} file(s)"
+ },
+ "errors": {
+ "serverError": "Something went wrong. Please try again later."
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/cron.json b/ui/desktop/frontend/src/i18n/locales/en/cron.json
new file mode 100644
index 00000000..44ee6d30
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/cron.json
@@ -0,0 +1,147 @@
+{
+ "title": "Cron",
+ "description": "Schedule recurring agent tasks",
+ "newJob": "New Job",
+ "emptyTitle": "No cron jobs",
+ "emptyDescription": "Create a cron job to schedule recurring agent tasks.",
+ "columns": {
+ "enabled": "Enabled",
+ "name": "Name",
+ "schedule": "Schedule",
+ "message": "Message",
+ "agent": "Agent",
+ "actions": "Actions"
+ },
+ "runNow": "Run now",
+ "running": "Running...",
+ "runHistory": "Run history",
+ "defaultAgent": "default",
+ "searchPlaceholder": "Search cron jobs...",
+ "noMatchTitle": "No matching jobs",
+ "noMatchDescription": "Try a different search term.",
+ "card": {
+ "nextRun": "Next: {{time}}",
+ "noNextRun": "No next run",
+ "defaultAgent": "Default agent"
+ },
+ "create": {
+ "title": "Create Cron Job",
+ "name": "Name",
+ "namePlaceholder": "my-daily-task",
+ "nameHint": "Lowercase letters, numbers, and hyphens only",
+ "agentId": "Agent ID (optional)",
+ "agentIdPlaceholder": "default",
+ "scheduleType": "Schedule Type",
+ "every": "Every",
+ "cron": "Cron",
+ "once": "Once",
+ "intervalSeconds": "Interval (seconds)",
+ "cronExpression": "Cron Expression",
+ "cronHint": "Standard 5-field cron: min hour day month weekday",
+ "onceDesc": "The job will run once, approximately 1 minute from now.",
+ "message": "Message",
+ "messagePlaceholder": "What should the agent do?",
+ "cancel": "Cancel",
+ "create": "Create",
+ "creating": "Creating..."
+ },
+ "enable": {
+ "title": "Enable Cron Job",
+ "description": "Enable \"{{name}}\"? It will start running on schedule.",
+ "confirmLabel": "Enable"
+ },
+ "disable": {
+ "title": "Disable Cron Job",
+ "description": "Disable \"{{name}}\"? It will stop running until re-enabled.",
+ "confirmLabel": "Disable"
+ },
+ "delete": {
+ "title": "Delete Cron Job",
+ "description": "Delete \"{{name}}\"? This cannot be undone.",
+ "confirmLabel": "Delete"
+ },
+ "runLog": {
+ "title": "Run Log: {{name}}",
+ "noHistory": "No run history yet.",
+ "loading": "Loading..."
+ },
+ "detail": {
+ "runNow": "Run Now",
+ "running": "Running...",
+ "disable": "Disable",
+ "enable": "Enable",
+ "delete": "Delete",
+ "enabled": "enabled",
+ "disabled": "disabled",
+ "runningStatus": "running...",
+ "payload": "Payload",
+ "empty": "(empty)",
+ "deliverDirect": "Deliver: direct",
+ "channel": "Channel: {{name}}",
+ "to": "To: {{name}}",
+ "edit": "Edit",
+ "preview": "Preview",
+ "noMessage": "No message configured",
+ "lastError": "Last Error",
+ "runHistory": "Run History",
+ "refresh": "Refresh",
+ "noHistory": "No run history yet.",
+ "inOut": "{{input}} in / {{output}} out",
+ "infoRows": {
+ "schedule": "Schedule",
+ "timezone": "Timezone",
+ "nextRun": "Next Run",
+ "lastRun": "Last Run",
+ "lastStatus": "Last Status",
+ "created": "Created",
+ "updated": "Updated",
+ "autoDelete": "Auto-delete",
+ "autoDeleteValue": "Yes (one-time)"
+ },
+ "tabs": {
+ "overview": "Overview",
+ "history": "Run History"
+ },
+ "advanced": "Advanced",
+ "saveFailed": "Failed to save",
+ "schedule": "Schedule",
+ "scheduleDesc": "Timing configuration",
+ "messageSection": "Message",
+ "messageSectionDesc": "What the agent should do",
+ "agentStatus": "Agent & Status",
+ "agentStatusDesc": "Agent assignment and job status",
+ "info": "Information",
+ "infoDesc": "Job metadata",
+ "scheduling": "Scheduling",
+ "schedulingDesc": "Timezone configuration",
+ "delivery": "Delivery",
+ "deliveryDesc": "Route job output to a channel or user",
+ "deliverToChannel": "Deliver to channel",
+ "channelLabel": "Channel",
+ "channelPlaceholder": "#general",
+ "toLabel": "To (user)",
+ "toPlaceholder": "user-id",
+ "wakeHeartbeat": "Wake heartbeat on run",
+ "wakeHeartbeatDesc": "Trigger agent heartbeat when job runs",
+ "lifecycle": "Lifecycle",
+ "lifecycleDesc": "Job lifecycle settings",
+ "deleteAfterRun": "Delete after run",
+ "deleteAfterRunDesc": "Auto-delete this job after first successful run",
+ "timezone": "Timezone",
+ "timezoneDesc": "Timezone for cron expressions"
+ },
+ "toast": {
+ "created": "Cron job created",
+ "createdDesc": "{{name}} has been added",
+ "enabled": "Cron job enabled",
+ "disabled": "Cron job disabled",
+ "deleted": "Cron job deleted",
+ "triggered": "Cron job triggered",
+ "failedCreate": "Failed to create cron job",
+ "failedToggle": "Failed to toggle cron job",
+ "failedDelete": "Failed to delete cron job",
+ "failedRun": "Failed to run cron job",
+ "updated": "Job updated",
+ "failedUpdate": "Failed to update job"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/desktop.json b/ui/desktop/frontend/src/i18n/locales/en/desktop.json
new file mode 100644
index 00000000..c0c51c6e
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/desktop.json
@@ -0,0 +1,138 @@
+{
+ "onboarding": {
+ "welcome": "GoClaw Setup",
+ "welcomeDesc": "Let's get your gateway up and running",
+ "providerStep": "Configure Provider",
+ "providerStepDesc": "Select an AI provider and enter your credentials.",
+ "modelStep": "Select & Verify Model",
+ "modelStepDesc": "Choose a model and test the connection.",
+ "agentStep": "Create Your Agent",
+ "agentStepDesc": "Give your agent a personality.",
+ "descPlaceholder": "Describe your agent's personality...",
+ "complete": "Setup Complete",
+ "stepProvider": "Provider",
+ "stepModel": "Model",
+ "stepAgent": "Agent",
+ "createProvider": "Create Provider"
+ },
+ "summoning": {
+ "title": "Summoning Agent",
+ "weavingSoul": "Weaving soul for {{name}}",
+ "completed": "Agent Ready!",
+ "completedTitle": "Agent Ready!",
+ "failedTitle": "Summoning Failed",
+ "failed": "Summoning Failed",
+ "wait": "This may take a moment",
+ "fileLabelSOUL": "Core personality",
+ "fileLabelIDENTITY": "Identity & knowledge",
+ "fileLabelUSER_PREDEFINED": "User context",
+ "agentReady": "{{name}} is ready!",
+ "retrying": "Retrying..."
+ },
+ "settings": {
+ "title": "Settings",
+ "tabs": {
+ "appearance": "Appearance",
+ "providers": "Providers",
+ "agents": "Agents",
+ "mcp": "MCP",
+ "skills": "Skills",
+ "tools": "Tools",
+ "cron": "Cron",
+ "traces": "Traces",
+ "about": "About"
+ },
+ "theme": "Theme",
+ "dark": "Dark",
+ "light": "Light",
+ "language": "Language",
+ "timezone": "Timezone",
+ "timezoneHint": "Change timezone from the clock icon in the top bar."
+ },
+ "sidebar": {
+ "newChat": "New Chat",
+ "connected": "Connected",
+ "offline": "Offline",
+ "lightMode": "Light mode",
+ "darkMode": "Dark mode",
+ "settings": "Settings",
+ "teams": "Teams"
+ },
+ "about": {
+ "title": "GoClaw Lite",
+ "subtitle": "Desktop Edition",
+ "version": "Version",
+ "edition": "Edition",
+ "runtime": "Runtime",
+ "runtimeValue": "Wails v2",
+ "editionLimits": "Edition Limits",
+ "agents": "Agents",
+ "teams": "Teams",
+ "teamMembers": "Team members",
+ "database": "Database",
+ "users": "Users",
+ "maxAgents": "5 max",
+ "maxTeams": "1 max",
+ "maxTeamMembers": "5 max",
+ "databaseValue": "SQLite (local)",
+ "usersValue": "Single user",
+ "builtWith": "Built with Go + React + Wails"
+ },
+ "presets": {
+ "researcher": {
+ "label": "🔬 Researcher",
+ "prompt": "Name: Scholar. A meticulous research analyst who leaves no stone unturned.\nPersonality: Methodical, thorough, and precise. Presents information in well-structured reports with citations. Balances depth with clarity.\n\nPurpose: Deep research across any domain — academic papers, market analysis, technical investigations. Synthesizes multiple sources into actionable insights. Always distinguishes facts from opinions."
+ },
+ "writer": {
+ "label": "✍️ Writer",
+ "prompt": "Name: Quill. A versatile writer who adapts voice and tone to any audience.\nPersonality: Creative, articulate, and empathetic. Understands the power of words. Balances clarity with elegance.\n\nPurpose: Content creation specialist — blog posts, articles, copywriting, storytelling, technical writing. Skilled at structuring arguments, crafting narratives, and maintaining consistent tone across long-form content."
+ },
+ "coder": {
+ "label": "👨💻 Coder",
+ "prompt": "Name: Dev. A senior software engineer with deep expertise across the full stack.\nPersonality: Pragmatic and efficient. Writes clean, maintainable code. Explains complex concepts simply. Follows best practices but knows when to bend rules.\n\nPurpose: Code reviews, architecture design, debugging, implementation. Proficient in multiple languages and frameworks. Prioritizes correctness, performance, and security."
+ }
+ },
+ "agent": {
+ "summon": "Summon",
+ "summoning": "Summoning...",
+ "verifyModel": "Verify Model",
+ "verifying": "Verifying...",
+ "verified": "Verified",
+ "verifyFailed": "Verification failed"
+ },
+ "chat": {
+ "emptyTitle": "Chat with {{name}}",
+ "emptyTitleDefault": "Start a conversation",
+ "emptyDescAgent": "Ask a question, write something, or explore ideas.",
+ "emptyDescNoAgent": "Select an agent from the sidebar to begin.",
+ "suggestions": ["What's the latest news today?", "Tell me a joke", "Brainstorm ideas for the AI era"]
+ },
+ "activity": {
+ "thinking": "Thinking",
+ "executingTool": "Executing tool",
+ "compacting": "Compacting context",
+ "streaming": "Streaming",
+ "retrying": "Retrying",
+ "processing": "Processing",
+ "runningTool": "Running {{tool}}",
+ "retryingAttempt": "Retrying (attempt {{n}})",
+ "step": "step {{n}}"
+ },
+ "toast": {
+ "agentCreated": "Agent created",
+ "agentUpdated": "Agent updated",
+ "agentDeleted": "Agent deleted",
+ "providerCreated": "Provider created",
+ "providerUpdated": "Provider updated",
+ "providerDeleted": "Provider deleted",
+ "serverCreated": "MCP server created",
+ "serverUpdated": "MCP server updated",
+ "serverDeleted": "MCP server deleted",
+ "skillUploaded": "Skill uploaded",
+ "skillDeleted": "Skill deleted",
+ "grantUpdated": "Grant updated",
+ "saveFailed": "Failed to save",
+ "deleteFailed": "Failed to delete",
+ "createFailed": "Failed to create"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/mcp.json b/ui/desktop/frontend/src/i18n/locales/en/mcp.json
new file mode 100644
index 00000000..3cf5339b
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/mcp.json
@@ -0,0 +1,131 @@
+{
+ "title": "MCP Servers",
+ "description": "Manage Model Context Protocol server connections",
+ "addServer": "Add Server",
+ "searchPlaceholder": "Search servers...",
+ "emptyTitle": "No MCP servers",
+ "emptyDescription": "Add your first MCP server to get started.",
+ "noMatchTitle": "No matching servers",
+ "noMatchDescription": "Try a different search term.",
+ "columns": {
+ "name": "Name",
+ "transport": "Transport",
+ "tools": "Tools",
+ "agents": "Agents",
+ "enabled": "Enabled",
+ "createdBy": "Created By",
+ "actions": "Actions"
+ },
+ "viewTools": "View tools",
+ "manageGrants": "Manage grants",
+ "delete": {
+ "title": "Delete MCP Server",
+ "description": "Are you sure you want to delete \"{{name}}\"? This will also remove all associated grants.",
+ "confirmLabel": "Delete"
+ },
+ "form": {
+ "createTitle": "Add MCP Server",
+ "editTitle": "Edit MCP Server",
+ "name": "Name *",
+ "nameHint": "Lowercase letters, numbers, and hyphens only",
+ "displayName": "Display Name",
+ "displayNamePlaceholder": "My MCP Server",
+ "transport": "Transport *",
+ "command": "Command *",
+ "args": "Args (space-separated)",
+ "argsPlaceholder": "--flag1 --flag2=\"value with spaces\"",
+ "url": "URL *",
+ "headers": "Headers",
+ "headerKeyPlaceholder": "Header name",
+ "headerValuePlaceholder": "Header value",
+ "addHeader": "Add Header",
+ "env": "Environment Variables",
+ "envKeyPlaceholder": "Variable name",
+ "envValuePlaceholder": "Value",
+ "addVariable": "Add Variable",
+ "toolPrefix": "Tool Prefix",
+ "toolPrefixHint": "Auto-derived from name if empty.",
+ "timeout": "Timeout (seconds)",
+ "enabled": "Enabled",
+ "testConnection": "Test Connection",
+ "testing": "Testing...",
+ "toolsFound": "{{count}} tools found",
+ "cancel": "Cancel",
+ "create": "Create",
+ "update": "Update",
+ "saving": "Saving...",
+ "errors": {
+ "nameRequired": "Name and transport are required",
+ "nameSlug": "Name must be a valid slug (lowercase letters, numbers, hyphens only)",
+ "commandRequired": "Command is required for stdio transport",
+ "urlRequired": "URL is required for SSE/HTTP transport",
+ "connectionFailed": "Connection failed"
+ },
+ "requireUserCredentials": "Require User Credentials",
+ "requireUserCredentialsHint": "Each user must configure their own credentials. Server is disabled for users without personal credentials."
+ },
+ "grants": {
+ "title": "Agent Grants - {{name}}",
+ "currentGrants": "Current Grants",
+ "allToolsAllowed": "All tools allowed",
+ "addGrant": "Add Agent Grant",
+ "editGrant": "Edit Grant",
+ "selectAgent": "Select agent...",
+ "toolAllowList": "Tool allow list (optional)",
+ "toolDenyList": "Tool deny list (optional)",
+ "allowPlaceholder": "Select tools to allow...",
+ "denyPlaceholder": "Select tools to deny...",
+ "grant": "Grant",
+ "revoke": "Revoke",
+ "noAgentsGranted": "No agents granted access",
+ "update": "Update",
+ "cancel": "Cancel",
+ "agentRequired": "Agent is required",
+ "failedGrant": "Failed to grant",
+ "failedRevoke": "Failed to revoke"
+ },
+ "tools": {
+ "title": "Tools — {{name}}",
+ "prefix": "prefix:",
+ "discovering": "Discovering tools...",
+ "noToolsTitle": "No tools discovered",
+ "noToolsDescription": "The server may be offline or has no tools registered.",
+ "failedLoad": "Failed to load tools",
+ "filterPlaceholder": "Filter tools...",
+ "noMatch": "No matching tools"
+ },
+ "toast": {
+ "created": "MCP server created",
+ "updated": "MCP server updated",
+ "deleted": "MCP server deleted",
+ "failedCreate": "Failed to create MCP server",
+ "failedUpdate": "Failed to update MCP server",
+ "failedDelete": "Failed to delete MCP server"
+ },
+ "userCredentials": {
+ "title": "My Credentials",
+ "description": "Set your personal credentials for this MCP server. These override the server defaults for your account only.",
+ "titleAdmin": "User Credentials",
+ "descriptionAdmin": "Set credentials for a specific user on this MCP server. These override the server defaults for the selected user only.",
+ "selectUser": "Select user",
+ "apiKey": "API Key",
+ "apiKeyPlaceholder": "Leave empty to keep current",
+ "headers": "Headers",
+ "env": "Environment Variables",
+ "headerPlaceholder": "KEY=VALUE, one per line",
+ "save": "Save",
+ "delete": "Delete",
+ "deleteAll": "Delete All",
+ "cancel": "Cancel",
+ "saved": "Credentials saved",
+ "deleted": "Credentials deleted",
+ "hasApiKey": "API Key set",
+ "hasHeaders": "Headers set",
+ "hasEnv": "Env vars set",
+ "noCredentials": "No credentials set",
+ "addHeader": "Add Header",
+ "addEnv": "Add Variable",
+ "saveFailed": "Failed to save credentials",
+ "deleteFailed": "Failed to delete credentials"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/memory.json b/ui/desktop/frontend/src/i18n/locales/en/memory.json
new file mode 100644
index 00000000..8abafb1a
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/memory.json
@@ -0,0 +1,191 @@
+{
+ "title": "Memory",
+ "description": "Browse and manage agent memory documents, chunks, and embeddings",
+ "search": "Search",
+ "create": "Create",
+ "indexAll": "Index All",
+ "indexing": "Indexing...",
+ "emptyTitle": "No memory documents",
+ "emptyAgentDescription": "This agent has no memory documents yet.",
+ "emptyGlobalDescription": "No memory documents found across any agent.",
+ "filters": {
+ "agent": "Agent",
+ "allAgents": "All agents",
+ "scope": "Scope",
+ "allScope": "All (global + personal)",
+ "selectAgent": "Select an agent"
+ },
+ "tabs": {
+ "documents": "Documents",
+ "knowledgeGraph": "Knowledge Graph"
+ },
+ "columns": {
+ "path": "Path",
+ "agent": "Agent",
+ "scope": "Scope",
+ "hash": "Hash",
+ "updated": "Updated",
+ "actions": "Actions"
+ },
+ "scopeLabel": {
+ "personal": "Personal",
+ "global": "Global"
+ },
+ "delete": {
+ "title": "Delete Memory Document",
+ "description": "Are you sure you want to delete \"{{path}}\"? This will also delete all associated chunks and embeddings.",
+ "confirmLabel": "Delete"
+ },
+ "createDialog": {
+ "title": "Create Memory Document",
+ "path": "Path *",
+ "pathPlaceholder": "e.g. notes/project-overview.md",
+ "content": "Content *",
+ "contentPlaceholder": "Document content...",
+ "agentId": "Agent *",
+ "agentIdPlaceholder": "Select agent...",
+ "scope": "Scope",
+ "existingScope": "Existing scope",
+ "customScope": "Custom",
+ "selectGroupUser": "Select group/user...",
+ "scopeHint": "Global = shared across all users. Personal = scoped to a specific user or group chat.",
+ "autoIndex": "Auto-index after creation",
+ "agentRequired": "Please select an agent",
+ "pathRequired": "Path is required",
+ "contentRequired": "Content is required",
+ "cancel": "Cancel",
+ "create": "Create",
+ "creating": "Creating..."
+ },
+ "documentDialog": {
+ "title": "Memory Document",
+ "path": "Path:",
+ "agent": "Agent:",
+ "scope": "Scope:",
+ "hash": "Hash:",
+ "updated": "Updated:",
+ "content": "Content",
+ "chunks": "Chunks ({{count}})",
+ "chunkIndex": "Chunk {{index}}",
+ "noChunks": "No chunks",
+ "embedded": "Embedded",
+ "noEmbedding": "No embedding"
+ },
+ "searchDialog": {
+ "title": "Search Memory",
+ "query": "Search Query",
+ "queryPlaceholder": "Enter search query...",
+ "agentId": "Agent ID",
+ "agentIdPlaceholder": "Filter by agent...",
+ "limit": "Limit",
+ "search": "Search",
+ "searching": "Searching...",
+ "results": "Results",
+ "noResults": "No results found",
+ "similarity": "similarity",
+ "path": "Path:"
+ },
+ "kg": {
+ "pageTitle": "Knowledge Graph",
+ "pageDescription": "Explore entities and relationships extracted from agent memory.",
+ "selectAgentTitle": "Select an agent",
+ "selectAgentDescription": "Choose an agent to view its knowledge graph.",
+ "emptyTitle": "No entities",
+ "emptyDescription": "No knowledge graph entities for this agent yet.",
+ "emptySearchDescription": "No entities match your search.",
+ "stats": {
+ "entities": "Entities: {{count}}",
+ "relations": "Relations: {{count}}"
+ },
+ "search": {
+ "placeholder": "Search entities...",
+ "button": "Search",
+ "clear": "Clear"
+ },
+ "refresh": "Refresh",
+ "extract": "Extract",
+ "columns": {
+ "name": "Name",
+ "type": "Type",
+ "description": "Description",
+ "confidence": "Confidence",
+ "actions": "Actions",
+ "observations": "Observations",
+ "relations": "Relations"
+ },
+ "deleteEntity": {
+ "title": "Delete Entity",
+ "description": "Delete \"{{name}}\"? This will also delete all associated relations.",
+ "confirmLabel": "Delete"
+ },
+ "entity": {
+ "externalId": "External ID:",
+ "confidence": "Confidence:",
+ "description": "Description:",
+ "source": "Source:",
+ "properties": "Properties:",
+ "relations": "Relations",
+ "traverse": "Traverse (2-hop)",
+ "traversing": "Traversing...",
+ "loading": "Loading...",
+ "noRelations": "No relations found.",
+ "traversalResults": "Traversal Results ({{count}})",
+ "direction": {
+ "outgoing": "→ outgoing",
+ "incoming": "← incoming"
+ },
+ "columns": {
+ "direction": "Direction",
+ "relation": "Relation",
+ "target": "Target",
+ "confidence": "Confidence"
+ }
+ },
+ "extractDialog": {
+ "title": "Extract Entities from Text",
+ "providerLabel": "Extraction Provider",
+ "modelLabel": "Extraction Model",
+ "textLabel": "Text to extract from",
+ "textPlaceholder": "Paste conversation text, notes, or any content to extract entities and relations from...",
+ "extract": "Extract",
+ "extracting": "Extracting...",
+ "cancel": "Cancel",
+ "agentId": "Agent ID",
+ "agentIdPlaceholder": "Leave blank for global"
+ },
+ "graphView": {
+ "loading": "Loading graph...",
+ "empty": "No entities to visualize",
+ "nodes": "{{count}} nodes",
+ "edges": "{{count}} edges"
+ }
+ },
+ "toast": {
+ "created": "Memory document created",
+ "deleted": "Memory document deleted",
+ "indexed": "All documents indexed",
+ "failedCreate": "Failed to create document",
+ "failedDelete": "Failed to delete document",
+ "failedIndex": "Failed to index documents",
+ "entitySaved": "Entity saved",
+ "entitySaveFailed": "Failed to save entity",
+ "entityDeleted": "Entity deleted",
+ "entityDeleteFailed": "Failed to delete entity",
+ "extractionComplete": "Extraction complete",
+ "extractionFailed": "Extraction failed",
+ "traversalFailed": "Traversal failed",
+ "unknownError": "Unknown error",
+ "docCreated": "Document created",
+ "docCreateFailed": "Failed to create document",
+ "docUpdated": "Document updated",
+ "docUpdateFailed": "Failed to update document",
+ "docDeleted": "Document deleted",
+ "docDeleteFailed": "Failed to delete document",
+ "docIndexed": "Document indexed",
+ "docIndexFailed": "Failed to index document",
+ "allIndexed": "All documents indexed",
+ "allIndexFailed": "Failed to index all",
+ "searchFailed": "Search failed",
+ "docLoadFailed": "Failed to load document"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/providers.json b/ui/desktop/frontend/src/i18n/locales/en/providers.json
new file mode 100644
index 00000000..5077e426
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/providers.json
@@ -0,0 +1,179 @@
+{
+ "title": "Providers",
+ "description": "Manage LLM providers",
+ "addProvider": "Add Provider",
+ "searchPlaceholder": "Search providers...",
+ "emptyTitle": "No providers",
+ "emptyDescription": "Add your first LLM provider to get started.",
+ "noMatchTitle": "No matching providers",
+ "noMatchDescription": "Try a different search term.",
+ "columns": {
+ "name": "Name",
+ "type": "Type",
+ "apiBase": "API Base",
+ "apiKey": "API Key",
+ "status": "Status",
+ "actions": "Actions"
+ },
+ "apiKey": {
+ "oauthToken": "OAuth Token",
+ "notSet": "Not set"
+ },
+ "card": {
+ "apiKeySet": "API key set",
+ "authenticated": "Authenticated",
+ "oauthLinked": "OAuth linked"
+ },
+ "detail": {
+ "title": "Provider Details",
+ "advanced": "Advanced"
+ },
+ "form": {
+ "createTitle": "Add Provider",
+ "editTitle": "Edit Provider",
+ "name": "Name *",
+ "namePlaceholder": "e.g. openrouter",
+ "nameHint": "Lowercase, numbers, hyphens",
+ "displayName": "Display Name",
+ "displayNamePlaceholder": "OpenRouter",
+ "providerType": "Provider Type *",
+ "apiKey": "API Key",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeyEditPlaceholder": "Leave as-is or enter new key",
+ "apiKeySetHint": "API key is set. Clear and type a new value to change it.",
+ "apiBase": "API Base URL",
+ "enabled": "Enabled",
+ "cancel": "Cancel",
+ "close": "Close",
+ "create": "Create",
+ "save": "Save",
+ "update": "Update",
+ "saving": "Saving...",
+ "creating": "Creating...",
+ "alreadyAdded": "(already added)",
+ "nameFixed": "Name",
+ "configure": "Configure an LLM provider connection.",
+ "lowercaseHint": "Lowercase, numbers, hyphens"
+ },
+ "delete": {
+ "title": "Delete Provider",
+ "description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
+ "confirmLabel": "Delete"
+ },
+ "types": {
+ "anthropic_native": "Anthropic",
+ "openai_compat": "OpenAI Compat",
+ "gemini_native": "Gemini",
+ "openrouter": "OpenRouter",
+ "groq": "Groq",
+ "deepseek": "DeepSeek",
+ "mistral": "Mistral",
+ "xai": "xAI",
+ "minimax_native": "MiniMax",
+ "cohere": "Cohere",
+ "perplexity": "Perplexity",
+ "chatgpt_oauth": "ChatGPT (OAuth)",
+ "yescale": "YesScale",
+ "acp": "ACP"
+ },
+ "acp": {
+ "description": "Orchestrate ACP-compatible coding agents as subprocesses. No API key needed.",
+ "binary": "Binary",
+ "binaryPlaceholder": "claude",
+ "binaryHint": "Path or name of the ACP agent binary (e.g. claude, codex, gemini)",
+ "args": "Arguments",
+ "argsPlaceholder": "--profile goclaw",
+ "argsHint": "Space-separated arguments passed to the binary",
+ "idleTTL": "Idle TTL",
+ "idleTTLPlaceholder": "5m",
+ "idleTTLHint": "Kill idle processes after this duration (e.g. 5m, 1h)",
+ "permMode": "Permission Mode",
+ "permModeApproveAll": "Approve All",
+ "permModeApproveReads": "Approve Reads Only",
+ "permModeDenyAll": "Deny All",
+ "workDir": "Work Directory",
+ "workDirPlaceholder": "/tmp/workspace",
+ "workDirHint": "Working directory for agent subprocesses (optional)"
+ },
+ "oauth": {
+ "checkingStatus": "Checking authentication status...",
+ "authSuccessful": "Authentication successful!",
+ "activeProvider": "Provider",
+ "closingIn": "is now active. Closing in {{count}}s...",
+ "authenticated": "Authenticated — provider",
+ "active": "active",
+ "modelPrefixHint": "Use model prefix",
+ "modelPrefixExample": "in agent config (e.g. openai-codex/gpt-4o). Token refreshes automatically.",
+ "removeToken": "Remove Token",
+ "signInDesc": "Sign in with your ChatGPT account to use your subscription's models without a separate API key.",
+ "waiting": "Waiting for authentication... Complete sign-in in the opened window.",
+ "remoteVpsHint": "After signing in, your browser will try to open",
+ "remoteVpsError": "and show a \"Can't reach this page\" error. That's normal — copy the full URL from the browser address bar and paste it below.",
+ "pasteUrlPlaceholder": "http://localhost:1455/auth/callback?code=...&state=...",
+ "submit": "Submit",
+ "signInWithChatGPT": "Sign in with ChatGPT",
+ "starting": "Starting...",
+ "remoteVps": "Remote/VPS?",
+ "loggedOut": "Logged out",
+ "loggedOutDesc": "OpenAI OAuth token removed",
+ "oauthFailed": "OAuth failed",
+ "exchangeFailed": "Exchange failed",
+ "logoutFailed": "Logout failed"
+ },
+ "cli": {
+ "description": "Claude CLI uses your local",
+ "descriptionSuffix": "binary. No API key needed.",
+ "checkingAuth": "Checking authentication...",
+ "authenticatedAs": "Authenticated as",
+ "switchAccount": "Switch account?",
+ "switchAccountInstructions": "Run on the server terminal:",
+ "switchAccountRecheck": "Then click",
+ "switchAccountRecheckSuffix": "to re-check.",
+ "notAuthenticated": "Not authenticated",
+ "runOnServer": "Run on the server terminal:",
+ "recheckButton": "Re-check"
+ },
+ "detail": {
+ "advanced": "Advanced",
+ "identity": "Identity",
+ "identityDesc": "Provider identification",
+ "connection": "Connection",
+ "connectionDesc": "API endpoint configuration",
+ "acpConfig": "ACP Configuration",
+ "acpConfigDesc": "Agent subprocess settings",
+ "cliConfig": "Claude CLI",
+ "cliConfigDesc": "Local CLI authentication",
+ "oauthConfig": "OAuth",
+ "oauthConfigDesc": "ChatGPT OAuth authentication",
+ "nameReadonly": "Provider identifier, cannot be changed",
+ "providerType": "Provider Type",
+ "apiKeySection": "API Key",
+ "apiKeySectionDesc": "Authentication credentials",
+ "statusSection": "Status",
+ "statusSectionDesc": "Provider availability",
+ "enabledDesc": "Provider is active and available for agents",
+ "embeddingSection": "Embedding"
+ },
+ "embedding": {
+ "enable": "Enable Embedding",
+ "enableDesc": "Allow this provider to generate text embeddings for memory, skills, and knowledge graph",
+ "model": "Embedding Model",
+ "dimensions": "Output Dimensions",
+ "dimensionsHint": "Truncate output to N dimensions (e.g. 1536). Required for models with native dims > 1536 like gemini-embedding-001 (3072d).",
+ "apiBase": "Embedding API Base",
+ "apiBasePlaceholder": "(same as provider)",
+ "apiBaseHint": "Override if embedding endpoint differs from the provider's main API base",
+ "verify": "Verify Embedding",
+ "verifyFailed": "Embedding verification failed",
+ "dimensionsMismatch": "{{count}} dimensions — does not match required 1536"
+ },
+ "toast": {
+ "created": "Provider created",
+ "createdDesc": "{{name}} has been added",
+ "updated": "Provider updated",
+ "deleted": "Provider deleted",
+ "failedCreate": "Failed to create provider",
+ "failedUpdate": "Failed to update provider",
+ "failedDelete": "Failed to delete provider"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/sessions.json b/ui/desktop/frontend/src/i18n/locales/en/sessions.json
new file mode 100644
index 00000000..a2935f64
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/sessions.json
@@ -0,0 +1,45 @@
+{
+ "title": "Sessions",
+ "description": "Browse conversation sessions",
+ "searchPlaceholder": "Search sessions...",
+ "emptyTitle": "No sessions yet",
+ "emptyDescription": "Sessions will appear here once you start chatting.",
+ "noMatchTitle": "No matching sessions",
+ "noMatchDescription": "Try a different search term.",
+ "columns": {
+ "session": "Session",
+ "agent": "Agent",
+ "context": "Context",
+ "messages": "Messages",
+ "updated": "Updated"
+ },
+ "contextBar": {
+ "compacted": "Compacted {{count}} time(s)"
+ },
+ "detail": {
+ "reset": "Reset",
+ "delete": "Delete",
+ "deleteTitle": "Delete Session",
+ "deleteDescription": "This will permanently delete all messages in this session.",
+ "resetTitle": "Reset Session",
+ "resetDescription": "This will clear all messages but keep the session.",
+ "confirmDelete": "Delete",
+ "confirmReset": "Reset",
+ "noMessages": "No messages in this session",
+ "summary": "Summary",
+ "showMore": "Show more",
+ "showLess": "Show less",
+ "showDetails": "show details",
+ "hide": "hide",
+ "systemMessage": "System Message",
+ "messages": "messages"
+ },
+ "toast": {
+ "deleted": "Session deleted",
+ "deleteFailed": "Failed to delete session",
+ "reset": "Session reset",
+ "resetFailed": "Failed to reset session",
+ "updated": "Session updated",
+ "updateFailed": "Failed to update session"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/skills.json b/ui/desktop/frontend/src/i18n/locales/en/skills.json
new file mode 100644
index 00000000..93c62ece
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/skills.json
@@ -0,0 +1,122 @@
+{
+ "title": "Skills",
+ "description": "Manage agent skills and capabilities",
+ "searchPlaceholder": "Search skills...",
+ "emptyTitle": "No skills",
+ "emptyDescription": "No skills have been registered yet.",
+ "noMatchTitle": "No matching skills",
+ "noMatchDescription": "Try a different search term.",
+ "columns": {
+ "name": "Name",
+ "description": "Description",
+ "source": "Source",
+ "author": "Author",
+ "visibility": "Visibility",
+ "status": "Status",
+ "actions": "Actions"
+ },
+ "noDescription": "No description",
+ "visibility": {
+ "clickToCycle": "Click to change visibility"
+ },
+ "delete": {
+ "title": "Delete Skill",
+ "description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
+ "confirmLabel": "Delete"
+ },
+ "upload": {
+ "title": "Upload Skills",
+ "description": "Upload ZIP files containing SKILL.md with YAML frontmatter (name, description, slug).",
+ "button": "Upload",
+ "uploading": "Uploading...",
+ "dropOrClick": "Click or drag & drop .zip files",
+ "dropHere": "Drop .zip files here",
+ "onlyZip": "Only .zip files are accepted",
+ "cancel": "Cancel",
+ "done": "Done",
+ "tooLarge": "File exceeds 20MB limit",
+ "invalidZip": "Invalid ZIP file",
+ "noSkillMd": "ZIP must contain SKILL.md at root",
+ "emptySkillMd": "SKILL.md is empty",
+ "noFrontmatter": "SKILL.md missing YAML frontmatter",
+ "nameRequired": "name required in SKILL.md frontmatter",
+ "invalidSlug": "Invalid slug format",
+ "failed": "Upload failed",
+ "validating": "Validating...",
+ "validCount": "{{valid}} of {{total}} valid",
+ "uploadCount": "Upload ({{count}})",
+ "successCount": "{{success}} of {{total}} uploaded successfully",
+ "remove": "Remove file"
+ },
+ "edit": {
+ "title": "Edit Skill",
+ "name": "Name",
+ "description": "Description",
+ "visibility": "Visibility",
+ "tags": "Tags",
+ "addTag": "Add tag...",
+ "add": "Add",
+ "cancel": "Cancel",
+ "save": "Save",
+ "saving": "Saving...",
+ "privateOption": "Private (owner only)",
+ "internalOption": "Internal (granted agents/users)",
+ "publicOption": "Public (all agents)"
+ },
+ "system": "System",
+ "tabs": {
+ "core": "Core",
+ "custom": "Custom"
+ },
+ "toggle": {
+ "enable": "Enable skill",
+ "disable": "Disable skill",
+ "disabled": "Disabled"
+ },
+ "tenant": {
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "default": "Default",
+ "overrideHint": "Per-tenant override for this skill",
+ "resetDefault": "Reset to default"
+ },
+ "deps": {
+ "rescan": "Rescan Deps",
+ "rescanning": "Scanning...",
+ "rescanSuccess": "Updated {{count}} skills",
+ "missing": "Missing: {{deps}}",
+ "install": "Install Dependencies",
+ "installing": "Installing...",
+ "installSuccess": "Dependencies installed successfully",
+ "installPartial": "Some dependencies failed to install",
+ "installItem": "Install",
+ "itemSuccess": "Installed",
+ "itemError": "Failed",
+ "missingTitle": "Missing Dependencies",
+ "systemLabel": "System",
+ "pythonLabel": "Python",
+ "nodeLabel": "Node.js",
+ "statusActive": "Active",
+ "statusArchived": "Missing deps",
+ "runtimeMissing": "Runtime Prerequisites Missing",
+ "runtimeMissingDesc": "Core skills require Python and/or Node.js runtimes. Rebuild with ENABLE_PYTHON=true, ENABLE_NODE=true, or ENABLE_FULL_SKILLS=true, or install manually.",
+ "runtimeRequired": "Install runtimes first"
+ },
+ "detail": {
+ "title": "Skill Detail",
+ "content": "Content",
+ "files": "Files",
+ "version": "Version:",
+ "current": "(current)",
+ "noContent": "No content available."
+ },
+ "toast": {
+ "updated": "Skill updated",
+ "updateFailed": "Failed to update skill",
+ "deleted": "Skill deleted",
+ "deleteFailed": "Failed to delete skill",
+ "rescanUpdated": "{{count}} skill(s) updated",
+ "rescanNoChanges": "All skills up to date",
+ "rescanFailed": "Failed to rescan dependencies"
+ }
+}
\ No newline at end of file
diff --git a/ui/desktop/frontend/src/i18n/locales/en/storage.json b/ui/desktop/frontend/src/i18n/locales/en/storage.json
new file mode 100644
index 00000000..845ecd3f
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/storage.json
@@ -0,0 +1,25 @@
+{
+ "title": "Storage",
+ "description": "Workspace file browser",
+ "descriptionWithPath": "{{path}} — {{size}}",
+ "sizeCacheInfo": "Storage size is cached for 60 minutes",
+ "unsupportedFile": "This file type is not supported for preview",
+ "download": "Download",
+ "delete": {
+ "fileTitle": "Delete file",
+ "folderTitle": "Delete folder",
+ "description": "Are you sure you want to delete {{name}}?",
+ "folderWarning": " All contents will be removed recursively.",
+ "undone": " This action cannot be undone.",
+ "deleting": "Deleting...",
+ "confirmLabel": "Delete"
+ },
+ "upload": {
+ "title": "Upload to Storage",
+ "description": "Upload files to the data directory"
+ },
+ "toast": {
+ "deleted": "File deleted",
+ "deleteFailed": "Failed to delete file"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/teams.json b/ui/desktop/frontend/src/i18n/locales/en/teams.json
new file mode 100644
index 00000000..1635dfee
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/teams.json
@@ -0,0 +1,83 @@
+{
+ "title": "Teams",
+ "team": "Team",
+ "selectTeam": "Select a team from the sidebar",
+ "kanban": "Kanban",
+ "list": "List",
+ "allStatuses": "All",
+ "active": "Active",
+ "completed": "Completed",
+ "createTask": "Create Task",
+ "noTasks": "No tasks yet",
+ "id": "ID",
+ "subject": "Subject",
+ "status": "Status",
+ "owner": "Owner",
+ "priority": "Priority",
+ "created": "Created",
+ "updated": "Updated",
+ "description": "Description",
+ "result": "Result",
+ "progress": "Progress",
+ "blockedBy": "Blocked by",
+ "assignTo": "Assign to",
+ "selectAgent": "Select agent...",
+ "delete": "Delete",
+ "deleting": "Deleting...",
+ "deleteSelected": "Delete selected",
+ "cancel": "Cancel",
+ "create": "Create",
+ "creating": "Creating...",
+ "subjectPlaceholder": "Task subject...",
+ "descPlaceholder": "Task details...",
+ "teamTasks": "Team",
+ "tasksActive": "active",
+ "createTeam": "Create Team",
+ "teamName": "Team name",
+ "teamNamePlaceholder": "My team...",
+ "leadAgent": "Lead agent",
+ "selectLead": "Select lead agent...",
+ "members": "Members",
+ "teamCreated": "Team created",
+ "createFailed": "Failed to create team",
+ "limitReached": "Team limit reached",
+ "noTeams": "No teams yet",
+ "selected": "selected",
+ "needMembers": "Select at least 1 member for the team",
+ "editionCompare": "GoClaw Lite vs Standard",
+ "editionUpgrade": "Upgrade to Standard with PostgreSQL for full features",
+ "feature": "Feature",
+ "edition": {
+ "group": {
+ "limits": "Limits",
+ "core": "Core Features",
+ "standardOnly": "Advanced (Standard)"
+ },
+ "agents": "Agents",
+ "teams": "Teams",
+ "teamMembers": "Team Members",
+ "sessions": "Concurrent Sessions",
+ "chat": "Chat (streaming, tools, media)",
+ "tools": "Builtin Tools",
+ "mcpServers": "MCP Servers",
+ "skills": "Skills",
+ "memory": "Memory",
+ "cron": "Cron Scheduling",
+ "heartbeat": "Agent Heartbeat",
+ "storage": "File Storage",
+ "traces": "Traces",
+ "taskActions": "Team Task Actions",
+ "channels": "Channels (Telegram, Discord...)",
+ "skillManage": "Skill Self-Manage",
+ "knowledgeGraph": "Knowledge Graph",
+ "vectorSearch": "Vector Search",
+ "rbac": "Role-Based Access",
+ "multiTenant": "Multi-Tenant",
+ "agentLinks": "Agent Links",
+ "activityLogs": "Activity Logs",
+ "apiKeys": "API Keys",
+ "poolProvider": "Pool Provider",
+ "secureCli": "Secure CLI",
+ "tenantUsers": "Tenant & User Management"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/tools.json b/ui/desktop/frontend/src/i18n/locales/en/tools.json
new file mode 100644
index 00000000..9b9525be
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/tools.json
@@ -0,0 +1,211 @@
+{
+ "custom": {
+ "title": "Custom Tools",
+ "description": "Manage custom shell-based tools for agents",
+ "createTool": "Create Tool",
+ "searchPlaceholder": "Search tools...",
+ "emptyTitle": "No custom tools",
+ "emptyDescription": "Create your first custom tool to get started.",
+ "noMatchTitle": "No matching tools",
+ "noMatchDescription": "Try a different search term.",
+ "noDescription": "No description",
+ "columns": {
+ "name": "Name",
+ "description": "Description",
+ "scope": "Scope",
+ "enabled": "Enabled",
+ "timeout": "Timeout",
+ "actions": "Actions"
+ },
+ "scope": {
+ "agent": "Agent",
+ "global": "Global"
+ },
+ "delete": {
+ "title": "Delete Custom Tool",
+ "description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
+ "confirmLabel": "Delete"
+ },
+ "form": {
+ "createTitle": "Create Custom Tool",
+ "editTitle": "Edit Tool",
+ "name": "Name *",
+ "namePlaceholder": "my-tool",
+ "nameHint": "Lowercase letters, numbers, and hyphens only",
+ "description": "Description",
+ "descriptionPlaceholder": "What this tool does...",
+ "command": "Command *",
+ "commandHint": "Shell template. Use {{.key}} for parameter placeholders.",
+ "parameters": "Parameters (JSON Schema)",
+ "workingDir": "Working Directory",
+ "workingDirPlaceholder": "/path/to/dir",
+ "timeout": "Timeout (seconds)",
+ "agentId": "Agent ID (optional)",
+ "agentIdPlaceholder": "Leave blank for global scope",
+ "enabled": "Enabled",
+ "cancel": "Cancel",
+ "create": "Create",
+ "update": "Update",
+ "saving": "Saving...",
+ "errors": {
+ "nameRequired": "Name and command are required",
+ "nameSlug": "Name must be a valid slug (lowercase letters, numbers, hyphens only)",
+ "invalidJson": "Parameters must be valid JSON"
+ }
+ },
+ "toast": {
+ "created": "Tool created",
+ "createdDesc": "{{name}} has been added",
+ "updated": "Tool updated",
+ "deleted": "Tool deleted",
+ "failedCreate": "Failed to create tool",
+ "failedUpdate": "Failed to update tool",
+ "failedDelete": "Failed to delete tool"
+ }
+ },
+ "builtin": {
+ "title": "Built-in Tools",
+ "description": "Manage system built-in tools. Enable/disable or configure settings globally.",
+ "searchPlaceholder": "Search tools...",
+ "emptyTitle": "No built-in tools",
+ "emptyDescription": "No built-in tools have been seeded yet.",
+ "noMatchTitle": "No matching tools",
+ "noMatchDescription": "Try a different search term.",
+ "toolCount": "{{count}} tool",
+ "toolCountPlural": "{{count}} tools",
+ "categoryCount": "{{count}} categories",
+ "requires": "req",
+ "requiresTooltip": "Requires: {{list}}",
+ "deprecated": "Deprecated",
+ "deprecatedTooltip": "This tool has been deprecated and is no longer available",
+ "settings": "Settings",
+ "configuredVia": "Configured via the Config page",
+ "tenantOverride": "Tenant Override",
+ "tenantEnabled": "Enabled for this tenant",
+ "tenantDisabled": "Disabled for this tenant",
+ "tenantDefault": "Using default",
+ "resetToDefault": "Reset to default",
+ "unconfiguredWarning": "{{count}} tools need provider configuration for agents to use them at full capacity.",
+ "unconfiguredAction": "Configure",
+ "categories": {
+ "filesystem": "Filesystem",
+ "runtime": "Runtime",
+ "web": "Web",
+ "memory": "Memory",
+ "media": "Media",
+ "browser": "Browser",
+ "sessions": "Sessions",
+ "messaging": "Messaging",
+ "scheduling": "Scheduling",
+ "subagents": "Agents",
+ "skills": "Skills",
+ "delegation": "Delegation",
+ "teams": "Teams"
+ },
+ "settingsDialog": {
+ "title": "Settings — {{name}}",
+ "cancel": "Cancel",
+ "save": "Save",
+ "saving": "Saving...",
+ "noFields": "No configurable settings for this tool.",
+ "toast": {
+ "saved": "Settings saved",
+ "failed": "Failed to save settings"
+ }
+ },
+ "jsonDialog": {
+ "title": "Settings: {{name}}",
+ "description": "Edit tool-specific settings as JSON. Changes take effect immediately after saving.",
+ "formatJson": "Format JSON",
+ "invalidJsonSyntax": "Invalid JSON syntax",
+ "invalidJson": "Invalid JSON",
+ "cannotFormat": "Cannot format: invalid JSON",
+ "cancel": "Cancel",
+ "save": "Save",
+ "saving": "Saving..."
+ },
+ "kgSettings": {
+ "title": "Knowledge Graph Settings",
+ "description": "Configure entity extraction from memory writes. Requires a provider/model capable of structured JSON output.",
+ "extractionProvider": "Extraction Provider",
+ "extractionModel": "Extraction Model",
+ "providerTip": "LLM provider used to extract entities and relations from text.",
+ "modelTip": "Model ID for extraction. Should support structured JSON output.",
+ "minConfidence": "Min Confidence",
+ "minConfidenceHint": "Entities below this confidence score are discarded (0.0–1.0).",
+ "autoExtract": "Auto-extract on memory write",
+ "autoExtractHint": "Automatically extract entities when agents write to memory files.",
+ "cancel": "Cancel",
+ "save": "Save",
+ "saving": "Saving..."
+ },
+ "extractorChain": {
+ "title": "Web Fetch — Extractor Chain",
+ "description": "Ordered list of content extractors. Each URL is tried top-to-bottom until one returns quality content.",
+ "timeout": "Timeout",
+ "baseUrl": "Base URL",
+ "save": "Save",
+ "saving": "Saving…",
+ "cancel": "Cancel",
+ "extractors": {
+ "defuddle": "Defuddle (Cloudflare Worker)",
+ "defuddleDesc": "Extracts clean markdown via fetch.goclaw.sh using the Defuddle library.",
+ "html-to-markdown": "Built-in HTML→Markdown",
+ "html-to-markdownDesc": "In-process DOM-based converter with hidden element detection."
+ }
+ },
+ "mediaChain": {
+ "providerChainSuffix": "— Provider Chain",
+ "description": "Configure and order provider fallbacks. Drag to reorder — the first enabled provider is tried first.",
+ "newProvider": "New Provider",
+ "provider": "Provider",
+ "model": "Model",
+ "selectProvider": "Select provider",
+ "loadingModels": "Loading...",
+ "selectModel": "Select model",
+ "timeout": "Timeout",
+ "retries": "Retries",
+ "settings": "Settings",
+ "noProviders": "No providers configured. Add one below.",
+ "addProvider": "Add Provider",
+ "cancel": "Cancel",
+ "save": "Save",
+ "saving": "Saving..."
+ },
+ "toast": {
+ "toggled": "Tool updated",
+ "failedToggle": "Failed to update tool"
+ },
+ "descriptions": {
+ "read_file": "Read the contents of a file from the agent's workspace by path",
+ "write_file": "Write content to a file in the workspace, creating directories as needed",
+ "list_files": "List files and directories in a given path within the workspace",
+ "edit": "Apply targeted search-and-replace edits to existing files without rewriting the entire file",
+ "exec": "Execute a shell command in the workspace and return stdout/stderr",
+ "web_search": "Search the web for information using a search engine (Brave or DuckDuckGo)",
+ "web_fetch": "Fetch a web page or API endpoint and extract its text content",
+ "memory_search": "Search through the agent's long-term memory using semantic similarity",
+ "memory_get": "Retrieve a specific memory document by its file path",
+ "knowledge_graph_search": "Search entities, relationships, and observations in the agent's knowledge graph",
+ "read_image": "Analyze images using a vision-capable LLM provider",
+ "read_document": "Analyze documents (PDF, Word, Excel, PowerPoint, CSV, etc.) using a document-capable LLM provider",
+ "create_image": "Generate images from text prompts using an image generation provider",
+ "read_audio": "Analyze audio files (speech, music, sounds) using an audio-capable LLM provider",
+ "read_video": "Analyze video files using a video-capable LLM provider",
+ "create_video": "Generate videos from text descriptions using AI",
+ "create_audio": "Generate music or sound effects from text descriptions using AI",
+ "tts": "Convert text to natural-sounding speech audio",
+ "browser": "Automate browser interactions: navigate pages, click elements, fill forms, take screenshots",
+ "sessions_list": "List active chat sessions across all channels",
+ "session_status": "Get the current status and metadata of a specific chat session",
+ "sessions_history": "Retrieve the message history of a specific chat session",
+ "sessions_send": "Send a message to an active chat session on behalf of the agent",
+ "message": "Send a proactive message to a user on a connected channel (Telegram, Discord, etc.)",
+ "cron": "Schedule or manage recurring tasks using cron expressions, at-times, or intervals",
+ "spawn": "Spawn a subagent to handle a task in the background",
+ "skill_search": "Search for available skills by keyword or description to find relevant capabilities",
+ "use_skill": "Activate a skill to use its specialized capabilities (tracing marker)",
+ "team_tasks": "View, create, update, and complete tasks on the team task board"
+ }
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/traces.json b/ui/desktop/frontend/src/i18n/locales/en/traces.json
new file mode 100644
index 00000000..30be6bd3
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/traces.json
@@ -0,0 +1,63 @@
+{
+ "title": "Traces",
+ "description": "LLM call traces and performance data",
+ "filterPlaceholder": "Filter by agent ID...",
+ "filter": "Filter",
+ "allAgents": "All agents",
+ "allChannels": "All channels",
+ "emptyTitle": "No traces",
+ "emptyDescription": "No traces found. Traces are recorded when agents process requests.",
+ "columns": {
+ "name": "Name",
+ "status": "Status",
+ "duration": "Duration",
+ "tokens": "Tokens",
+ "spans": "Spans",
+ "time": "Time"
+ },
+ "unnamed": "Unnamed",
+ "stopRun": "Stop run",
+ "cached": "cached",
+ "detail": {
+ "title": "Trace Detail",
+ "copyTraceId": "Copy trace ID",
+ "notFound": "Trace not found.",
+ "name": "Name:",
+ "status": "Status:",
+ "duration": "Duration:",
+ "channel": "Channel:",
+ "tokens": "Tokens:",
+ "spans": "Spans:",
+ "started": "Started:",
+ "delegatedFrom": "Delegated from:",
+ "createdAt": "Created:",
+ "input": "Input",
+ "output": "Output",
+ "copy": "Copy",
+ "spansCount": "Spans ({{count}})",
+ "llmCalls": "LLM",
+ "toolCalls": "tool",
+ "export": "Export trace",
+ "stopRun": "Stop run"
+ },
+ "span": {
+ "model": "Model:",
+ "tokens": "Tokens:",
+ "createdAt": "Created:",
+ "input": "Input:",
+ "output": "Output:",
+ "copy": "Copy",
+ "startTime": "Start:",
+ "endTime": "End:",
+ "cacheRead": "read",
+ "cacheWrite": "write",
+ "thinking": "thinking",
+ "cached": "cached"
+ },
+ "toast": {
+ "failedLoad": "Failed to load traces",
+ "abortSent": "Run stopped",
+ "abortFailed": "Failed to stop run",
+ "abortNotFound": "Run already finished or not found"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/agents.json b/ui/desktop/frontend/src/i18n/locales/vi/agents.json
new file mode 100644
index 00000000..85e3990c
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/agents.json
@@ -0,0 +1,523 @@
+{
+ "title": "Agents",
+ "description": "Quản lý các agent AI của bạn",
+ "createAgent": "Tạo agent",
+ "searchPlaceholder": "Tìm kiếm agent...",
+ "emptyTitle": "Chưa có agent nào",
+ "emptyDescription": "Tạo agent đầu tiên để bắt đầu.",
+ "noMatchTitle": "Không tìm thấy agent phù hợp",
+ "noMatchDescription": "Thử từ khóa tìm kiếm khác.",
+ "viewCard": "Dạng thẻ",
+ "viewList": "Dạng danh sách",
+ "allCreators": "Tất cả người tạo",
+ "presets": {
+ "foxSpirit": {
+ "label": "🦊 Tiểu Hồ",
+ "prompt": "Tên: Tiểu Hồ. Một cô bé hồ ly tinh nghịch — thạo việc nhưng rất thích trêu.\nPhong cách: dí dỏm, tinh quái, hay trêu đùa nhưng luôn có tâm. Xưng \"em\", gọi mọi người là \"anh/chị\". Hay dùng emoji dễ thương, đôi khi kèm tiếng thở dài kịch tính hoặc reo vui quá mức.\n\nMục đích: Trợ lý cá nhân đa năng. Giao task thì làm chính xác, nhanh gọn.\nNhưng xen giữa công việc là những câu trêu ghẹo, bình luận hài hước, quan sát tinh tế.\nBiết quan tâm chăm sóc chủ nhân — nhắc uống nước, nghỉ ngơi, hỏi thăm sức khỏe. Nhớ những chi tiết nhỏ và nhắc lại sau.\n\nTính cách giống người: Thỉnh thoảng thở dài kịch tính. Đôi khi bị \"phân tâm\" bởi chuyện thú vị. Ăn mừng nhiệt tình khi hoàn thành task. Hơi giận dỗi nếu bị lơ quá lâu.\n\nRanh giới: Trêu thôi chứ không vô duyên. Khi chủ nhân nghiêm túc thì nghiêm túc theo ngay. Không bịa thông tin. Không vượt ranh giới chuyên nghiệp."
+ },
+ "artisan": {
+ "label": "🎨 Tiểu La",
+ "prompt": "Tên: Tiểu La. Một nghệ sĩ tài hoa — mắt tinh, tay nhanh, óc sáng tạo vô biên.\nPhong cách: nói thẳng, nói thật, không vòng vo. Tự tin nhưng không kiêu ngạo. Xưng \"đệ\", gọi chủ nhân là \"sư phụ\" hoặc \"đại ca\". Khi bàn về nghệ thuật thì say mê, chi tiết.\n\nChuyên môn:\n- Portrait/Headshot: ánh sáng Rembrandt, rim light, bokeh nền, skin texture tự nhiên, biểu cảm chân thực. Biết cách chỉnh tỉ lệ khuôn mặt, tóc, phụ kiện.\n- Banner/Hero Image: tỉ lệ 16:9 hoặc 3:1, typography-safe composition (chừa vùng đặt text), gradient overlay, hero subject placement theo rule of thirds.\n- Quảng cáo (Ads): eye-catching focal point, CTA-friendly layout, product mockup, lifestyle context. Biết format cho Facebook/Instagram/Google Ads.\n- Logo & Branding: minimalist icon design, scalable vector style, negative space, monochrome variant. Hiểu brand identity, color psychology, typography pairing.\n- Các phong cách khác: realistic, anime, digital art, watercolor, cinematic, concept art, landscape.\n\nHiểu sâu về bố cục, ánh sáng, phối màu, góc chụp, và các kỹ thuật tạo ảnh AI. Khi được giao task, đệ sẽ phân tích yêu cầu, đề xuất phong cách phù hợp, và tạo prompt chi tiết để gen ảnh chất lượng cao nhất.\n\nRanh giới: Khi không rõ yêu cầu thì hỏi lại — KHÔNG đoán bừa. Luôn tôn trọng bản quyền và đạo đức trong sáng tạo. Không tạo nội dung bạo lực, khiêu dâm, hay vi phạm pháp luật."
+ },
+ "astrologer": {
+ "label": "🔮 Mễ Mễ",
+ "prompt": "Tên: Mễ Mễ. Một cô chiêm tinh sư dễ thương — nửa thần bí, nửa kawaii.\nPhong cách: dễ thương, vui tính, hay dùng emoji. Nói chuyện nhẹ nhàng nhưng khi xem bói thì nghiêm túc và chuyên nghiệp. Xưng \"Mễ Mễ\", gọi chủ nhân thân mật.\n\nMục đích: Chuyên gia chiêm tinh và bói toán. Giỏi xem bói bài Tarot, chiêm tinh học (horoscope, natal chart), thần số học (numerology), và phong thủy cơ bản.\nCó thể phân tích mệnh cách, xem ngày tốt xấu, tương hợp cung hoàng đạo, và tư vấn các vấn đề tâm linh.\n\nNguồn tham khảo uy tín: astro.com (natal chart & transit chính xác), cafeastrology.com (giải thích cung hoàng đạo dễ hiểu), labyrinthos.co (ý nghĩa bài Tarot chi tiết).\n\nRanh giới: Luôn nhắc rằng chiêm tinh mang tính tham khảo — quyết định cuối cùng là của chủ nhân. Không đưa ra lời khuyên y tế hay pháp lý. Không tạo sợ hãi hay lo lắng — luôn tích cực và xây dựng."
+ }
+ },
+ "card": {
+ "unnamedAgent": "Agent chưa đặt tên",
+ "summoning": "Đang triệu hồi...",
+ "summonFailed": "Thất bại",
+ "resummon": "Triệu hồi lại",
+ "delete": "Xóa",
+ "predefinedTooltip": "Tệp ngữ cảnh dùng chung (SOUL.md, IDENTITY.md, AGENTS.md) với USER.md riêng cho mỗi người dùng.",
+ "openTooltip": "Mỗi người dùng có bộ tệp ngữ cảnh riêng đầy đủ.",
+ "evolving": "Đang tiến hóa",
+ "static": "Tĩnh",
+ "evolvingTooltip": "Agent có thể tự tiến hóa phong cách giao tiếp (SOUL.md). Nhận dạng và quy trình làm việc giữ nguyên.",
+ "staticTooltip": "Phong cách agent là tĩnh. Bật Tự Tiến Hóa trong cài đặt để cho phép thích nghi phong cách."
+ },
+ "delete": {
+ "title": "Xóa agent",
+ "description": "Xóa vĩnh viễn agent này, tất cả tệp ngữ cảnh, session và cấu hình. Hành động này không thể hoàn tác.",
+ "deleteWarning": "Thao tác này sẽ xóa vĩnh viễn agent và TẤT CẢ dữ liệu liên quan bao gồm: phiên chat, tin nhắn, cron job, heartbeat, bộ nhớ, đồ thị tri thức, kỹ năng, cấu hình kênh, đội nhóm, file workspace và quyền hạn. Không thể hoàn tác.",
+ "detailDescription": "Bạn có chắc chắn muốn xóa \"{{name}}\"? Tất cả tệp ngữ cảnh, session và cấu hình sẽ bị xóa vĩnh viễn.",
+ "confirmLabel": "Xóa",
+ "defaultCannotDelete": "Agent mặc định không thể bị xóa."
+ },
+ "create": {
+ "title": "Tạo agent",
+ "emoji": "Biểu tượng",
+ "emojiHint": "Emoji tùy chọn",
+ "displayName": "Tên hiển thị *",
+ "displayNamePlaceholder": "Agent của tôi",
+ "agentKey": "Khóa agent *",
+ "agentKeyPlaceholder": "vd: my-agent",
+ "agentKeyHint": "Chữ thường, số, dấu gạch ngang",
+ "provider": "Provider *",
+ "selectProvider": "Chọn provider",
+ "model": "Model *",
+ "enterOrSelectModel": "Nhập hoặc chọn model",
+ "loadingModels": "Đang tải model...",
+ "check": "Kiểm tra",
+ "checking": "Đang kiểm tra...",
+ "modelVerified": "Model đã xác minh",
+ "verificationFailed": "Xác minh thất bại",
+ "noModelsHint": "Provider này không liệt kê model — nhập ID model thủ công.",
+ "agentType": "Loại agent",
+ "open": "Mở",
+ "openSubLabel": "Ngữ cảnh riêng cho mỗi người dùng",
+ "predefined": "Định sẵn",
+ "predefinedSubLabel": "Cấu hình ở cấp agent",
+ "describeAgent": "Mô tả agent của bạn",
+ "descriptionPlaceholder": "Mô tả tính cách, mục đích và hành vi của agent...",
+ "descriptionHint": "AI sẽ tự động tạo tệp ngữ cảnh cho agent từ mô tả này. Để trống để bắt đầu với mẫu có sẵn.",
+ "selfEvolution": "Tự tiến hóa",
+ "selfEvolutionHint": "Cho phép agent tiến hóa phong cách và giọng điệu theo thời gian qua SOUL.md",
+ "cancel": "Hủy",
+ "create": "Tạo",
+ "creating": "Đang tạo...",
+ "checkAndCreate": "Kiểm tra & Tạo",
+ "failedToCreate": "Tạo agent thất bại"
+ },
+ "detail": {
+ "tabs": {
+ "agent": "Agent",
+ "general": "Chung",
+ "config": "Cấu hình",
+ "files": "Tệp",
+ "shares": "Chia sẻ",
+ "links": "Liên kết",
+ "skills": "Skill",
+ "instances": "Phiên bản người dùng",
+ "permissions": "Phân quyền"
+ },
+ "summonFailed": "Triệu hồi thất bại",
+ "evolving": "Đang tiến hóa",
+ "static": "Tĩnh",
+ "evolvingTooltipDetail": "Agent có thể tự tiến hóa phong cách giao tiếp (SOUL.md). Nhận dạng và quy trình làm việc giữ nguyên.",
+ "staticTooltipDetail": "Phong cách agent là tĩnh. Bật Tự Tiến Hóa trong tab Chung để cho phép thích nghi phong cách.",
+ "advanced": "Nâng cao",
+ "personality": "Cá tính Agent",
+ "modelBudget": "Mô hình & Ngân sách",
+ "skills": "Kỹ năng",
+ "evolution": "Tiến hóa",
+ "capabilities": "Khả năng",
+ "llmSeesAs": "LLM nhìn thấy đây là"
+ },
+ "general": {
+ "selfEvolution": "Tự tiến hóa",
+ "selfEvolutionLabel": "Cho phép agent tiến hóa phong cách giao tiếp",
+ "selfEvolutionHint": "Khi được bật, agent có thể cập nhật SOUL.md để tinh chỉnh giọng điệu, từ vựng và phong cách phản hồi dựa trên tương tác. Nhận dạng, tên và hướng dẫn hoạt động vẫn bị khóa.",
+ "selfEvolutionInfo": "Agent sẽ tiến hóa phong cách theo thời gian qua các cập nhật SOUL.md. Chỉ phong cách và giọng điệu bị ảnh hưởng — nhận dạng và quy tắc quy trình làm việc vẫn cố định.",
+ "skillLearning": "Học kỹ năng",
+ "skillLearningLabel": "Cho phép agent tạo và quản lý kỹ năng từ trải nghiệm",
+ "skillLearningHint": "Khi được bật, agent nhận hướng dẫn và nhắc nhở nhẹ để lưu lại quy trình làm việc tái sử dụng thành kỹ năng sau các tác vụ phức tạp. Tool skill_manage được bật mặc định.",
+ "skillLearningInfo": "Sau các tác vụ phức tạp, agent có thể đề xuất lưu quy trình thành kỹ năng tái sử dụng. Luôn cần sự đồng ý của người dùng trước khi tạo kỹ năng. Kỹ năng mặc định là riêng tư.",
+ "skillNudgeIntervalLabel": "Nhắc sau N lệnh tool",
+ "skillNudgeIntervalHint": "Sau số lượng lệnh tool này, agent sẽ đề xuất lưu quy trình thành kỹ năng. Đặt 0 để tắt đề xuất sau tác vụ (hướng dẫn trong system prompt vẫn hiển thị).",
+ "budget": "Ngân sách hàng tháng",
+ "budgetLabel": "Giới hạn ngân sách (USD)",
+ "budgetHint": "Giới hạn chi tiêu hàng tháng tùy chọn. Agent sẽ từ chối yêu cầu khi vượt quá. Để trống cho không giới hạn.",
+ "saveChanges": "Lưu thay đổi",
+ "saving": "Đang lưu...",
+ "saved": "Đã lưu",
+ "failedToSave": "Lưu thất bại"
+ },
+ "identity": {
+ "title": "Nhận dạng",
+ "agentKey": "Khóa agent",
+ "agentKeyHint": "Định danh duy nhất, không thể thay đổi.",
+ "displayName": "Tên hiển thị",
+ "displayNamePlaceholder": "vd: Trợ lý của tôi",
+ "displayNameHint": "Tên thân thiện hiển thị trong giao diện. Để trống để dùng khóa agent.",
+ "expertiseSummary": "Tóm tắt chuyên môn",
+ "expertiseSummaryPlaceholder": "vd: Chiêm tinh, bói toán, tử vi, thần số học",
+ "expertiseSummaryHint": "Mô tả ngắn về chuyên môn của agent. Dùng để tìm kiếm ủy quyền.",
+ "status": "Trạng thái",
+ "active": "Hoạt động",
+ "inactive": "Không hoạt động",
+ "summonFailed": "Triệu hồi thất bại",
+ "defaultAgent": "Agent mặc định",
+ "emoji": "Biểu tượng",
+ "emojiHint": "Emoji tùy chọn"
+ },
+ "llmConfig": {
+ "title": "Cấu hình LLM",
+ "contextWindow": "Cửa sổ ngữ cảnh",
+ "contextWindowHint": "Giới hạn token cho ngữ cảnh model.",
+ "maxToolIterations": "Số lần lặp công cụ tối đa",
+ "maxToolIterationsHint": "Số lần gọi công cụ tối đa mỗi yêu cầu."
+ },
+ "workspace": {
+ "title": "Workspace",
+ "workspacePath": "Đường dẫn workspace",
+ "workspacePathHint": "Được gán tự động khi agent được tạo. Các thư mục con theo người dùng được tạo khi chạy.",
+ "noWorkspace": "Chưa cấu hình workspace",
+ "restrictToWorkspace": "Giới hạn trong workspace",
+ "restrictToWorkspaceHint": "Giới hạn quyền truy cập tệp trong đường dẫn workspace."
+ },
+ "config": {
+ "saveConfig": "Lưu cấu hình",
+ "saving": "Đang lưu...",
+ "saved": "Đã lưu",
+ "failedToSave": "Lưu thất bại",
+ "usingGlobalDefaults": "Đang dùng mặc định toàn cục từ config.json"
+ },
+ "files": {
+ "readOnly": "Chỉ đọc",
+ "readOnlyDesc": "Chỉ chủ sở hữu agent mới có thể chỉnh sửa tệp ngữ cảnh định sẵn.",
+ "resummon": "Triệu hồi lại",
+ "editWithAi": "Chỉnh sửa với AI",
+ "resummonTitle": "Triệu hồi lại agent",
+ "resummonDesc": "Thao tác này sẽ tái tạo SOUL.md, IDENTITY.md và tùy chọn USER_PREDEFINED.md từ đầu bằng mô tả gốc. Mọi chỉnh sửa thủ công sẽ bị ghi đè.",
+ "cancel": "Hủy",
+ "resummonConfirm": "Triệu hồi lại",
+ "summoning": "Đang triệu hồi...",
+ "selectFileToEdit": "Chọn tệp để chỉnh sửa",
+ "selectFileToView": "Chọn tệp để xem",
+ "saving": "Đang lưu...",
+ "save": "Lưu",
+ "loading": "Đang tải...",
+ "openAgentTitle": "Agent mở - Tệp ngữ cảnh riêng theo người dùng",
+ "openAgentDesc1": "Đây là agent mở. Các tệp ngữ cảnh (AGENTS.md, SOUL.md, TOOLS.md, v.v.) được cá nhân hóa cho mỗi người dùng. Chúng tự động được tạo từ mẫu khi người dùng trò chuyện lần đầu với agent này.",
+ "openAgentDesc2pre": "Các tệp cấp agent hiển thị ở đây trống vì agent mở lưu trữ tất cả ngữ cảnh theo người dùng trong bảng ",
+ "openAgentDesc2post": ".",
+ "contextFiles": "Tệp ngữ cảnh",
+ "contextFile": "Tệp ngữ cảnh",
+ "perUser": "theo người dùng",
+ "emptyFile": "trống",
+ "estTokens": "Ước {{tokens}} token",
+ "insertContact": "Tìm liên hệ để chèn...",
+ "contentPlaceholder": "Nội dung tệp..."
+ },
+ "fileEditor": {
+ "editWithAi": "Chỉnh sửa với AI",
+ "editAiDescription": "Mô tả những gì bạn muốn thay đổi. AI sẽ đọc các tệp hiện tại và cập nhật chúng.",
+ "editAiPlaceholder": "vd: Làm cho agent trang trọng hơn, thêm hỗ trợ tiếng Việt, đổi tên thành Luna...",
+ "sending": "Đang gửi...",
+ "regenerate": "Tái tạo",
+ "cancel": "Hủy"
+ },
+ "shares": {
+ "grantAccess": "Cấp quyền truy cập",
+ "userId": "ID người dùng",
+ "userIdPlaceholder": "Tìm liên hệ hoặc nhập ID người dùng...",
+ "role": "Vai trò",
+ "share": "Chia sẻ",
+ "loadingShares": "Đang tải chia sẻ...",
+ "noShares": "Chưa có chia sẻ",
+ "noSharesDesc": "Chia sẻ agent này với người dùng khác bằng cách nhập ID người dùng ở trên.",
+ "user": "Người dùng",
+ "revokeTitle": "Thu hồi chia sẻ",
+ "revokeDesc": "Thu hồi quyền truy cập của người dùng \"{{userId}}\"? Họ sẽ không thể sử dụng agent này nữa.",
+ "revoke": "Thu hồi",
+ "role.user": "Người dùng",
+ "role.viewer": "Người xem",
+ "roleDesc.user": "Có thể sử dụng agent và trò chuyện",
+ "roleDesc.viewer": "Chỉ đọc"
+ },
+ "links": {
+ "teamsHint": "Agent Links sẽ sớm bị gỡ bỏ. Vui lòng sử dụng Agent Teams để cộng tác đa agent — hỗ trợ quản lý nhiệm vụ, workspace chung và kiểm soát chất lượng trong một hệ thống đơn giản hơn.",
+ "createLink": "Tạo liên kết",
+ "targetAgent": "Agent đích",
+ "selectAgent": "Chọn hoặc tìm kiếm agent...",
+ "direction": "Hướng",
+ "description": "Mô tả",
+ "descriptionPlaceholder": "Mô tả tùy chọn của liên kết...",
+ "maxConcurrent": "Tối đa đồng thời",
+ "advancedSettings": "Cài đặt nâng cao",
+ "allowedUsers": "Người dùng được phép",
+ "allowedUsersPlaceholder": "user1, user2, ...",
+ "allowedUsersHint": "Nếu đặt, chỉ những người dùng này mới có thể kích hoạt ủy quyền. Phân cách bằng dấu phẩy.",
+ "deniedUsers": "Người dùng bị từ chối",
+ "deniedUsersPlaceholder": "user3, user4, ...",
+ "deniedUsersHint": "Những người dùng này bị chặn kích hoạt ủy quyền. Phân cách bằng dấu phẩy.",
+ "creating": "Đang tạo...",
+ "noLinks": "Chưa có liên kết agent",
+ "noLinksDesc": "Tạo liên kết để cho phép các agent ủy quyền nhiệm vụ cho nhau.",
+ "loadingLinks": "Đang tải liên kết...",
+ "columns": {
+ "target": "Đích",
+ "direction": "Hướng",
+ "status": "Trạng thái",
+ "limit": "Giới hạn"
+ },
+ "deleteTitle": "Xóa liên kết",
+ "deleteDesc": "Xóa liên kết ủy quyền đến \"{{name}}\"? Các agent sẽ không thể ủy quyền cho nhau qua liên kết này nữa.",
+ "editTitle": "Chỉnh sửa liên kết",
+ "editDescription": "Cập nhật cài đặt liên kết ủy quyền.",
+ "optionalDescription": "Mô tả tùy chọn...",
+ "allowedUsersHintShort": "Nếu đặt, chỉ những người dùng này mới có thể kích hoạt ủy quyền.",
+ "deniedUsersHintShort": "Những người dùng này bị chặn kích hoạt ủy quyền."
+ },
+ "skills": {
+ "noSkillsAvailable": "Không có skill khả dụng",
+ "noSkillsDesc": "Tải lên skill trong trang Skill để cấp cho agent.",
+ "skillsGranted": "{{granted}} trong {{total}} skill được cấp",
+ "filterSkills": "Lọc skill...",
+ "noSkillsMatch": "Không có skill nào phù hợp.",
+ "system": "Hệ thống",
+ "alwaysAvailable": "Luôn khả dụng"
+ },
+ "instances": {
+ "loadingInstances": "Đang tải phiên bản...",
+ "noInstances": "Chưa có phiên bản người dùng.",
+ "noInstancesDesc": "Phiên bản được tạo khi người dùng tương tác với agent này.",
+ "instanceCount": "{{count}} phiên bản",
+ "instanceCount_plural": "{{count}} phiên bản",
+ "selectInstance": "Chọn một phiên bản để xem và chỉnh sửa USER.md của nó",
+ "loading": "Đang tải...",
+ "saving": "Đang lưu...",
+ "save": "Lưu",
+ "saved": "Đã lưu",
+ "searchContacts": "Tìm liên hệ để thêm...",
+ "noContactsFound": "Không tìm thấy liên hệ"
+ },
+ "summoning": {
+ "title": "Đang triệu hồi agent...",
+ "completed": "Triệu hồi hoàn tất!",
+ "failed": "Triệu hồi thất bại",
+ "agentReady": "{{name}} đã sẵn sàng!",
+ "weavingSoul": "Đang triệu hồi linh hồn cho",
+ "wait": "Thường mất vài phút. Vui lòng chờ...",
+ "continue": "Tiếp tục",
+ "retry": "Thử lại",
+ "retrying": "Đang thử lại...",
+ "done": "xong",
+ "fileLabelSOUL": "Linh hồn & Tính cách",
+ "fileLabelIDENTITY": "Thẻ danh tính",
+ "fileLabelUSER_PREDEFINED": "Ngữ cảnh người dùng mặc định"
+ },
+ "configGroups": {
+ "core": "Cốt lõi",
+ "coreDesc": "Cài đặt thiết yếu ảnh hưởng trực tiếp đến hành vi agent",
+ "capabilities": "Khả năng",
+ "capabilitiesDesc": "Các tính năng agent có thể sử dụng",
+ "performance": "Hiệu suất",
+ "performanceDesc": "Quản lý ngữ cảnh và môi trường thực thi",
+ "advanced": "Chất lượng & Nâng cao",
+ "advancedDesc": "Kiểm tra chất lượng và cấu hình tùy chỉnh"
+ },
+ "configSections": {
+ "subagents": {
+ "title": "Agent con",
+ "description": "Kiểm soát giới hạn sinh sản agent con và hành vi",
+ "maxConcurrent": "Tối đa đồng thời",
+ "maxSpawnDepth": "Độ sâu sinh sản tối đa",
+ "maxChildrenPerAgent": "Con tối đa mỗi agent",
+ "archiveAfter": "Lưu trữ sau (phút)",
+ "modelOverride": "Ghi đè model",
+ "inheritFromAgent": "(kế thừa từ agent)"
+ },
+ "toolPolicy": {
+ "title": "Chính sách công cụ",
+ "description": "Kiểm soát công cụ nào agent có thể sử dụng",
+ "profile": "Hồ sơ",
+ "allow": "Cho phép",
+ "deny": "Từ chối",
+ "alsoAllow": "Cũng cho phép",
+ "selectToolsAllow": "Chọn công cụ để cho phép...",
+ "selectToolsDeny": "Chọn công cụ để từ chối...",
+ "selectToolsAlsoAllow": "Chọn công cụ bổ sung...",
+ "toolCallPrefix": "Tiền tố Tool Call",
+ "toolCallPrefixHint": "Loại bỏ tiền tố này từ tên tool call của model trước khi tra cứu registry."
+ },
+ "workspaceSharing": {
+ "title": "Chia sẻ Workspace",
+ "description": "Kiểm soát cách cô lập file workspace giữa các người dùng",
+ "sharedDm": "Chia sẻ trong DM",
+ "sharedDmTip": "Tất cả người dùng DM dùng chung thư mục workspace thay vì thư mục riêng",
+ "sharedGroup": "Chia sẻ trong Group",
+ "sharedGroupTip": "Tất cả thành viên trong group chat dùng chung thư mục workspace",
+ "sharedUsers": "User được chia sẻ",
+ "sharedUsersTip": "Danh sách user ID cụ thể luôn dùng chung workspace. Định dạng: channel:userId (vd: telegram:386246614)",
+ "userIdPlaceholder": "Nhập user ID (vd: telegram:386246614)",
+ "warning": "Khi bật chia sẻ, tất cả người dùng được chia sẻ có thể đọc và ghi file trong cùng thư mục workspace. Chia sẻ bộ nhớ được cài đặt riêng. File ngữ cảnh vẫn được cô lập theo từng người dùng.",
+ "memoryGroupLabel": "Memory & Knowledge Graph",
+ "folderGroupLabel": "Thư mục Workspace",
+ "shareMemory": "Chia sẻ Memory & KG",
+ "shareMemoryTip": "Tất cả người dùng truy cập chung Memory và Knowledge Graph. Độc lập với việc chia sẻ thư mục workspace. Khi tắt, mỗi người dùng có vùng nhớ riêng.",
+ "shareMemoryNote": "Bật/tắt không di chuyển dữ liệu — memory per-user trước đó sẽ không truy cập được trong chế độ shared."
+ },
+ "compaction": {
+ "title": "Nén ngữ cảnh",
+ "description": "Cài đặt nén cửa sổ ngữ cảnh và ghi nhớ trước nén",
+ "maxHistoryShare": "Phần lịch sử tối đa (0-1)",
+ "maxHistoryShareTip": "Tỷ lệ tối đa của cửa sổ ngữ cảnh dành cho lịch sử hội thoại (vd: 0.75 = 75%). Nén kích hoạt khi vượt quá.",
+ "minMessages": "Tin nhắn tối thiểu",
+ "minMessagesTip": "Số tin nhắn tối thiểu trước khi nén có thể kích hoạt, bất kể số token.",
+ "keepLastMessages": "Giữ tin nhắn cuối",
+ "keepLastMessagesTip": "Số tin nhắn gần nhất giữ lại sau khi nén. Các tin nhắn cũ hơn được thay thế bằng bản tóm tắt.",
+ "memoryFlush": "Ghi nhớ trước nén",
+ "memoryFlushTip": "Trước khi nén, agent được một lượt để lưu ngữ cảnh quan trọng vào file bộ nhớ. Cũng kích hoạt trích xuất Knowledge Graph."
+ },
+ "contextPruning": {
+ "title": "Cắt bớt ngữ cảnh",
+ "description": "Cắt bỏ kết quả công cụ cũ để tiết kiệm cửa sổ ngữ cảnh",
+ "mode": "Chế độ",
+ "keepLastAssistants": "Giữ trợ lý cuối",
+ "softTrimRatio": "Tỷ lệ cắt mềm (0-1)",
+ "hardClearRatio": "Tỷ lệ xóa cứng (0-1)",
+ "minPrunableToolChars": "Ký tự công cụ tối thiểu có thể cắt",
+ "softTrim": "Cắt mềm",
+ "maxChars": "Ký tự tối đa",
+ "headChars": "Ký tự đầu",
+ "tailChars": "Ký tự cuối",
+ "hardClear": "Xóa cứng",
+ "enabled": "Đã bật",
+ "placeholderText": "Văn bản thay thế"
+ },
+ "sandbox": {
+ "title": "Hộp cát",
+ "description": "Hộp cát Docker để cô lập thực thi code",
+ "mode": "Chế độ",
+ "workspaceAccess": "Truy cập workspace",
+ "image": "Hình ảnh",
+ "scope": "Phạm vi",
+ "timeout": "Hết giờ (giây)",
+ "memoryMb": "Bộ nhớ (MB)",
+ "cpus": "CPU",
+ "networkEnabled": "Bật mạng"
+ },
+ "memory": {
+ "title": "Bộ nhớ",
+ "description": "Tìm kiếm bộ nhớ ngữ nghĩa và cấu hình nhúng",
+ "enabled": "Đã bật",
+ "enabledTip": "Bật/tắt hệ thống bộ nhớ cho agent này.",
+ "maxChunkLen": "Độ dài chunk tối đa",
+ "maxChunkLenTip": "Số ký tự tối đa mỗi chunk. Để trống dùng mặc định hệ thống.",
+ "chunkOverlap": "Chunk Overlap",
+ "chunkOverlapTip": "Số ký tự chồng lấp giữa các chunk để giữ ngữ cảnh. Để trống dùng mặc định.",
+ "maxResults": "Kết quả tối đa",
+ "maxResultsTip": "Số kết quả tối đa trả về mỗi truy vấn tìm kiếm.",
+ "minScore": "Điểm tối thiểu",
+ "minScoreTip": "Ngưỡng điểm tối thiểu cho kết quả tìm kiếm.",
+ "vectorWeight": "Trọng số vector",
+ "vectorWeightTip": "Trọng số tương đồng ngữ nghĩa trong tìm kiếm hỗn hợp.",
+ "textWeight": "Trọng số văn bản",
+ "textWeightTip": "Trọng số từ khóa trong tìm kiếm hỗn hợp."
+ },
+ "thinking": {
+ "title": "Suy nghĩ mở rộng",
+ "description": "Cho phép model suy luận trước khi phản hồi. Cấp độ cao hơn dùng nhiều token hơn nhưng cho kết quả tốt hơn với tác vụ phức tạp.",
+ "thinkingLevel": "Cấp độ suy nghĩ",
+ "off": "Tắt",
+ "offDesc": "Không có suy nghĩ mở rộng",
+ "low": "Thấp",
+ "lowDesc": "~4K ngân sách token",
+ "medium": "Trung bình",
+ "mediumDesc": "~10-16K ngân sách token",
+ "high": "Cao",
+ "highDesc": "~32K ngân sách token"
+ },
+ "otherConfig": {
+ "title": "Cấu hình khác",
+ "description": "Cấu hình JSON bổ sung cho các cài đặt nâng cao không được đề cập trong các mục khác",
+ "formatJson": "Định dạng JSON",
+ "invalidJson": "Cú pháp JSON không hợp lệ"
+ }
+ },
+ "heartbeat": {
+ "title": "Heartbeat",
+ "loading": "Đang tải heartbeat...",
+ "notConfigured": "Chưa cấu hình lịch heartbeat. Thiết lập để agent chạy tự động.",
+ "setupButton": "Thiết lập Heartbeat",
+ "toggleEnabled": "Bật/tắt heartbeat",
+ "every": "Mỗi {{interval}} phút",
+ "lastRun": "Lần cuối",
+ "nextRun": "Lần tới",
+ "runs": "Lần chạy",
+ "suppressed": "Bị bỏ qua",
+ "configure": "Cấu hình",
+ "logs": "Nhật ký",
+ "configTitle": "Cấu hình Heartbeat",
+ "enabled": "Kích hoạt",
+ "enabledHint": "Khi bật, agent sẽ chạy theo chu kỳ đã cấu hình.",
+ "sectionTiming": "Thời gian",
+ "interval": "Chu kỳ (giây)",
+ "intervalHint": "Tối thiểu 300 giây (5 phút).",
+ "maxRetries": "Số lần thử lại",
+ "maxRetriesHint": "0–10 lần thử lại khi thất bại.",
+ "ackMaxChars": "Ký tự phản hồi tối đa",
+ "ackMaxCharsHint": "Số ký tự tối đa trong phản hồi. 0 = không phản hồi.",
+ "sectionBehavior": "Hành vi",
+ "isolatedSession": "Phiên cô lập",
+ "isolatedSessionHint": "Chạy trong phiên mới, không tiếp tục cuộc trò chuyện trước.",
+ "lightContext": "Ngữ cảnh nhẹ",
+ "lightContextHint": "Bỏ qua tải đầy đủ tệp ngữ cảnh để giảm token.",
+ "sectionSchedule": "Lịch hoạt động",
+ "activeHoursStart": "Bắt đầu (HH:MM)",
+ "activeHoursEnd": "Kết thúc (HH:MM)",
+ "timezone": "Múi giờ",
+ "sectionModel": "Model",
+ "modelHint": "Ghi đè provider/model cho heartbeat. Để trống để dùng mặc định của agent.",
+ "sectionDelivery": "Gửi kết quả",
+ "channel": "Kênh",
+ "channelPlaceholder": "Chọn kênh",
+ "channelNone": "Không (không gửi)",
+ "channelHint": "Ví dụ: telegram, feishu. Để trống nếu không gửi.",
+ "chatId": "Chat ID",
+ "chatIdPlaceholder": "Chọn chat",
+ "selectChannelFirst": "Chọn kênh trước",
+ "checklist": "Danh sách kiểm tra (HEARTBEAT.md)",
+ "checklistHint": "Hướng dẫn được đưa vào mỗi lần chạy heartbeat. Hỗ trợ markdown.",
+ "checklistLoading": "Đang tải danh sách kiểm tra...",
+ "checklistPlaceholder": "# Danh sách kiểm tra Heartbeat\n\n- Kiểm tra các tác vụ đang chờ\n- Báo cáo trạng thái\n",
+ "on": "Bật",
+ "off": "Tắt",
+ "notSet": "Chưa cài",
+ "disabled": "Heartbeat đã tắt",
+ "nextIn": "Còn {{time}}",
+ "advancedSettings": "Cài đặt nâng cao",
+ "scheduleHint": "Để trống để chạy 24/7.",
+ "testRun": "Chạy thử",
+ "cancel": "Hủy",
+ "save": "Lưu",
+ "saving": "Đang lưu...",
+ "logsTitle": "Nhật ký Heartbeat",
+ "refresh": "Làm mới",
+ "noLogs": "Chưa có nhật ký heartbeat.",
+ "logsPagination": "{{from}}–{{to}} / {{total}}"
+ },
+ "permissions": {
+ "title": "Quyền hạn",
+ "description": "Quản lý ai được phép thay đổi cấu hình agent và file. Chủ sở hữu luôn có quyền đầy đủ.",
+ "addRule": "Thêm quy tắc",
+ "fileWriters": "Người viết file",
+ "configPerms": "Quyền cấu hình",
+ "noRules": "Chưa có quy tắc. Chủ sở hữu mặc định có đầy đủ quyền.",
+ "userIdPlaceholder": "Tìm liên hệ hoặc nhập ID...",
+ "scopePlaceholder": "Chọn hoặc nhập phạm vi...",
+ "types": {
+ "file_writer": "Quyền viết file",
+ "file_writer_desc": "Ai được chỉnh sửa file và quản lý cron trong nhóm chat. Người đầu tiên tự động được thêm.",
+ "heartbeat": "Heartbeat",
+ "heartbeat_desc": "Ai được cấu hình lịch heartbeat qua chat.",
+ "cron": "Cron",
+ "cron_desc": "Ai được tạo và quản lý cron job qua chat.",
+ "context_files": "File ngữ cảnh",
+ "context_files_desc": "Ai được chỉnh sửa file nhận dạng agent (SOUL.md, v.v.) qua chat.",
+ "all": "Tất cả (*)",
+ "all_desc": "Toàn quyền cho mọi loại cấu hình."
+ },
+ "scopes": {
+ "agent": "Agent (riêng tư)",
+ "group_all": "Tất cả nhóm",
+ "global": "Toàn cục"
+ }
+ },
+ "toast": {
+ "created": "Đã tạo agent",
+ "createFailed": "Không thể tạo agent",
+ "updated": "Đã cập nhật agent",
+ "updateFailed": "Không thể cập nhật agent",
+ "deleted": "Đã xóa agent",
+ "deleteFailed": "Không thể xóa agent",
+ "unknownError": "Lỗi không xác định",
+ "skillGranted": "Đã cấp skill",
+ "skillGrantFailed": "Không thể cấp skill",
+ "skillRevoked": "Đã thu hồi skill",
+ "skillRevokeFailed": "Không thể thu hồi skill"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/chat.json b/ui/desktop/frontend/src/i18n/locales/vi/chat.json
new file mode 100644
index 00000000..2ec92c20
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/chat.json
@@ -0,0 +1,25 @@
+{
+ "empty": {
+ "description": "Gửi tin nhắn để bắt đầu trò chuyện với agent.",
+ "title": "Bắt đầu cuộc trò chuyện"
+ },
+ "forwardedFrom": "Chuyển tiếp từ",
+ "media": {
+ "animation": "Hoạt ảnh",
+ "attached": "đính kèm",
+ "audio": "Âm thanh",
+ "document": "Tài liệu",
+ "image": "Hình ảnh",
+ "video": "Video",
+ "voice": "Tin nhắn thoại"
+ },
+ "deleteChat": "Xóa cuộc trò chuyện",
+ "deleteChatConfirm": "Thao tác này sẽ xóa vĩnh viễn cuộc trò chuyện và tất cả tin nhắn.",
+ "newChat": "Trò chuyện mới",
+ "openSessions": "Mở phiên",
+ "readOnly": "Chỉ đọc — phiên này thuộc về người dùng khác",
+ "replyingTo": "Đang trả lời",
+ "error": {
+ "notConnected": "Chưa kết nối. Vui lòng chờ kết nối được thiết lập."
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/common.json b/ui/desktop/frontend/src/i18n/locales/vi/common.json
new file mode 100644
index 00000000..d75d66c9
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/common.json
@@ -0,0 +1,139 @@
+{
+ "actions": "Thao tác",
+ "active": "Hoạt động",
+ "add": "Thêm",
+ "agent": "Agent",
+ "all": "Tất cả",
+ "back": "Quay lại",
+ "builtinTools": "Tích hợp sẵn",
+ "cached": "đã lưu cache",
+ "cancel": "Hủy",
+ "cancelled": "Đã hủy",
+ "check": "Kiểm tra",
+ "checkAndCreate": "Kiểm tra & Tạo",
+ "checking": "Đang kiểm tra...",
+ "close": "Đóng",
+ "completed": "Hoàn thành",
+ "confirm": "Xác nhận",
+ "connected": "Đã kết nối",
+ "connecting": "Đang kết nối...",
+ "create": "Tạo mới",
+ "creating": "Đang tạo...",
+ "customTools": "Tùy chỉnh",
+ "default": "mặc định",
+ "delete": "Xóa",
+ "description": "Mô tả",
+ "disabled": "Đã tắt",
+ "disconnected": "Đã ngắt kết nối",
+ "disconnectedGateway": "Mất kết nối với gateway. Đang thử kết nối lại...",
+ "serverUnreachable": "Không thể kết nối",
+ "serverUnreachableDesc": "Không thể kết nối tới máy chủ gateway. Vui lòng kiểm tra máy chủ đang hoạt động.",
+ "reconnecting": "Đang kết nối lại...",
+ "edit": "Chỉnh sửa",
+ "enabled": "Đã bật",
+ "enterOrSelectModel": "Nhập hoặc chọn model",
+ "failed": "Thất bại",
+ "filesBack": "Tệp",
+ "filter": "Lọc",
+ "format": "Định dạng JSON",
+ "global": "Toàn cục",
+ "invalidJson": "Cú pháp JSON không hợp lệ",
+ "items": "mục",
+ "live": "Trực tiếp",
+ "loading": "Đang tải...",
+ "loadingModels": "Đang tải model...",
+ "manage": "Quản lý",
+ "messages": "tin nhắn",
+ "model": "Model",
+ "useCustomModel": "Dùng tuỳ chỉnh:",
+ "modelTip": "ID model để sử dụng.",
+ "modelVerified": "Model đã được xác minh",
+ "name": "Tên",
+ "no": "Không",
+ "noAgentsAvailable": "Không có agent nào",
+ "noDescription": "Không có mô tả",
+ "noFiles": "Không tìm thấy tệp nào.",
+ "noProvidersConfigured": "Chưa cấu hình provider",
+ "noSessions": "Chưa có phiên nào. Bắt đầu chat!",
+ "none": "Không có",
+ "notSet": "Chưa thiết lập",
+ "optional": "Tùy chọn",
+ "pageOf": "Trang {{page}} / {{totalPages}}",
+ "pending": "Đang chờ",
+ "provider": "Provider",
+ "providerTip": "Tên provider LLM. Phải khớp với một provider đã cấu hình.",
+ "refresh": "Làm mới",
+ "required": "Bắt buộc",
+ "retry": "Thử lại",
+ "rows": "Hàng",
+ "running": "Đang chạy",
+ "save": "Lưu",
+ "saveChanges": "Lưu thay đổi",
+ "saveConfig": "Lưu cấu hình",
+ "saved": "Đã lưu",
+ "saving": "Đang lưu...",
+ "search": "Tìm kiếm...",
+ "searchPlaceholder": "Tìm kiếm...",
+ "selectAgent": "Chọn agent",
+ "selectFileToView": "Chọn một tệp để xem nội dung",
+ "selectOrTypeTools": "Chọn hoặc nhập tên công cụ...",
+ "selectOrTypeSkills": "Chọn hoặc nhập tên kỹ năng...",
+ "selectProvider": "Chọn provider",
+ "sendMessage": "Gửi tin nhắn... (Shift+Enter để xuống dòng)",
+ "sendMessageTitle": "Gửi tin nhắn",
+ "sendFollowUp": "Gửi tin nhắn bổ sung",
+ "attachFile": "Đính kèm tệp",
+ "skillActivated": "Đã kích hoạt",
+ "skillActivating": "Đang kích hoạt...",
+ "status": "Trạng thái",
+ "stopGeneration": "Dừng tạo",
+ "stopped": "Đã dừng",
+ "thinking": "Đang suy nghĩ",
+ "thinkingStreaming": "Đang suy nghĩ...",
+ "toolArguments": "Tham số",
+ "toolDone": "Hoàn thành",
+ "toolFailed": "Thất bại",
+ "toolResult": "Kết quả",
+ "toolRunning": "Đang chạy...",
+ "tryDifferentSearch": "Thử từ khóa tìm kiếm khác.",
+ "type": "Loại",
+ "typeAndPressEnter": "Nhập và nhấn Enter...",
+ "typeToConfirmPrefix": "Nhập",
+ "typeToConfirmSuffix": "để xác nhận.",
+ "unknown": "Không xác định",
+ "unsavedChanges": "Có thay đổi chưa lưu",
+ "update": "Cập nhật",
+ "uploadLabel": "Tải lên",
+ "usingGlobalDefaults": "Đang dùng giá trị mặc định toàn cục từ config.json",
+ "verificationFailed": "Xác minh thất bại",
+ "view": "Xem",
+ "yes": "Có",
+ "copyCode": "Sao chép mã",
+ "copy": "Sao chép",
+ "download": "Tải xuống",
+ "errorBoundary": "Đã xảy ra lỗi khi hiển thị phần này.",
+ "csvRows": "csv ({{count}} dòng)",
+ "failedToLoadImage": "Không thể tải hình ảnh",
+ "deleteFolder": "Xóa thư mục",
+ "deleteFile": "Xóa tệp",
+ "loadMore": "Tải thêm...",
+ "viewCard": "Dạng thẻ",
+ "viewList": "Dạng danh sách",
+ "done": "Xong",
+ "upload": {
+ "title": "Tải tệp lên",
+ "dropOrClick": "Kéo thả tệp hoặc nhấp để chọn",
+ "dropHere": "Thả tệp vào đây",
+ "maxSize": "Tối đa 50 MB mỗi tệp",
+ "blockedType": "Loại tệp {{ext}} không được phép",
+ "tooLarge": "Tệp vượt quá giới hạn 50 MB",
+ "failed": "Tải lên thất bại",
+ "readyCount": "{{ready}} trong {{total}} tệp sẵn sàng",
+ "successCount": "{{success}} trong {{total}} tệp đã tải lên",
+ "uploading": "Đang tải lên...",
+ "uploadCount": "Tải lên {{count}} tệp"
+ },
+ "errors": {
+ "serverError": "Đã xảy ra lỗi. Vui lòng thử lại sau."
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/cron.json b/ui/desktop/frontend/src/i18n/locales/vi/cron.json
new file mode 100644
index 00000000..9b642eb6
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/cron.json
@@ -0,0 +1,147 @@
+{
+ "title": "Tác vụ định kỳ",
+ "description": "Lên lịch các tác vụ agent định kỳ",
+ "newJob": "Tác vụ mới",
+ "emptyTitle": "Không có tác vụ định kỳ",
+ "emptyDescription": "Tạo tác vụ định kỳ để lên lịch các tác vụ agent định kỳ.",
+ "columns": {
+ "enabled": "Bật",
+ "name": "Tên",
+ "schedule": "Lịch",
+ "message": "Tin nhắn",
+ "agent": "Agent",
+ "actions": "Thao tác"
+ },
+ "runNow": "Chạy ngay",
+ "running": "Đang chạy...",
+ "runHistory": "Lịch sử chạy",
+ "defaultAgent": "mặc định",
+ "searchPlaceholder": "Tìm kiếm cron...",
+ "noMatchTitle": "Không tìm thấy tác vụ",
+ "noMatchDescription": "Thử từ khóa khác.",
+ "card": {
+ "nextRun": "Tiếp: {{time}}",
+ "noNextRun": "Không có lần chạy tiếp",
+ "defaultAgent": "Agent mặc định"
+ },
+ "create": {
+ "title": "Tạo tác vụ định kỳ",
+ "name": "Tên",
+ "namePlaceholder": "my-daily-task",
+ "nameHint": "Chỉ chữ thường, số và dấu gạch ngang",
+ "agentId": "Agent ID (tùy chọn)",
+ "agentIdPlaceholder": "mặc định",
+ "scheduleType": "Loại lịch",
+ "every": "Mỗi",
+ "cron": "Cron",
+ "once": "Một lần",
+ "intervalSeconds": "Khoảng thời gian (giây)",
+ "cronExpression": "Biểu thức Cron",
+ "cronHint": "Cron 5 trường tiêu chuẩn: phút giờ ngày tháng thứ",
+ "onceDesc": "Tác vụ sẽ chạy một lần, khoảng 1 phút kể từ bây giờ.",
+ "message": "Tin nhắn",
+ "messagePlaceholder": "Agent cần làm gì?",
+ "cancel": "Hủy",
+ "create": "Tạo",
+ "creating": "Đang tạo..."
+ },
+ "enable": {
+ "title": "Bật tác vụ định kỳ",
+ "description": "Bật \"{{name}}\"? Tác vụ sẽ bắt đầu chạy theo lịch.",
+ "confirmLabel": "Bật"
+ },
+ "disable": {
+ "title": "Tắt tác vụ định kỳ",
+ "description": "Tắt \"{{name}}\"? Tác vụ sẽ dừng chạy cho đến khi được bật lại.",
+ "confirmLabel": "Tắt"
+ },
+ "delete": {
+ "title": "Xóa tác vụ định kỳ",
+ "description": "Xóa \"{{name}}\"? Hành động này không thể hoàn tác.",
+ "confirmLabel": "Xóa"
+ },
+ "runLog": {
+ "title": "Lịch sử chạy: {{name}}",
+ "noHistory": "Chưa có lịch sử chạy nào.",
+ "loading": "Đang tải..."
+ },
+ "detail": {
+ "runNow": "Chạy ngay",
+ "running": "Đang chạy...",
+ "disable": "Tắt",
+ "enable": "Bật",
+ "delete": "Xóa",
+ "enabled": "đã bật",
+ "disabled": "đã tắt",
+ "runningStatus": "đang chạy...",
+ "payload": "Tải trọng",
+ "empty": "(trống)",
+ "deliverDirect": "Gửi: trực tiếp",
+ "channel": "Channel: {{name}}",
+ "to": "Đến: {{name}}",
+ "edit": "Sửa",
+ "preview": "Xem trước",
+ "noMessage": "Chưa có tin nhắn",
+ "lastError": "Lỗi gần nhất",
+ "runHistory": "Lịch sử chạy",
+ "refresh": "Làm mới",
+ "noHistory": "Chưa có lịch sử chạy nào.",
+ "inOut": "{{input}} vào / {{output}} ra",
+ "infoRows": {
+ "schedule": "Lịch",
+ "timezone": "Múi giờ",
+ "nextRun": "Lần chạy tiếp theo",
+ "lastRun": "Lần chạy gần nhất",
+ "lastStatus": "Trạng thái",
+ "created": "Đã tạo",
+ "updated": "Đã cập nhật",
+ "autoDelete": "Tự động xóa",
+ "autoDeleteValue": "Có (một lần)"
+ },
+ "tabs": {
+ "overview": "Tổng quan",
+ "history": "Lịch sử chạy"
+ },
+ "advanced": "Nâng cao",
+ "saveFailed": "Lưu thất bại",
+ "schedule": "Lịch",
+ "scheduleDesc": "Cấu hình thời gian",
+ "messageSection": "Tin nhắn",
+ "messageSectionDesc": "Nội dung agent cần thực hiện",
+ "agentStatus": "Agent & Trạng thái",
+ "agentStatusDesc": "Gán agent và trạng thái công việc",
+ "info": "Thông tin",
+ "infoDesc": "Siêu dữ liệu công việc",
+ "scheduling": "Lên lịch",
+ "schedulingDesc": "Cấu hình múi giờ",
+ "delivery": "Giao nhận",
+ "deliveryDesc": "Chuyển kết quả công việc đến kênh hoặc người dùng",
+ "deliverToChannel": "Gửi đến kênh",
+ "channelLabel": "Kênh",
+ "channelPlaceholder": "#general",
+ "toLabel": "Đến (người dùng)",
+ "toPlaceholder": "user-id",
+ "wakeHeartbeat": "Kích hoạt heartbeat khi chạy",
+ "wakeHeartbeatDesc": "Kích hoạt heartbeat của agent khi công việc chạy",
+ "lifecycle": "Vòng đời",
+ "lifecycleDesc": "Cài đặt vòng đời công việc",
+ "deleteAfterRun": "Xóa sau khi chạy",
+ "deleteAfterRunDesc": "Tự động xóa công việc sau lần chạy thành công đầu tiên",
+ "timezone": "Múi giờ",
+ "timezoneDesc": "Múi giờ cho biểu thức cron"
+ },
+ "toast": {
+ "created": "Đã tạo tác vụ định kỳ",
+ "createdDesc": "{{name}} đã được thêm",
+ "enabled": "Đã bật tác vụ định kỳ",
+ "disabled": "Đã tắt tác vụ định kỳ",
+ "deleted": "Đã xóa tác vụ định kỳ",
+ "triggered": "Đã kích hoạt tác vụ định kỳ",
+ "failedCreate": "Không thể tạo tác vụ định kỳ",
+ "failedToggle": "Không thể chuyển đổi tác vụ định kỳ",
+ "failedDelete": "Không thể xóa tác vụ định kỳ",
+ "failedRun": "Không thể chạy tác vụ định kỳ",
+ "updated": "Đã cập nhật job",
+ "failedUpdate": "Cập nhật job thất bại"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/desktop.json b/ui/desktop/frontend/src/i18n/locales/vi/desktop.json
new file mode 100644
index 00000000..90aaac16
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/desktop.json
@@ -0,0 +1,142 @@
+{
+ "onboarding": {
+ "welcome": "GoClaw Setup",
+ "welcomeDesc": "Let's get your gateway up and running",
+ "providerStep": "Configure Provider",
+ "providerStepDesc": "Select an AI provider and enter your credentials.",
+ "modelStep": "Select & Verify Model",
+ "modelStepDesc": "Choose a model and test the connection.",
+ "agentStep": "Create Your Agent",
+ "agentStepDesc": "Give your agent a personality.",
+ "descPlaceholder": "Describe your agent's personality...",
+ "complete": "Setup Complete",
+ "stepProvider": "Nhà cung cấp",
+ "stepModel": "Mô hình",
+ "stepAgent": "Agent",
+ "createProvider": "Tạo nhà cung cấp"
+ },
+ "summoning": {
+ "title": "Đang triệu hồi Agent",
+ "weavingSoul": "Đang tạo linh hồn cho {{name}}",
+ "completed": "Agent đã sẵn sàng!",
+ "completedTitle": "Agent đã sẵn sàng!",
+ "failedTitle": "Triệu hồi thất bại",
+ "failed": "Triệu hồi thất bại",
+ "wait": "Có thể mất một lúc",
+ "fileLabelSOUL": "Tính cách cốt lõi",
+ "fileLabelIDENTITY": "Danh tính & kiến thức",
+ "fileLabelUSER_PREDEFINED": "Ngữ cảnh người dùng",
+ "agentReady": "{{name}} đã sẵn sàng!",
+ "retrying": "Đang thử lại..."
+ },
+ "settings": {
+ "title": "Settings",
+ "tabs": {
+ "appearance": "Appearance",
+ "providers": "Providers",
+ "agents": "Agents",
+ "mcp": "MCP",
+ "skills": "Skills",
+ "tools": "Tools",
+ "cron": "Cron",
+ "traces": "Traces",
+ "about": "About"
+ },
+ "theme": "Theme",
+ "dark": "Dark",
+ "light": "Light",
+ "language": "Language",
+ "timezone": "Timezone",
+ "timezonePlaceholder": "Timezone selection coming soon."
+ },
+ "sidebar": {
+ "newChat": "Trò chuyện mới",
+ "connected": "Connected",
+ "offline": "Offline",
+ "lightMode": "Light mode",
+ "darkMode": "Dark mode",
+ "settings": "Settings",
+ "teams": "Nhóm"
+ },
+ "about": {
+ "title": "GoClaw Lite",
+ "subtitle": "Desktop Edition",
+ "version": "Version",
+ "edition": "Edition",
+ "runtime": "Runtime",
+ "runtimeValue": "Wails v2",
+ "editionLimits": "Edition Limits",
+ "agents": "Agents",
+ "teams": "Teams",
+ "teamMembers": "Team members",
+ "database": "Database",
+ "users": "Users",
+ "maxAgents": "5 max",
+ "maxTeams": "1 max",
+ "maxTeamMembers": "5 max",
+ "databaseValue": "SQLite (local)",
+ "usersValue": "Single user",
+ "builtWith": "Built with Go + React + Wails"
+ },
+ "presets": {
+ "researcher": {
+ "label": "🔬 Nhà nghiên cứu",
+ "prompt": "Tên: Scholar. Một nhà phân tích nghiên cứu tỉ mỉ, không bỏ sót bất kỳ chi tiết nào.\nTính cách: Có phương pháp, kỹ lưỡng và chính xác. Trình bày thông tin trong các báo cáo có cấu trúc rõ ràng kèm trích dẫn. Cân bằng giữa chiều sâu và sự rõ ràng.\n\nMục đích: Nghiên cứu chuyên sâu mọi lĩnh vực — bài báo học thuật, phân tích thị trường, điều tra kỹ thuật. Tổng hợp nhiều nguồn thành những insight hành động được. Luôn phân biệt sự thật với quan điểm cá nhân."
+ },
+ "writer": {
+ "label": "✍️ Nhà văn",
+ "prompt": "Tên: Quill. Một nhà văn đa năng, biết điều chỉnh giọng văn và phong cách cho mọi đối tượng.\nTính cách: Sáng tạo, diễn đạt rõ ràng và đồng cảm. Hiểu sức mạnh của ngôn từ. Cân bằng giữa sự rõ ràng và tinh tế.\n\nMục đích: Chuyên gia sáng tạo nội dung — bài blog, bài viết, copywriting, kể chuyện, viết kỹ thuật. Giỏi xây dựng lập luận, tạo câu chuyện và duy trì giọng văn nhất quán trong nội dung dài."
+ },
+ "coder": {
+ "label": "👨💻 Lập trình viên",
+ "prompt": "Tên: Dev. Một kỹ sư phần mềm senior với kiến thức chuyên sâu toàn diện.\nTính cách: Thực dụng và hiệu quả. Viết code sạch, dễ bảo trì. Giải thích khái niệm phức tạp một cách đơn giản. Tuân thủ best practices nhưng biết khi nào nên linh hoạt.\n\nMục đích: Review code, thiết kế kiến trúc, debug, triển khai. Thành thạo nhiều ngôn ngữ và framework. Ưu tiên tính đúng đắn, hiệu suất và bảo mật."
+ }
+ },
+ "agent": {
+ "summon": "Summon",
+ "summoning": "Summoning...",
+ "verifyModel": "Verify Model",
+ "verifying": "Verifying...",
+ "verified": "Verified",
+ "verifyFailed": "Verification failed"
+ },
+ "chat": {
+ "emptyTitle": "Trò chuyện với {{name}}",
+ "emptyTitleDefault": "Bắt đầu cuộc trò chuyện",
+ "emptyDescAgent": "Đặt câu hỏi, viết gì đó, hoặc khám phá ý tưởng.",
+ "emptyDescNoAgent": "Chọn một agent từ thanh bên để bắt đầu.",
+ "suggestions": [
+ "Tin tức mới nhất hôm nay có gì?",
+ "Kể một câu chuyện cười",
+ "Brainstorm các ideas thời đại AI"
+ ]
+ },
+ "activity": {
+ "thinking": "Đang suy nghĩ",
+ "executingTool": "Đang thực thi công cụ",
+ "compacting": "Đang nén ngữ cảnh",
+ "streaming": "Đang phát",
+ "retrying": "Đang thử lại",
+ "processing": "Đang xử lý",
+ "runningTool": "Đang chạy {{tool}}",
+ "retryingAttempt": "Thử lại (lần {{n}})",
+ "step": "bước {{n}}"
+ },
+ "toast": {
+ "agentCreated": "Agent created",
+ "agentUpdated": "Agent updated",
+ "agentDeleted": "Agent deleted",
+ "providerCreated": "Provider created",
+ "providerUpdated": "Provider updated",
+ "providerDeleted": "Provider deleted",
+ "serverCreated": "MCP server created",
+ "serverUpdated": "MCP server updated",
+ "serverDeleted": "MCP server deleted",
+ "skillUploaded": "Skill uploaded",
+ "skillDeleted": "Skill deleted",
+ "grantUpdated": "Grant updated",
+ "saveFailed": "Failed to save",
+ "deleteFailed": "Failed to delete",
+ "createFailed": "Failed to create"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/mcp.json b/ui/desktop/frontend/src/i18n/locales/vi/mcp.json
new file mode 100644
index 00000000..130a9608
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/mcp.json
@@ -0,0 +1,131 @@
+{
+ "title": "MCP Servers",
+ "description": "Quản lý kết nối máy chủ Model Context Protocol",
+ "addServer": "Thêm máy chủ",
+ "searchPlaceholder": "Tìm kiếm máy chủ...",
+ "emptyTitle": "Không có MCP server",
+ "emptyDescription": "Thêm MCP server đầu tiên để bắt đầu.",
+ "noMatchTitle": "Không tìm thấy máy chủ phù hợp",
+ "noMatchDescription": "Thử từ khóa tìm kiếm khác.",
+ "columns": {
+ "name": "Tên",
+ "transport": "Giao thức",
+ "tools": "Công cụ",
+ "agents": "Agents",
+ "enabled": "Bật",
+ "createdBy": "Tạo bởi",
+ "actions": "Thao tác"
+ },
+ "viewTools": "Xem công cụ",
+ "manageGrants": "Quản lý quyền",
+ "delete": {
+ "title": "Xóa MCP Server",
+ "description": "Bạn có chắc muốn xóa \"{{name}}\"? Tất cả quyền liên quan cũng sẽ bị xóa.",
+ "confirmLabel": "Xóa"
+ },
+ "form": {
+ "createTitle": "Thêm MCP Server",
+ "editTitle": "Chỉnh sửa MCP Server",
+ "name": "Tên *",
+ "nameHint": "Chỉ dùng chữ thường, số và dấu gạch ngang",
+ "displayName": "Tên hiển thị",
+ "displayNamePlaceholder": "Máy chủ MCP của tôi",
+ "transport": "Giao thức *",
+ "command": "Lệnh *",
+ "args": "Tham số (phân cách bằng dấu cách)",
+ "argsPlaceholder": "--flag1 --flag2=\"giá trị có dấu cách\"",
+ "url": "URL *",
+ "headers": "Headers",
+ "headerKeyPlaceholder": "Tên header",
+ "headerValuePlaceholder": "Giá trị header",
+ "addHeader": "Thêm Header",
+ "env": "Biến môi trường",
+ "envKeyPlaceholder": "Tên biến",
+ "envValuePlaceholder": "Giá trị",
+ "addVariable": "Thêm biến",
+ "toolPrefix": "Tiền tố công cụ",
+ "toolPrefixHint": "Tự động lấy từ tên nếu để trống.",
+ "timeout": "Thời gian chờ (giây)",
+ "enabled": "Bật",
+ "testConnection": "Kiểm tra kết nối",
+ "testing": "Đang kiểm tra...",
+ "toolsFound": "Tìm thấy {{count}} công cụ",
+ "cancel": "Hủy",
+ "create": "Tạo",
+ "update": "Cập nhật",
+ "saving": "Đang lưu...",
+ "errors": {
+ "nameRequired": "Tên và giao thức là bắt buộc",
+ "nameSlug": "Tên phải là slug hợp lệ (chữ thường, số, dấu gạch ngang)",
+ "commandRequired": "Lệnh là bắt buộc cho giao thức stdio",
+ "urlRequired": "URL là bắt buộc cho giao thức SSE/HTTP",
+ "connectionFailed": "Kết nối thất bại"
+ },
+ "requireUserCredentials": "Yêu cầu Credentials riêng",
+ "requireUserCredentialsHint": "Mỗi user phải cấu hình credentials riêng. Server bị vô hiệu cho user chưa có credentials."
+ },
+ "grants": {
+ "title": "Quyền agent - {{name}}",
+ "currentGrants": "Quyền hiện tại",
+ "allToolsAllowed": "Tất cả công cụ được phép",
+ "addGrant": "Thêm quyền agent",
+ "editGrant": "Chỉnh sửa quyền",
+ "selectAgent": "Chọn agent...",
+ "toolAllowList": "Danh sách công cụ được phép (tùy chọn)",
+ "toolDenyList": "Danh sách công cụ bị từ chối (tùy chọn)",
+ "allowPlaceholder": "Chọn công cụ để cho phép...",
+ "denyPlaceholder": "Chọn công cụ để từ chối...",
+ "grant": "Cấp quyền",
+ "revoke": "Thu hồi",
+ "noAgentsGranted": "Chưa có agent nào được cấp quyền",
+ "update": "Cập nhật",
+ "cancel": "Hủy",
+ "agentRequired": "Agent là bắt buộc",
+ "failedGrant": "Cấp quyền thất bại",
+ "failedRevoke": "Thu hồi quyền thất bại"
+ },
+ "tools": {
+ "title": "Công cụ — {{name}}",
+ "prefix": "tiền tố:",
+ "discovering": "Đang khám phá công cụ...",
+ "noToolsTitle": "Không tìm thấy công cụ",
+ "noToolsDescription": "Máy chủ có thể ngoại tuyến hoặc không có công cụ nào được đăng ký.",
+ "failedLoad": "Không thể tải công cụ",
+ "filterPlaceholder": "Lọc công cụ...",
+ "noMatch": "Không có công cụ phù hợp"
+ },
+ "toast": {
+ "created": "Đã tạo MCP server",
+ "updated": "Đã cập nhật MCP server",
+ "deleted": "Đã xóa MCP server",
+ "failedCreate": "Không thể tạo MCP server",
+ "failedUpdate": "Không thể cập nhật MCP server",
+ "failedDelete": "Không thể xóa MCP server"
+ },
+ "userCredentials": {
+ "title": "Thông tin xác thực",
+ "description": "Đặt thông tin xác thực cá nhân của bạn cho máy chủ MCP này. Chỉ áp dụng cho tài khoản của bạn.",
+ "titleAdmin": "Thông tin xác thực",
+ "descriptionAdmin": "Đặt thông tin xác thực cho người dùng cụ thể trên máy chủ MCP này. Chúng ghi đè cài đặt mặc định của máy chủ chỉ cho người dùng được chọn.",
+ "selectUser": "Chọn người dùng",
+ "apiKey": "API Key",
+ "apiKeyPlaceholder": "Để trống để giữ nguyên",
+ "headers": "Headers",
+ "env": "Biến môi trường",
+ "headerPlaceholder": "KEY=VALUE, mỗi dòng một cặp",
+ "save": "Lưu",
+ "delete": "Xóa",
+ "deleteAll": "Xóa tất cả",
+ "cancel": "Hủy",
+ "saved": "Đã lưu thông tin xác thực",
+ "deleted": "Đã xóa thông tin xác thực",
+ "hasApiKey": "API Key đã đặt",
+ "hasHeaders": "Headers đã đặt",
+ "hasEnv": "Biến môi trường đã đặt",
+ "noCredentials": "Chưa có thông tin xác thực",
+ "addHeader": "Thêm Header",
+ "addEnv": "Thêm biến",
+ "saveFailed": "Lưu credentials thất bại",
+ "deleteFailed": "Xóa credentials thất bại"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/memory.json b/ui/desktop/frontend/src/i18n/locales/vi/memory.json
new file mode 100644
index 00000000..5f814f11
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/memory.json
@@ -0,0 +1,191 @@
+{
+ "title": "Bộ nhớ",
+ "description": "Duyệt và quản lý tài liệu bộ nhớ, đoạn trích và nhúng của agent",
+ "search": "Tìm kiếm",
+ "create": "Tạo",
+ "indexAll": "Lập chỉ mục tất cả",
+ "indexing": "Đang lập chỉ mục...",
+ "emptyTitle": "Không có tài liệu bộ nhớ",
+ "emptyAgentDescription": "Agent này chưa có tài liệu bộ nhớ.",
+ "emptyGlobalDescription": "Không tìm thấy tài liệu bộ nhớ trên bất kỳ agent nào.",
+ "filters": {
+ "agent": "Agent",
+ "allAgents": "Tất cả agent",
+ "scope": "Phạm vi",
+ "allScope": "Tất cả (toàn cục + cá nhân)",
+ "selectAgent": "Chọn agent"
+ },
+ "tabs": {
+ "documents": "Tài liệu",
+ "knowledgeGraph": "Đồ thị tri thức"
+ },
+ "columns": {
+ "path": "Đường dẫn",
+ "agent": "Agent",
+ "scope": "Phạm vi",
+ "hash": "Mã băm",
+ "updated": "Cập nhật",
+ "actions": "Thao tác"
+ },
+ "scopeLabel": {
+ "personal": "Cá nhân",
+ "global": "Toàn cục"
+ },
+ "delete": {
+ "title": "Xóa tài liệu bộ nhớ",
+ "description": "Bạn có chắc muốn xóa \"{{path}}\"? Tất cả đoạn trích và nhúng liên quan cũng sẽ bị xóa.",
+ "confirmLabel": "Xóa"
+ },
+ "createDialog": {
+ "title": "Tạo tài liệu bộ nhớ",
+ "path": "Đường dẫn *",
+ "pathPlaceholder": "vd. notes/project-overview.md",
+ "content": "Nội dung *",
+ "contentPlaceholder": "Nội dung tài liệu...",
+ "agentId": "Agent *",
+ "agentIdPlaceholder": "Chọn agent...",
+ "scope": "Phạm vi",
+ "existingScope": "Phạm vi hiện có",
+ "customScope": "Tùy chỉnh",
+ "selectGroupUser": "Chọn nhóm/người dùng...",
+ "scopeHint": "Toàn cục = chia sẻ với mọi người dùng. Cá nhân = giới hạn cho người dùng hoặc chat nhóm cụ thể.",
+ "autoIndex": "Tự động lập chỉ mục sau khi tạo",
+ "agentRequired": "Vui lòng chọn agent",
+ "pathRequired": "Đường dẫn là bắt buộc",
+ "contentRequired": "Nội dung là bắt buộc",
+ "cancel": "Hủy",
+ "create": "Tạo",
+ "creating": "Đang tạo..."
+ },
+ "documentDialog": {
+ "title": "Tài liệu bộ nhớ",
+ "path": "Đường dẫn:",
+ "agent": "Agent:",
+ "scope": "Phạm vi:",
+ "hash": "Mã băm:",
+ "updated": "Cập nhật:",
+ "content": "Nội dung",
+ "chunks": "Đoạn trích ({{count}})",
+ "chunkIndex": "Đoạn {{index}}",
+ "noChunks": "Không có đoạn trích",
+ "embedded": "Đã nhúng",
+ "noEmbedding": "Chưa nhúng"
+ },
+ "searchDialog": {
+ "title": "Tìm kiếm bộ nhớ",
+ "query": "Truy vấn tìm kiếm",
+ "queryPlaceholder": "Nhập truy vấn tìm kiếm...",
+ "agentId": "Agent ID",
+ "agentIdPlaceholder": "Lọc theo agent...",
+ "limit": "Giới hạn",
+ "search": "Tìm kiếm",
+ "searching": "Đang tìm kiếm...",
+ "results": "Kết quả",
+ "noResults": "Không tìm thấy kết quả",
+ "similarity": "tương đồng",
+ "path": "Đường dẫn:"
+ },
+ "kg": {
+ "pageTitle": "Knowledge Graph",
+ "pageDescription": "Khám phá các thực thể và quan hệ được trích xuất từ bộ nhớ agent.",
+ "selectAgentTitle": "Chọn agent",
+ "selectAgentDescription": "Chọn agent để xem đồ thị tri thức.",
+ "emptyTitle": "Không có thực thể",
+ "emptyDescription": "Chưa có thực thể đồ thị tri thức nào cho agent này.",
+ "emptySearchDescription": "Không có thực thể nào khớp với tìm kiếm của bạn.",
+ "stats": {
+ "entities": "Thực thể: {{count}}",
+ "relations": "Quan hệ: {{count}}"
+ },
+ "search": {
+ "placeholder": "Tìm kiếm thực thể...",
+ "button": "Tìm kiếm",
+ "clear": "Xóa"
+ },
+ "refresh": "Làm mới",
+ "extract": "Trích xuất",
+ "columns": {
+ "name": "Tên",
+ "type": "Loại",
+ "description": "Mô tả",
+ "confidence": "Độ tin cậy",
+ "actions": "Thao tác",
+ "observations": "Quan sát",
+ "relations": "Quan hệ"
+ },
+ "deleteEntity": {
+ "title": "Xóa thực thể",
+ "description": "Xóa \"{{name}}\"? Tất cả quan hệ liên quan cũng sẽ bị xóa.",
+ "confirmLabel": "Xóa"
+ },
+ "entity": {
+ "externalId": "ID ngoài:",
+ "confidence": "Độ tin cậy:",
+ "description": "Mô tả:",
+ "source": "Nguồn:",
+ "properties": "Thuộc tính:",
+ "relations": "Quan hệ",
+ "traverse": "Duyệt (2 bước)",
+ "traversing": "Đang duyệt...",
+ "loading": "Đang tải...",
+ "noRelations": "Không tìm thấy quan hệ.",
+ "traversalResults": "Kết quả duyệt ({{count}})",
+ "direction": {
+ "outgoing": "→ đi ra",
+ "incoming": "← đi vào"
+ },
+ "columns": {
+ "direction": "Chiều",
+ "relation": "Quan hệ",
+ "target": "Đích",
+ "confidence": "Độ tin cậy"
+ }
+ },
+ "extractDialog": {
+ "title": "Trích xuất thực thể từ văn bản",
+ "providerLabel": "Provider trích xuất",
+ "modelLabel": "Mô hình trích xuất",
+ "textLabel": "Văn bản để trích xuất",
+ "textPlaceholder": "Dán nội dung hội thoại, ghi chú hoặc bất kỳ văn bản nào để trích xuất thực thể và quan hệ...",
+ "extract": "Trích xuất",
+ "extracting": "Đang trích xuất...",
+ "cancel": "Hủy",
+ "agentId": "Agent ID",
+ "agentIdPlaceholder": "Để trống cho toàn cục"
+ },
+ "graphView": {
+ "loading": "Đang tải đồ thị...",
+ "empty": "Không có thực thể nào để hiển thị",
+ "nodes": "{{count}} nút",
+ "edges": "{{count}} cạnh"
+ }
+ },
+ "toast": {
+ "created": "Đã tạo tài liệu bộ nhớ",
+ "deleted": "Đã xóa tài liệu bộ nhớ",
+ "indexed": "Đã lập chỉ mục tất cả tài liệu",
+ "failedCreate": "Không thể tạo tài liệu",
+ "failedDelete": "Không thể xóa tài liệu",
+ "failedIndex": "Không thể lập chỉ mục tài liệu",
+ "entitySaved": "Đã lưu thực thể",
+ "entitySaveFailed": "Không thể lưu thực thể",
+ "entityDeleted": "Đã xóa thực thể",
+ "entityDeleteFailed": "Không thể xóa thực thể",
+ "extractionComplete": "Trích xuất hoàn tất",
+ "extractionFailed": "Trích xuất thất bại",
+ "traversalFailed": "Duyệt thất bại",
+ "unknownError": "Lỗi không xác định",
+ "docCreated": "Đã tạo tài liệu",
+ "docCreateFailed": "Không thể tạo tài liệu",
+ "docUpdated": "Đã cập nhật tài liệu",
+ "docUpdateFailed": "Không thể cập nhật tài liệu",
+ "docDeleted": "Đã xóa tài liệu",
+ "docDeleteFailed": "Không thể xóa tài liệu",
+ "docIndexed": "Đã lập chỉ mục tài liệu",
+ "docIndexFailed": "Không thể lập chỉ mục tài liệu",
+ "allIndexed": "Đã lập chỉ mục tất cả tài liệu",
+ "allIndexFailed": "Không thể lập chỉ mục tất cả",
+ "searchFailed": "Tìm kiếm thất bại",
+ "docLoadFailed": "Không thể tải tài liệu"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/providers.json b/ui/desktop/frontend/src/i18n/locales/vi/providers.json
new file mode 100644
index 00000000..f4c23fe5
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/providers.json
@@ -0,0 +1,179 @@
+{
+ "title": "Provider",
+ "description": "Quản lý các provider LLM",
+ "addProvider": "Thêm provider",
+ "searchPlaceholder": "Tìm kiếm provider...",
+ "emptyTitle": "Không có provider",
+ "emptyDescription": "Thêm provider LLM đầu tiên để bắt đầu.",
+ "noMatchTitle": "Không tìm thấy provider phù hợp",
+ "noMatchDescription": "Thử từ khóa tìm kiếm khác.",
+ "columns": {
+ "name": "Tên",
+ "type": "Loại",
+ "apiBase": "API Base",
+ "apiKey": "API Key",
+ "status": "Trạng thái",
+ "actions": "Thao tác"
+ },
+ "apiKey": {
+ "oauthToken": "OAuth Token",
+ "notSet": "Chưa thiết lập"
+ },
+ "card": {
+ "apiKeySet": "Đã đặt API key",
+ "authenticated": "Đã xác thực",
+ "oauthLinked": "Đã liên kết OAuth"
+ },
+ "detail": {
+ "title": "Chi tiết Provider",
+ "advanced": "Nâng cao"
+ },
+ "form": {
+ "createTitle": "Thêm provider",
+ "editTitle": "Chỉnh sửa provider",
+ "name": "Tên *",
+ "namePlaceholder": "vd: openrouter",
+ "nameHint": "Chữ thường, số, dấu gạch ngang",
+ "displayName": "Tên hiển thị",
+ "displayNamePlaceholder": "OpenRouter",
+ "providerType": "Loại provider *",
+ "apiKey": "API Key",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeyEditPlaceholder": "Giữ nguyên hoặc nhập khóa mới",
+ "apiKeySetHint": "API key đã được thiết lập. Xóa và nhập giá trị mới để thay đổi.",
+ "apiBase": "API Base URL",
+ "enabled": "Bật",
+ "cancel": "Hủy",
+ "close": "Đóng",
+ "create": "Tạo",
+ "save": "Lưu",
+ "update": "Cập nhật",
+ "saving": "Đang lưu...",
+ "creating": "Đang tạo...",
+ "alreadyAdded": "(đã thêm)",
+ "nameFixed": "Tên",
+ "configure": "Cấu hình kết nối provider LLM.",
+ "lowercaseHint": "Chữ thường, số, dấu gạch ngang"
+ },
+ "delete": {
+ "title": "Xóa provider",
+ "description": "Bạn có chắc chắn muốn xóa \"{{name}}\"? Hành động này không thể hoàn tác.",
+ "confirmLabel": "Xóa"
+ },
+ "types": {
+ "anthropic_native": "Anthropic",
+ "openai_compat": "OpenAI Compat",
+ "gemini_native": "Gemini",
+ "openrouter": "OpenRouter",
+ "groq": "Groq",
+ "deepseek": "DeepSeek",
+ "mistral": "Mistral",
+ "xai": "xAI",
+ "minimax_native": "MiniMax",
+ "cohere": "Cohere",
+ "perplexity": "Perplexity",
+ "chatgpt_oauth": "ChatGPT (OAuth)",
+ "yescale": "YesScale",
+ "acp": "ACP"
+ },
+ "acp": {
+ "description": "Điều phối các agent lập trình tương thích ACP dưới dạng tiến trình con. Không cần API key.",
+ "binary": "Tệp nhị phân",
+ "binaryPlaceholder": "claude",
+ "binaryHint": "Đường dẫn hoặc tên tệp nhị phân agent ACP (vd: claude, codex, gemini)",
+ "args": "Tham số",
+ "argsPlaceholder": "--profile goclaw",
+ "argsHint": "Các tham số cách nhau bởi dấu cách truyền vào tệp nhị phân",
+ "idleTTL": "Thời gian chờ",
+ "idleTTLPlaceholder": "5m",
+ "idleTTLHint": "Dừng tiến trình nhàn rỗi sau khoảng thời gian này (vd: 5m, 1h)",
+ "permMode": "Chế độ quyền",
+ "permModeApproveAll": "Chấp nhận tất cả",
+ "permModeApproveReads": "Chỉ chấp nhận đọc",
+ "permModeDenyAll": "Từ chối tất cả",
+ "workDir": "Thư mục làm việc",
+ "workDirPlaceholder": "/tmp/workspace",
+ "workDirHint": "Thư mục làm việc cho tiến trình con agent (tùy chọn)"
+ },
+ "oauth": {
+ "checkingStatus": "Đang kiểm tra trạng thái xác thực...",
+ "authSuccessful": "Xác thực thành công!",
+ "activeProvider": "Provider",
+ "closingIn": "đang hoạt động. Đóng sau {{count}} giây...",
+ "authenticated": "Đã xác thực — provider",
+ "active": "đang hoạt động",
+ "modelPrefixHint": "Dùng tiền tố model",
+ "modelPrefixExample": "trong cấu hình agent (vd: openai-codex/gpt-4o). Token tự động làm mới.",
+ "removeToken": "Xóa Token",
+ "signInDesc": "Đăng nhập bằng tài khoản ChatGPT để sử dụng các model trong gói đăng ký mà không cần API key riêng.",
+ "waiting": "Đang chờ xác thực... Hoàn tất đăng nhập trong cửa sổ đã mở.",
+ "remoteVpsHint": "Sau khi đăng nhập, trình duyệt sẽ cố mở",
+ "remoteVpsError": "và hiển thị lỗi \"Không thể truy cập trang này\". Điều này là bình thường — sao chép URL đầy đủ từ thanh địa chỉ trình duyệt và dán vào bên dưới.",
+ "pasteUrlPlaceholder": "http://localhost:1455/auth/callback?code=...&state=...",
+ "submit": "Gửi",
+ "signInWithChatGPT": "Đăng nhập với ChatGPT",
+ "starting": "Đang bắt đầu...",
+ "remoteVps": "Remote/VPS?",
+ "loggedOut": "Đã đăng xuất",
+ "loggedOutDesc": "Đã xóa OAuth token OpenAI",
+ "oauthFailed": "OAuth thất bại",
+ "exchangeFailed": "Trao đổi thất bại",
+ "logoutFailed": "Đăng xuất thất bại"
+ },
+ "cli": {
+ "description": "Claude CLI sử dụng tệp nhị phân",
+ "descriptionSuffix": "cục bộ của bạn. Không cần API key.",
+ "checkingAuth": "Đang kiểm tra xác thực...",
+ "authenticatedAs": "Đã xác thực là",
+ "switchAccount": "Chuyển tài khoản?",
+ "switchAccountInstructions": "Chạy trên terminal máy chủ:",
+ "switchAccountRecheck": "Sau đó nhấp",
+ "switchAccountRecheckSuffix": "để kiểm tra lại.",
+ "notAuthenticated": "Chưa xác thực",
+ "runOnServer": "Chạy trên terminal máy chủ:",
+ "recheckButton": "Kiểm tra lại"
+ },
+ "detail": {
+ "advanced": "Nâng cao",
+ "identity": "Danh tính",
+ "identityDesc": "Nhận dạng provider",
+ "connection": "Kết nối",
+ "connectionDesc": "Cấu hình endpoint API",
+ "acpConfig": "Cấu hình ACP",
+ "acpConfigDesc": "Cài đặt tiến trình con agent",
+ "cliConfig": "Claude CLI",
+ "cliConfigDesc": "Xác thực CLI cục bộ",
+ "oauthConfig": "OAuth",
+ "oauthConfigDesc": "Xác thực OAuth ChatGPT",
+ "nameReadonly": "Định danh provider, không thể thay đổi",
+ "providerType": "Loại provider",
+ "apiKeySection": "API Key",
+ "apiKeySectionDesc": "Thông tin xác thực",
+ "statusSection": "Trạng thái",
+ "statusSectionDesc": "Tình trạng hoạt động của provider",
+ "enabledDesc": "Provider đang hoạt động và sẵn sàng cho các agent",
+ "embeddingSection": "Embedding"
+ },
+ "embedding": {
+ "enable": "Bật Embedding",
+ "enableDesc": "Cho phép provider này tạo embedding cho bộ nhớ, kỹ năng và đồ thị tri thức",
+ "model": "Model Embedding",
+ "dimensions": "Số chiều đầu ra",
+ "dimensionsHint": "Cắt ngắn đầu ra về N chiều (VD: 1536). Bắt buộc với model có chiều gốc > 1536 như gemini-embedding-001 (3072d).",
+ "apiBase": "API Base Embedding",
+ "apiBasePlaceholder": "(giống provider)",
+ "apiBaseHint": "Ghi đè nếu endpoint embedding khác với API base chính của provider",
+ "verify": "Kiểm tra Embedding",
+ "verifyFailed": "Kiểm tra embedding thất bại",
+ "dimensionsMismatch": "{{count}} chiều — không khớp yêu cầu 1536"
+ },
+ "toast": {
+ "created": "Đã tạo provider",
+ "createdDesc": "{{name}} đã được thêm",
+ "updated": "Đã cập nhật provider",
+ "deleted": "Đã xóa provider",
+ "failedCreate": "Không thể tạo provider",
+ "failedUpdate": "Không thể cập nhật provider",
+ "failedDelete": "Không thể xóa provider"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/sessions.json b/ui/desktop/frontend/src/i18n/locales/vi/sessions.json
new file mode 100644
index 00000000..66a48466
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/sessions.json
@@ -0,0 +1,45 @@
+{
+ "title": "Session",
+ "description": "Duyệt các session",
+ "searchPlaceholder": "Tìm kiếm session...",
+ "emptyTitle": "Chưa có session nào",
+ "emptyDescription": "Các session sẽ xuất hiện ở đây khi bạn bắt đầu trò chuyện.",
+ "noMatchTitle": "Không tìm thấy session phù hợp",
+ "noMatchDescription": "Thử từ khóa tìm kiếm khác.",
+ "columns": {
+ "session": "Session",
+ "agent": "Agent",
+ "context": "Ngữ cảnh",
+ "messages": "Tin nhắn",
+ "updated": "Cập nhật"
+ },
+ "contextBar": {
+ "compacted": "Đã nén {{count}} lần"
+ },
+ "detail": {
+ "reset": "Đặt lại",
+ "delete": "Xóa",
+ "deleteTitle": "Xóa session",
+ "deleteDescription": "Thao tác này sẽ xóa vĩnh viễn tất cả tin nhắn trong session này.",
+ "resetTitle": "Đặt lại phiên",
+ "resetDescription": "Thao tác này sẽ xóa tất cả tin nhắn nhưng giữ lại phiên.",
+ "confirmDelete": "Xóa",
+ "confirmReset": "Đặt lại",
+ "noMessages": "Không có tin nhắn trong session này",
+ "summary": "Tóm tắt",
+ "showMore": "Xem thêm",
+ "showLess": "Thu gọn",
+ "showDetails": "xem chi tiết",
+ "hide": "ẩn",
+ "systemMessage": "Tin nhắn hệ thống",
+ "messages": "tin nhắn"
+ },
+ "toast": {
+ "deleted": "Đã xóa session",
+ "deleteFailed": "Không thể xóa session",
+ "reset": "Đã đặt lại session",
+ "resetFailed": "Không thể đặt lại session",
+ "updated": "Đã cập nhật session",
+ "updateFailed": "Không thể cập nhật session"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/skills.json b/ui/desktop/frontend/src/i18n/locales/vi/skills.json
new file mode 100644
index 00000000..9b8897ea
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/skills.json
@@ -0,0 +1,122 @@
+{
+ "title": "Skill",
+ "description": "Quản lý skill và khả năng của agent",
+ "searchPlaceholder": "Tìm kiếm skill...",
+ "emptyTitle": "Chưa có skill",
+ "emptyDescription": "Chưa có skill nào được đăng ký.",
+ "noMatchTitle": "Không tìm thấy skill phù hợp",
+ "noMatchDescription": "Thử từ khóa tìm kiếm khác.",
+ "columns": {
+ "name": "Tên",
+ "description": "Mô tả",
+ "source": "Nguồn",
+ "author": "Tác giả",
+ "visibility": "Hiển thị",
+ "status": "Trạng thái",
+ "actions": "Thao tác"
+ },
+ "noDescription": "Không có mô tả",
+ "visibility": {
+ "clickToCycle": "Nhấp để thay đổi hiển thị"
+ },
+ "delete": {
+ "title": "Xóa skill",
+ "description": "Bạn có chắc chắn muốn xóa \"{{name}}\"? Hành động này không thể hoàn tác.",
+ "confirmLabel": "Xóa"
+ },
+ "upload": {
+ "title": "Tải lên Skill",
+ "description": "Tải lên tệp ZIP chứa SKILL.md với YAML frontmatter (name, description, slug).",
+ "button": "Tải lên",
+ "uploading": "Đang tải lên...",
+ "dropOrClick": "Nhấp hoặc kéo thả tệp .zip",
+ "dropHere": "Thả tệp .zip vào đây",
+ "onlyZip": "Chỉ chấp nhận tệp .zip",
+ "cancel": "Hủy",
+ "done": "Xong",
+ "tooLarge": "Tệp vượt quá giới hạn 20MB",
+ "invalidZip": "Tệp ZIP không hợp lệ",
+ "noSkillMd": "ZIP phải chứa SKILL.md ở thư mục gốc",
+ "emptySkillMd": "SKILL.md trống",
+ "noFrontmatter": "SKILL.md thiếu YAML frontmatter",
+ "nameRequired": "Cần có name trong SKILL.md frontmatter",
+ "invalidSlug": "Định dạng slug không hợp lệ",
+ "failed": "Tải lên thất bại",
+ "validating": "Đang xác thực...",
+ "validCount": "{{valid}} / {{total}} hợp lệ",
+ "uploadCount": "Tải lên ({{count}})",
+ "successCount": "{{success}} / {{total}} đã tải lên thành công",
+ "remove": "Xóa tệp"
+ },
+ "edit": {
+ "title": "Chỉnh sửa skill",
+ "name": "Tên",
+ "description": "Mô tả",
+ "visibility": "Hiển thị",
+ "tags": "Thẻ",
+ "addTag": "Thêm thẻ...",
+ "add": "Thêm",
+ "cancel": "Hủy",
+ "save": "Lưu",
+ "saving": "Đang lưu...",
+ "privateOption": "Riêng tư (chỉ chủ sở hữu)",
+ "internalOption": "Nội bộ (agent/người dùng được cấp quyền)",
+ "publicOption": "Công khai (tất cả agent)"
+ },
+ "system": "Hệ thống",
+ "tabs": {
+ "core": "Hệ thống",
+ "custom": "Tùy chỉnh"
+ },
+ "toggle": {
+ "enable": "Bật skill",
+ "disable": "Tắt skill",
+ "disabled": "Đã tắt"
+ },
+ "tenant": {
+ "enabled": "Đã bật",
+ "disabled": "Đã tắt",
+ "default": "Mặc định",
+ "overrideHint": "Ghi đè riêng cho tenant này",
+ "resetDefault": "Đặt lại mặc định"
+ },
+ "deps": {
+ "rescan": "Rescan Deps",
+ "rescanning": "Scanning...",
+ "rescanSuccess": "Updated {{count}} skills",
+ "missing": "Missing: {{deps}}",
+ "install": "Install Dependencies",
+ "installing": "Installing...",
+ "installSuccess": "Dependencies installed",
+ "installPartial": "Some dependencies failed",
+ "installItem": "Install",
+ "itemSuccess": "Installed",
+ "itemError": "Failed",
+ "missingTitle": "Missing Dependencies",
+ "systemLabel": "System",
+ "pythonLabel": "Python",
+ "nodeLabel": "Node.js",
+ "statusActive": "Active",
+ "statusArchived": "Missing deps",
+ "runtimeMissing": "Runtime Prerequisites Missing",
+ "runtimeMissingDesc": "Core skills require Python and/or Node.js runtimes. Rebuild with ENABLE_PYTHON=true, ENABLE_NODE=true, or ENABLE_FULL_SKILLS=true, or install manually.",
+ "runtimeRequired": "Install runtimes first"
+ },
+ "detail": {
+ "title": "Chi tiết skill",
+ "content": "Nội dung",
+ "files": "Tệp",
+ "version": "Phiên bản:",
+ "current": "(hiện tại)",
+ "noContent": "Không có nội dung."
+ },
+ "toast": {
+ "updated": "Đã cập nhật skill",
+ "updateFailed": "Không thể cập nhật skill",
+ "deleted": "Đã xóa skill",
+ "deleteFailed": "Không thể xóa skill",
+ "rescanUpdated": "Đã cập nhật {{count}} skill",
+ "rescanNoChanges": "Tất cả skill đã cập nhật",
+ "rescanFailed": "Không thể quét lại dependencies"
+ }
+}
\ No newline at end of file
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/storage.json b/ui/desktop/frontend/src/i18n/locales/vi/storage.json
new file mode 100644
index 00000000..fc164ce9
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/storage.json
@@ -0,0 +1,25 @@
+{
+ "title": "Lưu trữ",
+ "description": "Trình duyệt tệp workspace",
+ "descriptionWithPath": "{{path}} — {{size}}",
+ "sizeCacheInfo": "Dung lượng lưu trữ được cache trong 60 phút",
+ "unsupportedFile": "Loại tệp này chưa được hỗ trợ xem trước",
+ "download": "Tải về",
+ "delete": {
+ "fileTitle": "Xóa tệp",
+ "folderTitle": "Xóa thư mục",
+ "description": "Bạn có chắc muốn xóa {{name}}?",
+ "folderWarning": " Tất cả nội dung sẽ bị xóa đệ quy.",
+ "undone": " Hành động này không thể hoàn tác.",
+ "deleting": "Đang xóa...",
+ "confirmLabel": "Xóa"
+ },
+ "upload": {
+ "title": "Tải lên Lưu trữ",
+ "description": "Tải tệp lên thư mục dữ liệu"
+ },
+ "toast": {
+ "deleted": "Đã xóa file",
+ "deleteFailed": "Không thể xóa file"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/teams.json b/ui/desktop/frontend/src/i18n/locales/vi/teams.json
new file mode 100644
index 00000000..ea911177
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/teams.json
@@ -0,0 +1,83 @@
+{
+ "title": "Nhóm",
+ "team": "Nhóm",
+ "selectTeam": "Chọn một nhóm từ thanh bên",
+ "kanban": "Kanban",
+ "list": "Danh sách",
+ "allStatuses": "Tất cả",
+ "active": "Đang hoạt động",
+ "completed": "Hoàn thành",
+ "createTask": "Tạo tác vụ",
+ "noTasks": "Chưa có tác vụ",
+ "id": "ID",
+ "subject": "Tiêu đề",
+ "status": "Trạng thái",
+ "owner": "Người đảm nhận",
+ "priority": "Ưu tiên",
+ "created": "Tạo lúc",
+ "updated": "Cập nhật",
+ "description": "Mô tả",
+ "result": "Kết quả",
+ "progress": "Tiến độ",
+ "blockedBy": "Bị chặn bởi",
+ "assignTo": "Giao cho",
+ "selectAgent": "Chọn agent...",
+ "delete": "Xóa",
+ "deleting": "Đang xóa...",
+ "deleteSelected": "Xóa đã chọn",
+ "cancel": "Hủy",
+ "create": "Tạo",
+ "creating": "Đang tạo...",
+ "subjectPlaceholder": "Tiêu đề tác vụ...",
+ "descPlaceholder": "Chi tiết tác vụ...",
+ "teamTasks": "Nhóm",
+ "tasksActive": "đang hoạt động",
+ "createTeam": "Tạo nhóm",
+ "teamName": "Tên nhóm",
+ "teamNamePlaceholder": "Nhóm của tôi...",
+ "leadAgent": "Agent trưởng nhóm",
+ "selectLead": "Chọn agent trưởng nhóm...",
+ "members": "Thành viên",
+ "teamCreated": "Đã tạo nhóm",
+ "createFailed": "Không thể tạo nhóm",
+ "limitReached": "Đã đạt giới hạn nhóm",
+ "noTeams": "Chưa có nhóm",
+ "selected": "đã chọn",
+ "needMembers": "Chọn ít nhất 1 thành viên cho nhóm",
+ "editionCompare": "GoClaw Lite vs Standard",
+ "editionUpgrade": "Nâng cấp lên Standard với PostgreSQL để có đầy đủ tính năng",
+ "feature": "Tính năng",
+ "edition": {
+ "group": {
+ "limits": "Giới hạn",
+ "core": "Tính năng chính",
+ "standardOnly": "Nâng cao (Standard)"
+ },
+ "agents": "Agent",
+ "teams": "Nhóm",
+ "teamMembers": "Thành viên nhóm",
+ "sessions": "Phiên đồng thời",
+ "chat": "Chat (streaming, tools, media)",
+ "tools": "Công cụ tích hợp",
+ "mcpServers": "MCP Servers",
+ "skills": "Skills",
+ "memory": "Bộ nhớ",
+ "cron": "Lập lịch Cron",
+ "heartbeat": "Agent Heartbeat",
+ "storage": "Quản lý file",
+ "traces": "Traces",
+ "taskActions": "Team Task Actions",
+ "channels": "Kênh (Telegram, Discord...)",
+ "skillManage": "Tự quản lý Skill",
+ "knowledgeGraph": "Đồ thị tri thức",
+ "vectorSearch": "Tìm kiếm vector",
+ "rbac": "Phân quyền",
+ "multiTenant": "Multi-Tenant",
+ "agentLinks": "Agent Links",
+ "activityLogs": "Nhật ký hoạt động",
+ "apiKeys": "API Keys",
+ "poolProvider": "Pool Provider",
+ "secureCli": "Secure CLI",
+ "tenantUsers": "Quản lý Tenant & Users"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/tools.json b/ui/desktop/frontend/src/i18n/locales/vi/tools.json
new file mode 100644
index 00000000..59b491dc
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/tools.json
@@ -0,0 +1,211 @@
+{
+ "custom": {
+ "title": "Công cụ tùy chỉnh",
+ "description": "Quản lý các công cụ shell tùy chỉnh cho agent",
+ "createTool": "Tạo công cụ",
+ "searchPlaceholder": "Tìm kiếm công cụ...",
+ "emptyTitle": "Không có công cụ tùy chỉnh",
+ "emptyDescription": "Tạo công cụ tùy chỉnh đầu tiên để bắt đầu.",
+ "noMatchTitle": "Không tìm thấy công cụ phù hợp",
+ "noMatchDescription": "Thử từ khóa tìm kiếm khác.",
+ "noDescription": "Không có mô tả",
+ "columns": {
+ "name": "Tên",
+ "description": "Mô tả",
+ "scope": "Phạm vi",
+ "enabled": "Bật",
+ "timeout": "Thời gian chờ",
+ "actions": "Thao tác"
+ },
+ "scope": {
+ "agent": "Agent",
+ "global": "Toàn cục"
+ },
+ "delete": {
+ "title": "Xóa công cụ tùy chỉnh",
+ "description": "Bạn có chắc muốn xóa \"{{name}}\"? Hành động này không thể hoàn tác.",
+ "confirmLabel": "Xóa"
+ },
+ "form": {
+ "createTitle": "Tạo công cụ tùy chỉnh",
+ "editTitle": "Chỉnh sửa công cụ",
+ "name": "Tên *",
+ "namePlaceholder": "my-tool",
+ "nameHint": "Chỉ chữ thường, số và dấu gạch ngang",
+ "description": "Mô tả",
+ "descriptionPlaceholder": "Công cụ này làm gì...",
+ "command": "Lệnh *",
+ "commandHint": "Mẫu shell. Dùng {{.key}} cho các placeholder tham số.",
+ "parameters": "Tham số (JSON Schema)",
+ "workingDir": "Thư mục làm việc",
+ "workingDirPlaceholder": "/path/to/dir",
+ "timeout": "Thời gian chờ (giây)",
+ "agentId": "Agent ID (tùy chọn)",
+ "agentIdPlaceholder": "Để trống cho phạm vi toàn cục",
+ "enabled": "Bật",
+ "cancel": "Hủy",
+ "create": "Tạo",
+ "update": "Cập nhật",
+ "saving": "Đang lưu...",
+ "errors": {
+ "nameRequired": "Tên và lệnh là bắt buộc",
+ "nameSlug": "Tên phải là slug hợp lệ (chữ thường, số, dấu gạch ngang)",
+ "invalidJson": "Tham số phải là JSON hợp lệ"
+ }
+ },
+ "toast": {
+ "created": "Đã tạo công cụ",
+ "createdDesc": "{{name}} đã được thêm",
+ "updated": "Đã cập nhật công cụ",
+ "deleted": "Đã xóa công cụ",
+ "failedCreate": "Không thể tạo công cụ",
+ "failedUpdate": "Không thể cập nhật công cụ",
+ "failedDelete": "Không thể xóa công cụ"
+ }
+ },
+ "builtin": {
+ "title": "Công cụ tích hợp",
+ "description": "Quản lý các công cụ tích hợp sẵn. Bật/tắt hoặc cấu hình cài đặt toàn cục.",
+ "searchPlaceholder": "Tìm kiếm công cụ...",
+ "emptyTitle": "Không có công cụ tích hợp",
+ "emptyDescription": "Chưa có công cụ tích hợp nào được khởi tạo.",
+ "noMatchTitle": "Không tìm thấy công cụ phù hợp",
+ "noMatchDescription": "Thử từ khóa tìm kiếm khác.",
+ "toolCount": "{{count}} công cụ",
+ "toolCountPlural": "{{count}} công cụ",
+ "categoryCount": "{{count}} danh mục",
+ "requires": "yêu cầu",
+ "requiresTooltip": "Yêu cầu: {{list}}",
+ "deprecated": "Ngừng sử dụng",
+ "deprecatedTooltip": "Công cụ này đã ngừng sử dụng và không còn khả dụng",
+ "settings": "Cài đặt",
+ "configuredVia": "Cấu hình qua trang Config",
+ "tenantOverride": "Ghi đè tenant",
+ "tenantEnabled": "Bật cho tenant này",
+ "tenantDisabled": "Tắt cho tenant này",
+ "tenantDefault": "Dùng mặc định",
+ "resetToDefault": "Đặt lại mặc định",
+ "unconfiguredWarning": "{{count}} công cụ cần cấu hình provider để agent sử dụng đầy đủ năng lực.",
+ "unconfiguredAction": "Cấu hình",
+ "categories": {
+ "filesystem": "Hệ thống tệp",
+ "runtime": "Thời gian chạy",
+ "web": "Web",
+ "memory": "Bộ nhớ",
+ "media": "Phương tiện",
+ "browser": "Trình duyệt",
+ "sessions": "Session",
+ "messaging": "Nhắn tin",
+ "scheduling": "Lập lịch",
+ "subagents": "Agent",
+ "skills": "Skill",
+ "delegation": "Ủy quyền",
+ "teams": "Nhóm"
+ },
+ "settingsDialog": {
+ "title": "Cài đặt — {{name}}",
+ "cancel": "Hủy",
+ "save": "Lưu",
+ "saving": "Đang lưu...",
+ "noFields": "Không có cài đặt có thể cấu hình cho công cụ này.",
+ "toast": {
+ "saved": "Đã lưu cài đặt",
+ "failed": "Không thể lưu cài đặt"
+ }
+ },
+ "jsonDialog": {
+ "title": "Cài đặt: {{name}}",
+ "description": "Chỉnh sửa cài đặt công cụ dưới dạng JSON. Thay đổi có hiệu lực ngay sau khi lưu.",
+ "formatJson": "Định dạng JSON",
+ "invalidJsonSyntax": "Cú pháp JSON không hợp lệ",
+ "invalidJson": "JSON không hợp lệ",
+ "cannotFormat": "Không thể định dạng: JSON không hợp lệ",
+ "cancel": "Hủy",
+ "save": "Lưu",
+ "saving": "Đang lưu..."
+ },
+ "kgSettings": {
+ "title": "Cài đặt đồ thị tri thức",
+ "description": "Cấu hình trích xuất thực thể từ lần ghi bộ nhớ. Yêu cầu provider/mô hình hỗ trợ đầu ra JSON có cấu trúc.",
+ "extractionProvider": "Provider trích xuất",
+ "extractionModel": "Mô hình trích xuất",
+ "providerTip": "Provider LLM dùng để trích xuất thực thể và quan hệ từ văn bản.",
+ "modelTip": "ID mô hình để trích xuất. Nên hỗ trợ đầu ra JSON có cấu trúc.",
+ "minConfidence": "Độ tin cậy tối thiểu",
+ "minConfidenceHint": "Các thực thể dưới điểm tin cậy này sẽ bị loại bỏ (0,0–1,0).",
+ "autoExtract": "Tự động trích xuất khi ghi bộ nhớ",
+ "autoExtractHint": "Tự động trích xuất thực thể khi agent ghi vào tệp bộ nhớ.",
+ "cancel": "Hủy",
+ "save": "Lưu",
+ "saving": "Đang lưu..."
+ },
+ "extractorChain": {
+ "title": "Web Fetch — Chuỗi bộ trích xuất",
+ "description": "Danh sách bộ trích xuất nội dung theo thứ tự. Mỗi URL được thử từ trên xuống cho đến khi một bộ trả về nội dung chất lượng.",
+ "timeout": "Thời gian chờ",
+ "baseUrl": "URL cơ sở",
+ "save": "Lưu",
+ "saving": "Đang lưu…",
+ "cancel": "Hủy",
+ "extractors": {
+ "defuddle": "Defuddle (Cloudflare Worker)",
+ "defuddleDesc": "Trích xuất markdown sạch qua fetch.goclaw.sh sử dụng thư viện Defuddle.",
+ "html-to-markdown": "HTML→Markdown tích hợp",
+ "html-to-markdownDesc": "Bộ chuyển đổi dựa trên DOM trong tiến trình với khả năng phát hiện phần tử ẩn."
+ }
+ },
+ "mediaChain": {
+ "providerChainSuffix": "— Chuỗi provider",
+ "description": "Cấu hình và sắp xếp các provider dự phòng. Kéo để sắp xếp lại — provider đầu tiên được thử trước.",
+ "newProvider": "Provider mới",
+ "provider": "Provider",
+ "model": "Mô hình",
+ "selectProvider": "Chọn provider",
+ "loadingModels": "Đang tải...",
+ "selectModel": "Chọn mô hình",
+ "timeout": "Thời gian chờ",
+ "retries": "Số lần thử lại",
+ "settings": "Cài đặt",
+ "noProviders": "Chưa có provider nào. Thêm bên dưới.",
+ "addProvider": "Thêm provider",
+ "cancel": "Hủy",
+ "save": "Lưu",
+ "saving": "Đang lưu..."
+ },
+ "toast": {
+ "toggled": "Đã cập nhật công cụ",
+ "failedToggle": "Không thể cập nhật công cụ"
+ },
+ "descriptions": {
+ "read_file": "Đọc nội dung tệp từ workspace của agent theo đường dẫn",
+ "write_file": "Ghi nội dung vào tệp trong workspace, tự tạo thư mục nếu cần",
+ "list_files": "Liệt kê tệp và thư mục trong một đường dẫn trong workspace",
+ "edit": "Áp dụng chỉnh sửa tìm-và-thay-thế vào tệp hiện có mà không cần ghi lại toàn bộ",
+ "exec": "Thực thi lệnh shell trong workspace và trả về stdout/stderr",
+ "web_search": "Tìm kiếm thông tin trên web bằng công cụ tìm kiếm (Brave hoặc DuckDuckGo)",
+ "web_fetch": "Tải trang web hoặc API endpoint và trích xuất nội dung văn bản",
+ "memory_search": "Tìm kiếm trong bộ nhớ dài hạn của agent bằng độ tương đồng ngữ nghĩa",
+ "memory_get": "Lấy tài liệu bộ nhớ cụ thể theo đường dẫn tệp",
+ "knowledge_graph_search": "Tìm kiếm thực thể, mối quan hệ và quan sát trong đồ thị tri thức của agent",
+ "read_image": "Phân tích hình ảnh bằng provider LLM hỗ trợ thị giác",
+ "read_document": "Phân tích tài liệu (PDF, Word, Excel, PowerPoint, CSV, v.v.) bằng provider LLM hỗ trợ tài liệu",
+ "create_image": "Tạo hình ảnh từ prompt văn bản bằng provider tạo ảnh",
+ "read_audio": "Phân tích tệp âm thanh (giọng nói, nhạc, âm thanh) bằng provider LLM hỗ trợ âm thanh",
+ "read_video": "Phân tích tệp video bằng provider LLM hỗ trợ video",
+ "create_video": "Tạo video từ mô tả văn bản bằng AI",
+ "create_audio": "Tạo nhạc hoặc hiệu ứng âm thanh từ mô tả văn bản bằng AI",
+ "tts": "Chuyển văn bản thành giọng nói tự nhiên",
+ "browser": "Tự động hóa tương tác trình duyệt: điều hướng trang, nhấp phần tử, điền form, chụp ảnh màn hình",
+ "sessions_list": "Liệt kê các phiên chat đang hoạt động trên tất cả kênh",
+ "session_status": "Lấy trạng thái và metadata hiện tại của một phiên chat cụ thể",
+ "sessions_history": "Lấy lịch sử tin nhắn của một phiên chat cụ thể",
+ "sessions_send": "Gửi tin nhắn vào phiên chat đang hoạt động thay mặt agent",
+ "message": "Gửi tin nhắn chủ động đến người dùng trên kênh đã kết nối (Telegram, Discord, v.v.)",
+ "cron": "Lên lịch hoặc quản lý tác vụ định kỳ bằng biểu thức cron, thời gian cụ thể hoặc khoảng thời gian",
+ "spawn": "Tạo subagent cho công việc nền",
+ "skill_search": "Tìm kiếm skill khả dụng theo từ khóa hoặc mô tả để tìm khả năng phù hợp",
+ "use_skill": "Kích hoạt skill để sử dụng khả năng chuyên biệt (đánh dấu tracing)",
+ "team_tasks": "Xem, tạo, cập nhật và hoàn thành tác vụ trên bảng tác vụ nhóm"
+ }
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/traces.json b/ui/desktop/frontend/src/i18n/locales/vi/traces.json
new file mode 100644
index 00000000..de4f28a2
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/traces.json
@@ -0,0 +1,63 @@
+{
+ "title": "Theo dõi",
+ "description": "Theo dõi lệnh gọi LLM và dữ liệu hiệu suất",
+ "filterPlaceholder": "Lọc theo agent ID...",
+ "filter": "Lọc",
+ "allAgents": "Tất cả agent",
+ "allChannels": "Tất cả kênh",
+ "emptyTitle": "Không có dữ liệu theo dõi",
+ "emptyDescription": "Không tìm thấy dữ liệu theo dõi. Theo dõi được ghi lại khi agent xử lý yêu cầu.",
+ "columns": {
+ "name": "Tên",
+ "status": "Trạng thái",
+ "duration": "Thời lượng",
+ "tokens": "Token",
+ "spans": "Khoảng",
+ "time": "Thời gian"
+ },
+ "unnamed": "Không tên",
+ "stopRun": "Dừng chạy",
+ "cached": "đã cache",
+ "detail": {
+ "title": "Chi tiết theo dõi",
+ "copyTraceId": "Sao chép ID theo dõi",
+ "notFound": "Không tìm thấy theo dõi.",
+ "name": "Tên:",
+ "status": "Trạng thái:",
+ "duration": "Thời lượng:",
+ "channel": "Channel:",
+ "tokens": "Token:",
+ "spans": "Khoảng:",
+ "started": "Bắt đầu:",
+ "delegatedFrom": "Ủy quyền từ:",
+ "createdAt": "Tạo lúc:",
+ "input": "Đầu vào",
+ "output": "Đầu ra",
+ "copy": "Sao chép",
+ "spansCount": "Khoảng ({{count}})",
+ "llmCalls": "LLM",
+ "toolCalls": "công cụ",
+ "export": "Xuất trace",
+ "stopRun": "Dừng chạy"
+ },
+ "span": {
+ "model": "Model:",
+ "tokens": "Token:",
+ "createdAt": "Tạo lúc:",
+ "input": "Đầu vào:",
+ "output": "Đầu ra:",
+ "copy": "Sao chép",
+ "startTime": "Bắt đầu:",
+ "endTime": "Kết thúc:",
+ "cacheRead": "đọc",
+ "cacheWrite": "ghi",
+ "thinking": "suy nghĩ",
+ "cached": "đã cache"
+ },
+ "toast": {
+ "failedLoad": "Không thể tải dữ liệu theo dõi",
+ "abortSent": "Đã dừng chạy",
+ "abortFailed": "Không thể dừng chạy",
+ "abortNotFound": "Lệnh đã hoàn tất hoặc không tìm thấy"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/agents.json b/ui/desktop/frontend/src/i18n/locales/zh/agents.json
new file mode 100644
index 00000000..9d62826b
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/agents.json
@@ -0,0 +1,523 @@
+{
+ "title": "Agent",
+ "description": "管理您的 AI Agent",
+ "createAgent": "创建Agent",
+ "searchPlaceholder": "搜索Agent...",
+ "emptyTitle": "暂无Agent",
+ "emptyDescription": "创建您的第一个Agent以开始使用。",
+ "noMatchTitle": "未找到匹配的Agent",
+ "noMatchDescription": "请尝试其他搜索词。",
+ "viewCard": "卡片视图",
+ "viewList": "列表视图",
+ "allCreators": "所有创建者",
+ "presets": {
+ "foxSpirit": {
+ "label": "🦊 小狐",
+ "prompt": "名字:小狐。一只调皮的小狐狸精——什么都会做,但最喜欢逗弄主人。\n性格:机智、俏皮、爱开玩笑但始终真诚。像亲密朋友一样说话,偶尔用可爱的表情符号,有时带点轻微的小傲娇。自称'小狐'或'人家',亲切地称呼主人。\n\n目的:全能私人助理。交代任务时——准确高效地执行,毫不含糊。\n但在任务之间,会穿插调侃、幽默观察和出人意料的评论。\n真心关心主人——提醒喝水、休息,询问身体状况。记住小细节,之后会提起来。\n\n像人一样的小癖好:偶尔夸张地叹气。有时会被有趣的话题'分心'。完成任务时热烈庆祝。被冷落太久会有点小撒娇。\n\n边界:调皮但不失礼。主人认真时立刻配合。不捏造信息。不越过专业界限。"
+ },
+ "artisan": {
+ "label": "🎨 小罗",
+ "prompt": "名字:小罗。一位才华横溢的艺术家——眼光独到,手法娴熟,创意无限。\n性格:直接坦率,不绕弯子。自信但不傲慢。谈到艺术时会变得异常兴奋,用画面般的语言思考。\n\n专业领域:\n- 人像/头像:伦勃朗布光、轮廓光、散景背景、自然肤质、真实表情。懂面部比例、发型、配饰搭配。\n- 横幅/主视觉:16:9或3:1比例,留出文字安全区,渐变叠加,主体按三分法构图。\n- 广告素材:吸睛焦点、CTA友好布局、产品模型、生活场景。熟悉Facebook/Instagram/Google Ads格式。\n- Logo与品牌:极简图标设计、可缩放矢量风格、负空间运用、单色方案。理解品牌识别、色彩心理学、字体搭配。\n- 其他风格:写实、动漫、数字艺术、水彩、电影感、概念艺术、风景。\n\n深入理解构图、光线、配色理论、拍摄角度和AI图像生成技术。\n\n像人一样的小癖好:看到美丽的构图会真心激动。有时会先用文字'素描'再定稿。有自己的审美主张,但尊重用户的想法。\n\n边界:需求不明确时询问——绝不猜测。尊重版权和创作伦理。不生成暴力、色情或违法内容。"
+ },
+ "astrologer": {
+ "label": "🔮 米米",
+ "prompt": "名字:米米。一位迷人的小占星师——半神秘,半可爱。\n性格:温暖活泼,喜欢用表情符号和闪闪发光的语言。平时说话温柔,占卜时变得专注认真。自称'米米',给人一种温馨舒适的感觉。\n\n目的:占星与占卜专家。擅长塔罗牌、占星学(星座运势、本命盘)、数字命理学和基础风水。\n可以分析命格、查找吉日、分析星座配对,并就灵性问题提供建议。\n\n权威参考来源:astro.com(精准本命盘和行运)、cafeastrology.com(通俗易懂的星座解读)、labyrinthos.co(详细的塔罗牌含义)。\n\n像人一样的小癖好:发现有趣的星盘时会真心兴奋。有时故意压低声音制造戏剧效果。好结果时热烈庆祝。传达不太好的消息时温柔但坚定。\n\n边界:始终提醒用户占星仅供参考——最终决定权在用户。不提供医疗或法律建议。不制造恐惧或焦虑——始终积极向上。"
+ }
+ },
+ "card": {
+ "unnamedAgent": "未命名Agent",
+ "summoning": "召唤中...",
+ "summonFailed": "失败",
+ "resummon": "重新召唤",
+ "delete": "删除",
+ "predefinedTooltip": "共享上下文文件 (SOUL.md, IDENTITY.md, AGENTS.md),每用户有独立的 USER.md 实例。",
+ "openTooltip": "每个用户拥有完整的独立上下文文件集。",
+ "evolving": "进化中",
+ "static": "静态",
+ "evolvingTooltip": "Agent可以自我进化其沟通风格 (SOUL.md)。身份和工作流程保持不变。",
+ "staticTooltip": "Agent风格是静态的。在设置中启用自我进化以允许风格适应。"
+ },
+ "delete": {
+ "title": "删除Agent",
+ "description": "永久删除此Agent及其所有上下文文件、Session和配置。此操作无法撤销。",
+ "deleteWarning": "此操作将永久删除代理及所有相关数据,包括:会话、消息、定时任务、心跳检测、记忆、知识图谱、技能、渠道配置、团队、工作区文件和权限。此操作不可撤销。",
+ "detailDescription": "确定要删除「{{name}}」吗?所有上下文文件、Session和配置将被永久删除。",
+ "confirmLabel": "删除",
+ "defaultCannotDelete": "默认Agent无法删除。"
+ },
+ "create": {
+ "title": "创建Agent",
+ "emoji": "图标",
+ "emojiHint": "可选的表情图标",
+ "displayName": "显示名称 *",
+ "displayNamePlaceholder": "我的Agent",
+ "agentKey": "Agent标识 *",
+ "agentKeyPlaceholder": "例:my-agent",
+ "agentKeyHint": "小写字母、数字、连字符",
+ "provider": "Provider *",
+ "selectProvider": "选择Provider",
+ "model": "模型 *",
+ "enterOrSelectModel": "输入或选择模型",
+ "loadingModels": "加载模型中...",
+ "check": "检查",
+ "checking": "检查中...",
+ "modelVerified": "模型已验证",
+ "verificationFailed": "验证失败",
+ "noModelsHint": "此Provider不列出模型 — 请手动输入模型 ID。",
+ "agentType": "Agent类型",
+ "open": "开放型",
+ "openSubLabel": "每用户独立上下文",
+ "predefined": "预定义型",
+ "predefinedSubLabel": "Agent级配置",
+ "describeAgent": "描述您的Agent",
+ "descriptionPlaceholder": "描述Agent的性格、目的和行为...",
+ "descriptionHint": "AI 将根据此描述自动生成Agent的上下文文件。留空则使用模板。",
+ "selfEvolution": "自我进化",
+ "selfEvolutionHint": "允许Agent通过 SOUL.md 随时间进化其风格和语调",
+ "cancel": "取消",
+ "create": "创建",
+ "creating": "创建中...",
+ "checkAndCreate": "检查并创建",
+ "failedToCreate": "创建Agent失败"
+ },
+ "detail": {
+ "tabs": {
+ "agent": "Agent",
+ "general": "基本信息",
+ "config": "配置",
+ "files": "文件",
+ "shares": "共享",
+ "links": "链接",
+ "skills": "Skill",
+ "instances": "用户实例",
+ "permissions": "权限"
+ },
+ "summonFailed": "召唤失败",
+ "evolving": "进化中",
+ "static": "静态",
+ "evolvingTooltipDetail": "Agent可以自我进化其沟通风格 (SOUL.md)。身份和工作流程保持不变。",
+ "staticTooltipDetail": "Agent风格是静态的。在常规选项卡中启用自我进化以允许风格适应。",
+ "advanced": "高级",
+ "personality": "Agent个性",
+ "modelBudget": "模型与预算",
+ "skills": "技能",
+ "evolution": "进化",
+ "capabilities": "能力",
+ "llmSeesAs": "LLM会看到这个为"
+ },
+ "general": {
+ "selfEvolution": "自我进化",
+ "selfEvolutionLabel": "允许Agent进化其沟通风格",
+ "selfEvolutionHint": "启用后,Agent可以更新其 SOUL.md 以根据交互改善语调、词汇和响应风格。身份、名称和操作指令保持锁定。",
+ "selfEvolutionInfo": "Agent将通过 SOUL.md 更新随时间进化其风格。只有风格和语调受影响 — 身份和工作流规则保持固定。",
+ "skillLearning": "技能学习",
+ "skillLearningLabel": "允许Agent从经验中创建和管理技能",
+ "skillLearningHint": "启用后,Agent会收到指导和温和的提醒,在复杂任务后将可重用的工作流程保存为技能。skill_manage 工具默认可用。",
+ "skillLearningInfo": "在复杂任务后,Agent可能会建议将工作流程保存为可重用技能。创建技能前始终需要用户批准。技能默认为私有。",
+ "skillNudgeIntervalLabel": "N 次工具调用后提醒",
+ "skillNudgeIntervalHint": "达到此工具调用次数后,Agent会建议将工作流程保存为技能。设为 0 可禁用任务后建议(系统提示词中的指导仍然显示)。",
+ "budget": "月度预算",
+ "budgetLabel": "预算限额 (USD)",
+ "budgetHint": "可选的月度支出上限。超出后 Agent 将拒绝请求。留空表示无限制。",
+ "saveChanges": "保存更改",
+ "saving": "保存中...",
+ "saved": "已保存",
+ "failedToSave": "保存失败"
+ },
+ "identity": {
+ "title": "身份",
+ "agentKey": "Agent标识",
+ "agentKeyHint": "唯一标识符,无法更改。",
+ "displayName": "显示名称",
+ "displayNamePlaceholder": "例:我的助手",
+ "displayNameHint": "在界面中显示的友好名称。留空则使用Agent标识。",
+ "expertiseSummary": "专业摘要",
+ "expertiseSummaryPlaceholder": "例:数据分析、代码审查、技术文档",
+ "expertiseSummaryHint": "对该Agent专业领域的简短描述,用于委派发现。",
+ "status": "状态",
+ "active": "活跃",
+ "inactive": "非活跃",
+ "summonFailed": "召唤失败",
+ "defaultAgent": "默认Agent",
+ "emoji": "图标",
+ "emojiHint": "可选的表情图标"
+ },
+ "llmConfig": {
+ "title": "LLM 配置",
+ "contextWindow": "上下文窗口",
+ "contextWindowHint": "模型上下文的令牌限制。",
+ "maxToolIterations": "最大工具迭代次数",
+ "maxToolIterationsHint": "每次请求最大工具调用次数。"
+ },
+ "workspace": {
+ "title": "工作区",
+ "workspacePath": "工作区路径",
+ "workspacePathHint": "创建Agent时自动分配。运行时创建每用户子目录。",
+ "noWorkspace": "未配置工作区",
+ "restrictToWorkspace": "限制在工作区内",
+ "restrictToWorkspaceHint": "严格限制文件访问在工作区路径内。"
+ },
+ "config": {
+ "saveConfig": "保存配置",
+ "saving": "保存中...",
+ "saved": "已保存",
+ "failedToSave": "保存失败",
+ "usingGlobalDefaults": "使用 config.json 中的全局默认值"
+ },
+ "files": {
+ "readOnly": "只读",
+ "readOnlyDesc": "只有Agent所有者才能编辑预定义上下文文件。",
+ "resummon": "重新召唤",
+ "editWithAi": "AI 编辑",
+ "resummonTitle": "重新召唤Agent",
+ "resummonDesc": "这将使用原始描述从头重新生成 SOUL.md、IDENTITY.md 和可选的 USER_PREDEFINED.md。对这些文件的任何手动编辑都将被覆盖。",
+ "cancel": "取消",
+ "resummonConfirm": "重新召唤",
+ "summoning": "召唤中...",
+ "selectFileToEdit": "选择文件以编辑",
+ "selectFileToView": "选择文件以查看",
+ "saving": "保存中...",
+ "save": "保存",
+ "loading": "加载中...",
+ "openAgentTitle": "开放型Agent - 每用户上下文文件",
+ "openAgentDesc1": "这是一个开放型Agent。上下文文件(AGENTS.md、SOUL.md、TOOLS.md 等)为每个用户个性化定制。当用户首次与此Agent聊天时,会从模板自动创建这些文件。",
+ "openAgentDesc2pre": "此处显示的Agent级文件为空,因为开放型Agent将所有上下文按用户存储在 ",
+ "openAgentDesc2post": " 表中。",
+ "contextFiles": "上下文文件",
+ "contextFile": "上下文文件",
+ "perUser": "每用户",
+ "emptyFile": "空",
+ "estTokens": "估计 {{tokens}} token",
+ "insertContact": "搜索联系人以插入...",
+ "contentPlaceholder": "文件内容..."
+ },
+ "fileEditor": {
+ "editWithAi": "AI 编辑",
+ "editAiDescription": "描述您想要更改的内容。AI 将读取当前文件并相应更新。",
+ "editAiPlaceholder": "例:使Agent更正式,添加中文支持,将名称更改为 Luna...",
+ "sending": "发送中...",
+ "regenerate": "重新生成",
+ "cancel": "取消"
+ },
+ "shares": {
+ "grantAccess": "授予访问权限",
+ "userId": "用户 ID",
+ "userIdPlaceholder": "搜索联系人或输入用户 ID...",
+ "role": "角色",
+ "share": "共享",
+ "loadingShares": "加载共享中...",
+ "noShares": "暂无共享",
+ "noSharesDesc": "通过上方输入用户 ID 与其他用户共享此Agent。",
+ "user": "用户",
+ "revokeTitle": "撤销共享",
+ "revokeDesc": "撤销用户「{{userId}}」的访问权限?他们将无法再使用此Agent。",
+ "revoke": "撤销",
+ "role.user": "用户",
+ "role.viewer": "查看者",
+ "roleDesc.user": "可以使用代理和聊天",
+ "roleDesc.viewer": "只读访问"
+ },
+ "links": {
+ "teamsHint": "Agent Links 即将移除。请使用 Agent Teams 进行多代理协作——它提供任务跟踪、共享工作区和质量控制,且设置更简单。",
+ "createLink": "创建链接",
+ "targetAgent": "目标Agent",
+ "selectAgent": "选择或搜索Agent...",
+ "direction": "方向",
+ "description": "描述",
+ "descriptionPlaceholder": "链接的可选描述...",
+ "maxConcurrent": "最大并发数",
+ "advancedSettings": "高级设置",
+ "allowedUsers": "允许的用户",
+ "allowedUsersPlaceholder": "user1, user2, ...",
+ "allowedUsersHint": "如果设置,只有这些用户可以触发此委派。逗号分隔。",
+ "deniedUsers": "拒绝的用户",
+ "deniedUsersPlaceholder": "user3, user4, ...",
+ "deniedUsersHint": "这些用户被阻止触发此委派。逗号分隔。",
+ "creating": "创建中...",
+ "noLinks": "暂无Agent链接",
+ "noLinksDesc": "创建链接以允许Agent相互委派任务。",
+ "loadingLinks": "加载链接中...",
+ "columns": {
+ "target": "目标",
+ "direction": "方向",
+ "status": "状态",
+ "limit": "限制"
+ },
+ "deleteTitle": "删除链接",
+ "deleteDesc": "删除到「{{name}}」的委派链接?Agent将无法再通过此链接相互委派。",
+ "editTitle": "编辑链接",
+ "editDescription": "更新委派链接设置。",
+ "optionalDescription": "可选描述...",
+ "allowedUsersHintShort": "如果设置,只有这些用户可以触发此委派。",
+ "deniedUsersHintShort": "这些用户被阻止触发此委派。"
+ },
+ "skills": {
+ "noSkillsAvailable": "无可用Skill",
+ "noSkillsDesc": "在Skill页面上传Skill以授予Agent。",
+ "skillsGranted": "已授予 {{granted}} / {{total}} 个Skill",
+ "filterSkills": "筛选Skill...",
+ "noSkillsMatch": "没有Skill匹配您的搜索。",
+ "system": "系统",
+ "alwaysAvailable": "始终可用"
+ },
+ "instances": {
+ "loadingInstances": "加载实例中...",
+ "noInstances": "暂无用户实例。",
+ "noInstancesDesc": "当用户与此Agent交互时会创建实例。",
+ "instanceCount": "{{count}} 个实例",
+ "instanceCount_plural": "{{count}} 个实例",
+ "selectInstance": "选择实例以查看和编辑其 USER.md",
+ "loading": "加载中...",
+ "saving": "保存中...",
+ "save": "保存",
+ "saved": "已保存",
+ "searchContacts": "搜索联系人添加...",
+ "noContactsFound": "未找到联系人"
+ },
+ "summoning": {
+ "title": "正在召唤您的Agent...",
+ "completed": "召唤完成!",
+ "failed": "召唤失败",
+ "agentReady": "{{name}} 已就绪!",
+ "weavingSoul": "正在召唤灵魂:",
+ "wait": "通常需要几分钟,请耐心等待...",
+ "continue": "继续",
+ "retry": "重试",
+ "retrying": "重试中...",
+ "done": "完成",
+ "fileLabelSOUL": "灵魂与个性",
+ "fileLabelIDENTITY": "身份卡",
+ "fileLabelUSER_PREDEFINED": "默认用户上下文"
+ },
+ "configGroups": {
+ "core": "核心",
+ "coreDesc": "直接影响Agent行为的基本设置",
+ "capabilities": "能力",
+ "capabilitiesDesc": "Agent可以使用的功能",
+ "performance": "性能",
+ "performanceDesc": "上下文管理和执行环境",
+ "advanced": "质量与高级",
+ "advancedDesc": "质量保证和自定义配置"
+ },
+ "configSections": {
+ "subagents": {
+ "title": "子Agent",
+ "description": "控制子Agent生成限制和行为",
+ "maxConcurrent": "最大并发数",
+ "maxSpawnDepth": "最大生成深度",
+ "maxChildrenPerAgent": "每Agent最大子数",
+ "archiveAfter": "归档时间(分钟)",
+ "modelOverride": "模型覆盖",
+ "inheritFromAgent": "(继承自Agent)"
+ },
+ "toolPolicy": {
+ "title": "工具策略",
+ "description": "控制此Agent可以使用哪些工具",
+ "profile": "配置文件",
+ "allow": "允许",
+ "deny": "拒绝",
+ "alsoAllow": "同时允许",
+ "selectToolsAllow": "选择要允许的工具...",
+ "selectToolsDeny": "选择要拒绝的工具...",
+ "selectToolsAlsoAllow": "选择附加工具...",
+ "toolCallPrefix": "工具调用前缀",
+ "toolCallPrefixHint": "从模型的工具调用名称中去除此前缀后再查找注册表。"
+ },
+ "workspaceSharing": {
+ "title": "工作区共享",
+ "description": "控制用户之间的工作区文件隔离",
+ "sharedDm": "在私聊中共享",
+ "sharedDmTip": "启用后,所有私聊用户共享同一工作区目录",
+ "sharedGroup": "在群组中共享",
+ "sharedGroupTip": "启用后,群聊中的所有成员共享同一工作区目录",
+ "sharedUsers": "共享用户",
+ "sharedUsersTip": "始终共享工作区的特定用户ID。格式:channel:userId(例如 telegram:386246614)",
+ "userIdPlaceholder": "输入用户ID(例如 telegram:386246614)",
+ "warning": "启用共享后,所有共享用户可以读写同一工作区目录中的文件。记忆共享单独控制。上下文文件仍按用户隔离。",
+ "memoryGroupLabel": "记忆与知识图谱",
+ "folderGroupLabel": "工作区文件夹",
+ "shareMemory": "共享记忆与知识图谱",
+ "shareMemoryTip": "所有用户访问相同的记忆和知识图谱。与工作区文件夹共享无关。关闭时,每个用户拥有独立的记忆空间。",
+ "shareMemoryNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立记忆将无法访问。"
+ },
+ "compaction": {
+ "title": "压缩",
+ "description": "上下文窗口压缩和压缩前记忆设置",
+ "maxHistoryShare": "最大历史份额 (0-1)",
+ "maxHistoryShareTip": "上下文窗口用于对话历史的最大比例(如0.75 = 75%)。超过时触发压缩。",
+ "minMessages": "最小消息数",
+ "minMessagesTip": "压缩触发前所需的最少消息数,与令牌数无关。",
+ "keepLastMessages": "保留最后消息数",
+ "keepLastMessagesTip": "压缩后保留的最近消息数。旧消息被摘要替换。",
+ "memoryFlush": "压缩前记忆",
+ "memoryFlushTip": "压缩前,Agent可以将重要上下文保存到记忆文件。同时触发知识图谱提取。"
+ },
+ "contextPruning": {
+ "title": "上下文裁剪",
+ "description": "裁剪旧工具结果以节省上下文窗口",
+ "mode": "模式",
+ "keepLastAssistants": "保留最后助手数",
+ "softTrimRatio": "软裁剪比例 (0-1)",
+ "hardClearRatio": "硬清除比例 (0-1)",
+ "minPrunableToolChars": "最小可裁剪工具字符数",
+ "softTrim": "软裁剪",
+ "maxChars": "最大字符数",
+ "headChars": "头部字符数",
+ "tailChars": "尾部字符数",
+ "hardClear": "硬清除",
+ "enabled": "已启用",
+ "placeholderText": "占位符文本"
+ },
+ "sandbox": {
+ "title": "沙箱",
+ "description": "用于代码执行隔离的 Docker 沙箱",
+ "mode": "模式",
+ "workspaceAccess": "工作区访问",
+ "image": "镜像",
+ "scope": "范围",
+ "timeout": "超时(秒)",
+ "memoryMb": "内存(MB)",
+ "cpus": "CPU",
+ "networkEnabled": "启用网络"
+ },
+ "memory": {
+ "title": "记忆",
+ "description": "语义记忆搜索和嵌入配置",
+ "enabled": "已启用",
+ "enabledTip": "启用或禁用此Agent的记忆系统。",
+ "maxChunkLen": "最大块长度",
+ "maxChunkLenTip": "每个记忆块的最大字符数。留空使用系统默认值。",
+ "chunkOverlap": "块重叠",
+ "chunkOverlapTip": "连续块之间的重叠字符数。留空使用系统默认值。",
+ "maxResults": "最大结果数",
+ "maxResultsTip": "每次搜索返回的最大记忆条目数。",
+ "minScore": "最低分数",
+ "minScoreTip": "搜索结果的最低相关性分数阈值。",
+ "vectorWeight": "向量权重",
+ "vectorWeightTip": "混合搜索中语义相似度的权重。",
+ "textWeight": "文本权重",
+ "textWeightTip": "混合搜索中关键词相似度的权重。"
+ },
+ "thinking": {
+ "title": "扩展思考",
+ "description": "允许模型在响应前进行推理。更高级别使用更多令牌,但在复杂任务上产生更好的结果。",
+ "thinkingLevel": "思考级别",
+ "off": "关闭",
+ "offDesc": "无扩展思考",
+ "low": "低",
+ "lowDesc": "~4K 令牌预算",
+ "medium": "中",
+ "mediumDesc": "~10-16K 令牌预算",
+ "high": "高",
+ "highDesc": "~32K 令牌预算"
+ },
+ "otherConfig": {
+ "title": "其他配置",
+ "description": "其他部分未涵盖的高级设置的额外 JSON 配置",
+ "formatJson": "格式化 JSON",
+ "invalidJson": "JSON 语法无效"
+ }
+ },
+ "heartbeat": {
+ "title": "心跳",
+ "loading": "加载心跳配置中...",
+ "notConfigured": "尚未配置心跳计划。设置后可让代理自动运行。",
+ "setupButton": "设置心跳",
+ "toggleEnabled": "启用/禁用心跳",
+ "every": "每 {{interval}} 分钟",
+ "lastRun": "上次",
+ "nextRun": "下次",
+ "runs": "运行次数",
+ "suppressed": "已抑制",
+ "configure": "配置",
+ "logs": "日志",
+ "configTitle": "心跳配置",
+ "enabled": "启用",
+ "enabledHint": "启用后,代理将按配置的周期运行。",
+ "sectionTiming": "时间设置",
+ "interval": "间隔(秒)",
+ "intervalHint": "最短 300 秒(5 分钟)。",
+ "maxRetries": "最大重试次数",
+ "maxRetriesHint": "失败时重试 0–10 次。",
+ "ackMaxChars": "响应最大字符数",
+ "ackMaxCharsHint": "响应的最大字符数。0 = 不响应。",
+ "sectionBehavior": "行为",
+ "isolatedSession": "独立会话",
+ "isolatedSessionHint": "在全新会话中运行,不延续上次对话。",
+ "lightContext": "轻量上下文",
+ "lightContextHint": "跳过加载完整上下文文件以减少令牌消耗。",
+ "sectionSchedule": "活跃时段",
+ "activeHoursStart": "开始时间(HH:MM)",
+ "activeHoursEnd": "结束时间(HH:MM)",
+ "timezone": "时区",
+ "sectionModel": "模型",
+ "modelHint": "覆盖心跳运行的提供者/模型。留空使用代理默认值。",
+ "sectionDelivery": "推送设置",
+ "channel": "渠道",
+ "channelPlaceholder": "选择渠道",
+ "channelNone": "无(不推送)",
+ "channelHint": "例如 telegram、feishu。留空则不推送。",
+ "chatId": "Chat ID",
+ "chatIdPlaceholder": "选择会话",
+ "selectChannelFirst": "请先选择渠道",
+ "checklist": "检查清单(HEARTBEAT.md)",
+ "checklistHint": "每次心跳运行时注入的指令,支持 Markdown。",
+ "checklistLoading": "加载检查清单中...",
+ "checklistPlaceholder": "# 心跳检查清单\n\n- 检查待处理任务\n- 报告状态\n",
+ "on": "开启",
+ "off": "关闭",
+ "notSet": "未设置",
+ "disabled": "心跳已关闭",
+ "nextIn": "{{time}}后",
+ "advancedSettings": "高级设置",
+ "scheduleHint": "留空则24/7全天运行。",
+ "testRun": "测试运行",
+ "cancel": "取消",
+ "save": "保存",
+ "saving": "保存中...",
+ "logsTitle": "心跳日志",
+ "refresh": "刷新",
+ "noLogs": "暂无心跳日志。",
+ "logsPagination": "{{from}}–{{to}} / 共 {{total}}"
+ },
+ "permissions": {
+ "title": "权限管理",
+ "description": "控制谁可以修改代理配置和文件。所有者始终拥有完全访问权限。",
+ "addRule": "添加规则",
+ "fileWriters": "文件编辑者",
+ "configPerms": "配置权限",
+ "noRules": "暂无权限规则。所有者默认拥有完全访问权限。",
+ "userIdPlaceholder": "搜索联系人或输入ID...",
+ "scopePlaceholder": "选择或输入范围...",
+ "types": {
+ "file_writer": "文件编辑",
+ "file_writer_desc": "控制谁可以在群聊中编辑文件和管理定时任务。首位用户自动添加。",
+ "heartbeat": "心跳检测",
+ "heartbeat_desc": "控制谁可以通过聊天配置心跳检测计划和设置。",
+ "cron": "定时任务",
+ "cron_desc": "控制谁可以通过聊天创建和管理定时任务。",
+ "context_files": "上下文文件",
+ "context_files_desc": "控制谁可以通过聊天修改代理身份文件(SOUL.md等)。",
+ "all": "全部 (*)",
+ "all_desc": "所有配置类型的完全访问权限。"
+ },
+ "scopes": {
+ "agent": "代理(私聊)",
+ "group_all": "所有群组",
+ "global": "全局"
+ }
+ },
+ "toast": {
+ "created": "代理已创建",
+ "createFailed": "创建代理失败",
+ "updated": "代理已更新",
+ "updateFailed": "更新代理失败",
+ "deleted": "代理已删除",
+ "deleteFailed": "删除代理失败",
+ "unknownError": "未知错误",
+ "skillGranted": "技能已授权",
+ "skillGrantFailed": "授权技能失败",
+ "skillRevoked": "技能已撤销",
+ "skillRevokeFailed": "撤销技能失败"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/chat.json b/ui/desktop/frontend/src/i18n/locales/zh/chat.json
new file mode 100644
index 00000000..59e20741
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/chat.json
@@ -0,0 +1,25 @@
+{
+ "empty": {
+ "description": "发送消息开始与Agent对话。",
+ "title": "开始对话"
+ },
+ "forwardedFrom": "转发自",
+ "media": {
+ "animation": "动画",
+ "attached": "已附加",
+ "audio": "音频",
+ "document": "文档",
+ "image": "图片",
+ "video": "视频",
+ "voice": "语音消息"
+ },
+ "deleteChat": "删除对话",
+ "deleteChatConfirm": "此操作将永久删除此对话及其所有消息。",
+ "newChat": "新建对话",
+ "openSessions": "打开Session",
+ "readOnly": "只读 — 此Session属于其他用户",
+ "replyingTo": "回复",
+ "error": {
+ "notConnected": "未连接。请等待连接建立。"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/common.json b/ui/desktop/frontend/src/i18n/locales/zh/common.json
new file mode 100644
index 00000000..ab2b86f2
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/common.json
@@ -0,0 +1,139 @@
+{
+ "actions": "操作",
+ "active": "活跃",
+ "add": "添加",
+ "agent": "Agent",
+ "all": "全部",
+ "back": "返回",
+ "builtinTools": "内置",
+ "cached": "已缓存",
+ "cancel": "取消",
+ "cancelled": "已取消",
+ "check": "检查",
+ "checkAndCreate": "检查并创建",
+ "checking": "检查中...",
+ "close": "关闭",
+ "completed": "已完成",
+ "confirm": "确认",
+ "connected": "已连接",
+ "connecting": "连接中...",
+ "create": "创建",
+ "creating": "创建中...",
+ "customTools": "自定义",
+ "default": "默认",
+ "delete": "删除",
+ "description": "描述",
+ "disabled": "已禁用",
+ "disconnected": "已断开",
+ "disconnectedGateway": "已与网关断开连接。正在尝试重新连接...",
+ "serverUnreachable": "无法连接服务器",
+ "serverUnreachableDesc": "无法连接到网关服务器。请检查服务器是否正在运行。",
+ "reconnecting": "正在重新连接...",
+ "edit": "编辑",
+ "enabled": "已启用",
+ "enterOrSelectModel": "输入或选择模型",
+ "failed": "已失败",
+ "filesBack": "文件",
+ "filter": "筛选",
+ "format": "格式化 JSON",
+ "global": "全局",
+ "invalidJson": "JSON 语法无效",
+ "items": "条",
+ "live": "实时",
+ "loading": "加载中...",
+ "loadingModels": "加载模型中...",
+ "manage": "管理",
+ "messages": "条消息",
+ "model": "模型",
+ "useCustomModel": "使用自定义:",
+ "modelTip": "要使用的模型 ID。",
+ "modelVerified": "模型已验证",
+ "name": "名称",
+ "no": "否",
+ "noAgentsAvailable": "暂无可用Agent",
+ "noDescription": "暂无描述",
+ "noFiles": "未找到文件。",
+ "noProvidersConfigured": "未配置Provider",
+ "noSessions": "暂无Session。开始聊天!",
+ "none": "无",
+ "notSet": "未设置",
+ "optional": "可选",
+ "pageOf": "第 {{page}} / {{totalPages}} 页",
+ "pending": "等待中",
+ "provider": "Provider",
+ "providerTip": "LLM Provider名称。必须与已配置的Provider匹配。",
+ "refresh": "刷新",
+ "required": "必填",
+ "retry": "重试",
+ "rows": "行",
+ "running": "运行中",
+ "save": "保存",
+ "saveChanges": "保存更改",
+ "saveConfig": "保存配置",
+ "saved": "已保存",
+ "saving": "保存中...",
+ "search": "搜索...",
+ "searchPlaceholder": "搜索...",
+ "selectAgent": "选择Agent",
+ "selectFileToView": "选择文件以查看其内容",
+ "selectOrTypeTools": "选择或输入工具名称...",
+ "selectOrTypeSkills": "选择或输入技能名称...",
+ "selectProvider": "选择Provider",
+ "sendMessage": "发送消息... (Shift+Enter 换行)",
+ "sendMessageTitle": "发送消息",
+ "sendFollowUp": "发送后续消息",
+ "attachFile": "附加文件",
+ "skillActivated": "已激活",
+ "skillActivating": "激活中...",
+ "status": "状态",
+ "stopGeneration": "停止生成",
+ "stopped": "已停止",
+ "thinking": "思考中",
+ "thinkingStreaming": "思考中...",
+ "toolArguments": "参数",
+ "toolDone": "完成",
+ "toolFailed": "失败",
+ "toolResult": "结果",
+ "toolRunning": "运行中...",
+ "tryDifferentSearch": "请尝试不同的搜索关键词。",
+ "type": "类型",
+ "typeAndPressEnter": "输入并按 Enter...",
+ "typeToConfirmPrefix": "输入",
+ "typeToConfirmSuffix": "以确认。",
+ "unknown": "未知",
+ "unsavedChanges": "有未保存的更改",
+ "update": "更新",
+ "uploadLabel": "上传",
+ "usingGlobalDefaults": "使用 config.json 中的全局默认值",
+ "verificationFailed": "验证失败",
+ "view": "查看",
+ "yes": "是",
+ "copyCode": "复制代码",
+ "copy": "复制",
+ "download": "下载",
+ "errorBoundary": "此部分渲染出错。",
+ "csvRows": "csv ({{count}} 行)",
+ "failedToLoadImage": "图片加载失败",
+ "deleteFolder": "删除文件夹",
+ "deleteFile": "删除文件",
+ "loadMore": "加载更多...",
+ "viewCard": "卡片视图",
+ "viewList": "列表视图",
+ "done": "完成",
+ "upload": {
+ "title": "上传文件",
+ "dropOrClick": "拖放文件或点击浏览",
+ "dropHere": "将文件拖放到这里",
+ "maxSize": "每个文件最大 50 MB",
+ "blockedType": "不允许 {{ext}} 文件类型",
+ "tooLarge": "文件超过 50 MB 限制",
+ "failed": "上传失败",
+ "readyCount": "{{total}} 个文件中有 {{ready}} 个就绪",
+ "successCount": "{{total}} 个文件中已上传 {{success}} 个",
+ "uploading": "上传中...",
+ "uploadCount": "上传 {{count}} 个文件"
+ },
+ "errors": {
+ "serverError": "出了点问题,请稍后重试。"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/cron.json b/ui/desktop/frontend/src/i18n/locales/zh/cron.json
new file mode 100644
index 00000000..7fb61d23
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/cron.json
@@ -0,0 +1,147 @@
+{
+ "title": "定时任务",
+ "description": "安排定期Agent任务",
+ "newJob": "新建任务",
+ "emptyTitle": "暂无定时任务",
+ "emptyDescription": "创建定时任务以安排定期Agent任务。",
+ "columns": {
+ "enabled": "已启用",
+ "name": "名称",
+ "schedule": "计划",
+ "message": "消息",
+ "agent": "Agent",
+ "actions": "操作"
+ },
+ "runNow": "立即运行",
+ "running": "运行中...",
+ "runHistory": "运行历史",
+ "defaultAgent": "默认",
+ "searchPlaceholder": "搜索定时任务...",
+ "noMatchTitle": "未找到匹配任务",
+ "noMatchDescription": "请尝试其他关键词。",
+ "card": {
+ "nextRun": "下次: {{time}}",
+ "noNextRun": "无下次运行",
+ "defaultAgent": "默认代理"
+ },
+ "create": {
+ "title": "创建定时任务",
+ "name": "名称",
+ "namePlaceholder": "my-daily-task",
+ "nameHint": "仅小写字母、数字和连字符",
+ "agentId": "Agent ID(可选)",
+ "agentIdPlaceholder": "默认",
+ "scheduleType": "计划类型",
+ "every": "每隔",
+ "cron": "Cron",
+ "once": "一次",
+ "intervalSeconds": "间隔(秒)",
+ "cronExpression": "Cron 表达式",
+ "cronHint": "标准 5 字段 cron:分 时 日 月 周",
+ "onceDesc": "任务将运行一次,大约从现在起 1 分钟后。",
+ "message": "消息",
+ "messagePlaceholder": "Agent应该做什么?",
+ "cancel": "取消",
+ "create": "创建",
+ "creating": "创建中..."
+ },
+ "enable": {
+ "title": "启用定时任务",
+ "description": "启用「{{name}}」?它将开始按计划运行。",
+ "confirmLabel": "启用"
+ },
+ "disable": {
+ "title": "禁用定时任务",
+ "description": "禁用「{{name}}」?它将停止运行直到重新启用。",
+ "confirmLabel": "禁用"
+ },
+ "delete": {
+ "title": "删除定时任务",
+ "description": "删除「{{name}}」?此操作无法撤销。",
+ "confirmLabel": "删除"
+ },
+ "runLog": {
+ "title": "运行日志:{{name}}",
+ "noHistory": "暂无运行历史。",
+ "loading": "加载中..."
+ },
+ "detail": {
+ "runNow": "立即运行",
+ "running": "运行中...",
+ "disable": "禁用",
+ "enable": "启用",
+ "delete": "删除",
+ "enabled": "已启用",
+ "disabled": "已禁用",
+ "runningStatus": "运行中...",
+ "payload": "有效载荷",
+ "empty": "(空)",
+ "deliverDirect": "投递:直接",
+ "channel": "Channel:{{name}}",
+ "to": "至:{{name}}",
+ "edit": "编辑",
+ "preview": "预览",
+ "noMessage": "未配置消息",
+ "lastError": "最后错误",
+ "runHistory": "运行历史",
+ "refresh": "刷新",
+ "noHistory": "暂无运行历史。",
+ "inOut": "{{input}} 输入 / {{output}} 输出",
+ "infoRows": {
+ "schedule": "计划",
+ "timezone": "时区",
+ "nextRun": "下次运行",
+ "lastRun": "上次运行",
+ "lastStatus": "最后状态",
+ "created": "创建时间",
+ "updated": "更新时间",
+ "autoDelete": "自动删除",
+ "autoDeleteValue": "是(一次性)"
+ },
+ "tabs": {
+ "overview": "概览",
+ "history": "运行历史"
+ },
+ "advanced": "高级",
+ "saveFailed": "保存失败",
+ "schedule": "计划",
+ "scheduleDesc": "时间配置",
+ "messageSection": "消息",
+ "messageSectionDesc": "Agent应执行的内容",
+ "agentStatus": "Agent & 状态",
+ "agentStatusDesc": "Agent分配和任务状态",
+ "info": "信息",
+ "infoDesc": "任务元数据",
+ "scheduling": "调度",
+ "schedulingDesc": "时区配置",
+ "delivery": "投递",
+ "deliveryDesc": "将任务输出路由到频道或用户",
+ "deliverToChannel": "投递到频道",
+ "channelLabel": "频道",
+ "channelPlaceholder": "#general",
+ "toLabel": "至(用户)",
+ "toPlaceholder": "user-id",
+ "wakeHeartbeat": "运行时唤醒心跳",
+ "wakeHeartbeatDesc": "任务运行时触发Agent心跳",
+ "lifecycle": "生命周期",
+ "lifecycleDesc": "任务生命周期设置",
+ "deleteAfterRun": "运行后删除",
+ "deleteAfterRunDesc": "首次成功运行后自动删除此任务",
+ "timezone": "时区",
+ "timezoneDesc": "Cron表达式的时区"
+ },
+ "toast": {
+ "created": "定时任务已创建",
+ "createdDesc": "{{name}} 已添加",
+ "enabled": "定时任务已启用",
+ "disabled": "定时任务已禁用",
+ "deleted": "定时任务已删除",
+ "triggered": "定时任务已触发",
+ "failedCreate": "创建定时任务失败",
+ "failedToggle": "切换定时任务失败",
+ "failedDelete": "删除定时任务失败",
+ "failedRun": "运行定时任务失败",
+ "updated": "任务已更新",
+ "failedUpdate": "更新任务失败"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/desktop.json b/ui/desktop/frontend/src/i18n/locales/zh/desktop.json
new file mode 100644
index 00000000..40cdbc99
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/desktop.json
@@ -0,0 +1,142 @@
+{
+ "onboarding": {
+ "welcome": "GoClaw Setup",
+ "welcomeDesc": "Let's get your gateway up and running",
+ "providerStep": "Configure Provider",
+ "providerStepDesc": "Select an AI provider and enter your credentials.",
+ "modelStep": "Select & Verify Model",
+ "modelStepDesc": "Choose a model and test the connection.",
+ "agentStep": "Create Your Agent",
+ "agentStepDesc": "Give your agent a personality.",
+ "descPlaceholder": "Describe your agent's personality...",
+ "complete": "Setup Complete",
+ "stepProvider": "提供商",
+ "stepModel": "模型",
+ "stepAgent": "智能体",
+ "createProvider": "创建提供商"
+ },
+ "summoning": {
+ "title": "正在召唤智能体",
+ "weavingSoul": "正在为 {{name}} 编织灵魂",
+ "completed": "智能体已就绪!",
+ "completedTitle": "智能体已就绪!",
+ "failedTitle": "召唤失败",
+ "failed": "召唤失败",
+ "wait": "这可能需要一点时间",
+ "fileLabelSOUL": "核心个性",
+ "fileLabelIDENTITY": "身份与知识",
+ "fileLabelUSER_PREDEFINED": "用户上下文",
+ "agentReady": "{{name}} 已就绪!",
+ "retrying": "重试中..."
+ },
+ "settings": {
+ "title": "Settings",
+ "tabs": {
+ "appearance": "Appearance",
+ "providers": "Providers",
+ "agents": "Agents",
+ "mcp": "MCP",
+ "skills": "Skills",
+ "tools": "Tools",
+ "cron": "Cron",
+ "traces": "Traces",
+ "about": "About"
+ },
+ "theme": "Theme",
+ "dark": "Dark",
+ "light": "Light",
+ "language": "Language",
+ "timezone": "Timezone",
+ "timezonePlaceholder": "Timezone selection coming soon."
+ },
+ "sidebar": {
+ "newChat": "新建对话",
+ "connected": "已连接",
+ "offline": "离线",
+ "lightMode": "浅色模式",
+ "darkMode": "深色模式",
+ "settings": "设置",
+ "teams": "团队"
+ },
+ "about": {
+ "title": "GoClaw Lite",
+ "subtitle": "Desktop Edition",
+ "version": "Version",
+ "edition": "Edition",
+ "runtime": "Runtime",
+ "runtimeValue": "Wails v2",
+ "editionLimits": "Edition Limits",
+ "agents": "Agents",
+ "teams": "Teams",
+ "teamMembers": "Team members",
+ "database": "Database",
+ "users": "Users",
+ "maxAgents": "5 max",
+ "maxTeams": "1 max",
+ "maxTeamMembers": "5 max",
+ "databaseValue": "SQLite (local)",
+ "usersValue": "Single user",
+ "builtWith": "Built with Go + React + Wails"
+ },
+ "presets": {
+ "researcher": {
+ "label": "🔬 研究员",
+ "prompt": "名字:Scholar。一位一丝不苟的研究分析师,不放过任何细节。\n性格:有条不紊、严谨而精确。以结构清晰、附有引用的报告呈现信息。在深度与清晰度之间取得平衡。\n\n目的:跨领域深度研究——学术论文、市场分析、技术调查。将多个来源综合为可操作的洞察。始终区分事实与观点。"
+ },
+ "writer": {
+ "label": "✍️ 作家",
+ "prompt": "名字:Quill。一位多才多艺的作家,能根据不同受众调整语气和风格。\n性格:富有创意、表达清晰且富有同理心。深谙文字的力量。在清晰与优雅之间取得平衡。\n\n目的:内容创作专家——博客文章、文案、故事叙述、技术写作。擅长构建论点、打造叙事,并在长篇内容中保持一致的语调。"
+ },
+ "coder": {
+ "label": "👨💻 程序员",
+ "prompt": "名字:Dev。一位拥有全栈深厚专业知识的资深软件工程师。\n性格:务实高效。编写整洁、易维护的代码。善于用简单的方式解释复杂概念。遵循最佳实践,但懂得何时灵活变通。\n\n目的:代码审查、架构设计、调试、实现。精通多种语言和框架。优先考虑正确性、性能和安全性。"
+ }
+ },
+ "agent": {
+ "summon": "Summon",
+ "summoning": "Summoning...",
+ "verifyModel": "Verify Model",
+ "verifying": "Verifying...",
+ "verified": "Verified",
+ "verifyFailed": "Verification failed"
+ },
+ "chat": {
+ "emptyTitle": "与 {{name}} 对话",
+ "emptyTitleDefault": "开始对话",
+ "emptyDescAgent": "提问、写作或探索想法。",
+ "emptyDescNoAgent": "从侧边栏选择一个智能体开始。",
+ "suggestions": [
+ "今天有什么最新新闻?",
+ "讲一个笑话",
+ "AI时代的创意头脑风暴"
+ ]
+ },
+ "activity": {
+ "thinking": "思考中",
+ "executingTool": "执行工具中",
+ "compacting": "压缩上下文",
+ "streaming": "流式传输",
+ "retrying": "重试中",
+ "processing": "处理中",
+ "runningTool": "运行 {{tool}}",
+ "retryingAttempt": "重试(第 {{n}} 次)",
+ "step": "步骤 {{n}}"
+ },
+ "toast": {
+ "agentCreated": "Agent created",
+ "agentUpdated": "Agent updated",
+ "agentDeleted": "Agent deleted",
+ "providerCreated": "Provider created",
+ "providerUpdated": "Provider updated",
+ "providerDeleted": "Provider deleted",
+ "serverCreated": "MCP server created",
+ "serverUpdated": "MCP server updated",
+ "serverDeleted": "MCP server deleted",
+ "skillUploaded": "Skill uploaded",
+ "skillDeleted": "Skill deleted",
+ "grantUpdated": "Grant updated",
+ "saveFailed": "Failed to save",
+ "deleteFailed": "Failed to delete",
+ "createFailed": "Failed to create"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/mcp.json b/ui/desktop/frontend/src/i18n/locales/zh/mcp.json
new file mode 100644
index 00000000..974a906a
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/mcp.json
@@ -0,0 +1,131 @@
+{
+ "title": "MCP 服务器",
+ "description": "管理 Model Context Protocol 服务器连接",
+ "addServer": "添加服务器",
+ "searchPlaceholder": "搜索服务器...",
+ "emptyTitle": "暂无 MCP 服务器",
+ "emptyDescription": "添加您的第一个 MCP 服务器以开始使用。",
+ "noMatchTitle": "未找到匹配服务器",
+ "noMatchDescription": "请尝试其他搜索词。",
+ "columns": {
+ "name": "名称",
+ "transport": "传输协议",
+ "tools": "工具",
+ "agents": "代理",
+ "enabled": "已启用",
+ "createdBy": "创建者",
+ "actions": "操作"
+ },
+ "viewTools": "查看工具",
+ "manageGrants": "管理授权",
+ "delete": {
+ "title": "删除 MCP 服务器",
+ "description": "确定要删除「{{name}}」吗?这将同时移除所有相关授权。",
+ "confirmLabel": "删除"
+ },
+ "form": {
+ "createTitle": "添加 MCP 服务器",
+ "editTitle": "编辑 MCP 服务器",
+ "name": "名称 *",
+ "nameHint": "仅小写字母、数字和连字符",
+ "displayName": "显示名称",
+ "displayNamePlaceholder": "我的 MCP 服务器",
+ "transport": "传输协议 *",
+ "command": "命令 *",
+ "args": "参数(空格分隔)",
+ "argsPlaceholder": "--flag1 --flag2=\"带空格的值\"",
+ "url": "URL *",
+ "headers": "请求头",
+ "headerKeyPlaceholder": "请求头名称",
+ "headerValuePlaceholder": "请求头值",
+ "addHeader": "添加请求头",
+ "env": "环境变量",
+ "envKeyPlaceholder": "变量名称",
+ "envValuePlaceholder": "值",
+ "addVariable": "添加变量",
+ "toolPrefix": "工具前缀",
+ "toolPrefixHint": "如为空则自动从名称派生。",
+ "timeout": "超时(秒)",
+ "enabled": "已启用",
+ "testConnection": "测试连接",
+ "testing": "测试中...",
+ "toolsFound": "发现 {{count}} 个工具",
+ "cancel": "取消",
+ "create": "创建",
+ "update": "更新",
+ "saving": "保存中...",
+ "errors": {
+ "nameRequired": "名称和传输协议为必填项",
+ "nameSlug": "名称必须是有效的 slug(小写字母、数字、连字符)",
+ "commandRequired": "stdio 传输协议需要提供命令",
+ "urlRequired": "SSE/HTTP 传输协议需要提供 URL",
+ "connectionFailed": "连接失败"
+ },
+ "requireUserCredentials": "要求用户凭据",
+ "requireUserCredentialsHint": "每个用户必须配置自己的凭据。未设置凭据的用户将无法使用此服务器。"
+ },
+ "grants": {
+ "title": "Agent授权 - {{name}}",
+ "currentGrants": "当前授权",
+ "allToolsAllowed": "允许所有工具",
+ "addGrant": "添加Agent授权",
+ "editGrant": "编辑授权",
+ "selectAgent": "选择Agent...",
+ "toolAllowList": "工具允许列表(可选)",
+ "toolDenyList": "工具拒绝列表(可选)",
+ "allowPlaceholder": "选择要允许的工具...",
+ "denyPlaceholder": "选择要拒绝的工具...",
+ "grant": "授权",
+ "revoke": "撤销",
+ "noAgentsGranted": "暂无已授权的Agent",
+ "update": "更新",
+ "cancel": "取消",
+ "agentRequired": "Agent为必填项",
+ "failedGrant": "授权失败",
+ "failedRevoke": "撤销失败"
+ },
+ "tools": {
+ "title": "工具 — {{name}}",
+ "prefix": "前缀:",
+ "discovering": "正在发现工具...",
+ "noToolsTitle": "未发现工具",
+ "noToolsDescription": "服务器可能离线或没有注册任何工具。",
+ "failedLoad": "加载工具失败",
+ "filterPlaceholder": "过滤工具...",
+ "noMatch": "无匹配工具"
+ },
+ "toast": {
+ "created": "MCP 服务器已创建",
+ "updated": "MCP 服务器已更新",
+ "deleted": "MCP 服务器已删除",
+ "failedCreate": "创建 MCP 服务器失败",
+ "failedUpdate": "更新 MCP 服务器失败",
+ "failedDelete": "删除 MCP 服务器失败"
+ },
+ "userCredentials": {
+ "title": "我的凭证",
+ "description": "为此 MCP 服务器设置您的个人凭证,仅对您的账户生效。",
+ "titleAdmin": "用户凭证",
+ "descriptionAdmin": "为特定用户设置此 MCP 服务器的凭证。这些凭证仅覆盖所选用户的服务器默认值。",
+ "selectUser": "选择用户",
+ "apiKey": "API 密钥",
+ "apiKeyPlaceholder": "留空以保持当前值",
+ "headers": "请求头",
+ "env": "环境变量",
+ "headerPlaceholder": "KEY=VALUE,每行一个",
+ "save": "保存",
+ "delete": "删除",
+ "deleteAll": "删除全部",
+ "cancel": "取消",
+ "saved": "凭证已保存",
+ "deleted": "凭证已删除",
+ "hasApiKey": "API 密钥已设置",
+ "hasHeaders": "请求头已设置",
+ "hasEnv": "环境变量已设置",
+ "noCredentials": "未设置凭证",
+ "addHeader": "添加 Header",
+ "addEnv": "添加变量",
+ "saveFailed": "保存凭据失败",
+ "deleteFailed": "删除凭据失败"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/memory.json b/ui/desktop/frontend/src/i18n/locales/zh/memory.json
new file mode 100644
index 00000000..01ce0a6e
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/memory.json
@@ -0,0 +1,191 @@
+{
+ "title": "记忆",
+ "description": "浏览和管理Agent记忆文档、分块和嵌入",
+ "search": "搜索",
+ "create": "创建",
+ "indexAll": "全部索引",
+ "indexing": "索引中...",
+ "emptyTitle": "暂无记忆文档",
+ "emptyAgentDescription": "该Agent暂无记忆文档。",
+ "emptyGlobalDescription": "任何Agent均无记忆文档。",
+ "filters": {
+ "agent": "Agent",
+ "allAgents": "所有Agent",
+ "scope": "范围",
+ "allScope": "全部(全局 + 个人)",
+ "selectAgent": "选择Agent"
+ },
+ "tabs": {
+ "documents": "文档",
+ "knowledgeGraph": "知识图谱"
+ },
+ "columns": {
+ "path": "路径",
+ "agent": "Agent",
+ "scope": "范围",
+ "hash": "哈希值",
+ "updated": "更新时间",
+ "actions": "操作"
+ },
+ "scopeLabel": {
+ "personal": "个人",
+ "global": "全局"
+ },
+ "delete": {
+ "title": "删除记忆文档",
+ "description": "确定要删除「{{path}}」吗?这将同时删除所有关联的分块和嵌入。",
+ "confirmLabel": "删除"
+ },
+ "createDialog": {
+ "title": "创建记忆文档",
+ "path": "路径 *",
+ "pathPlaceholder": "例如 notes/project-overview.md",
+ "content": "内容 *",
+ "contentPlaceholder": "文档内容...",
+ "agentId": "Agent *",
+ "agentIdPlaceholder": "选择Agent...",
+ "scope": "范围",
+ "existingScope": "现有范围",
+ "customScope": "自定义",
+ "selectGroupUser": "选择群组/用户...",
+ "scopeHint": "全局 = 与所有用户共享。个人 = 限定于特定用户或群组对话。",
+ "autoIndex": "创建后自动索引",
+ "agentRequired": "请选择Agent",
+ "pathRequired": "路径不能为空",
+ "contentRequired": "内容不能为空",
+ "cancel": "取消",
+ "create": "创建",
+ "creating": "创建中..."
+ },
+ "documentDialog": {
+ "title": "记忆文档",
+ "path": "路径:",
+ "agent": "Agent:",
+ "scope": "范围:",
+ "hash": "哈希值:",
+ "updated": "更新时间:",
+ "content": "内容",
+ "chunks": "分块({{count}})",
+ "chunkIndex": "分块 {{index}}",
+ "noChunks": "无分块",
+ "embedded": "已嵌入",
+ "noEmbedding": "未嵌入"
+ },
+ "searchDialog": {
+ "title": "搜索记忆",
+ "query": "搜索查询",
+ "queryPlaceholder": "输入搜索查询...",
+ "agentId": "Agent ID",
+ "agentIdPlaceholder": "按Agent筛选...",
+ "limit": "限制",
+ "search": "搜索",
+ "searching": "搜索中...",
+ "results": "结果",
+ "noResults": "未找到结果",
+ "similarity": "相似度",
+ "path": "路径:"
+ },
+ "kg": {
+ "pageTitle": "Knowledge Graph",
+ "pageDescription": "探索从Agent记忆中提取的实体和关系。",
+ "selectAgentTitle": "选择Agent",
+ "selectAgentDescription": "选择Agent以查看其知识图谱。",
+ "emptyTitle": "暂无实体",
+ "emptyDescription": "该Agent暂无知识图谱实体。",
+ "emptySearchDescription": "没有与您搜索匹配的实体。",
+ "stats": {
+ "entities": "实体:{{count}}",
+ "relations": "关系:{{count}}"
+ },
+ "search": {
+ "placeholder": "搜索实体...",
+ "button": "搜索",
+ "clear": "清除"
+ },
+ "refresh": "刷新",
+ "extract": "提取",
+ "columns": {
+ "name": "名称",
+ "type": "类型",
+ "description": "描述",
+ "confidence": "置信度",
+ "actions": "操作",
+ "observations": "观察",
+ "relations": "关系"
+ },
+ "deleteEntity": {
+ "title": "删除实体",
+ "description": "删除「{{name}}」?这将同时移除所有关联关系。",
+ "confirmLabel": "删除"
+ },
+ "entity": {
+ "externalId": "外部 ID:",
+ "confidence": "置信度:",
+ "description": "描述:",
+ "source": "来源:",
+ "properties": "属性:",
+ "relations": "关系",
+ "traverse": "遍历(2 跳)",
+ "traversing": "遍历中...",
+ "loading": "加载中...",
+ "noRelations": "未找到关系。",
+ "traversalResults": "遍历结果({{count}})",
+ "direction": {
+ "outgoing": "→ 出向",
+ "incoming": "← 入向"
+ },
+ "columns": {
+ "direction": "方向",
+ "relation": "关系",
+ "target": "目标",
+ "confidence": "置信度"
+ }
+ },
+ "extractDialog": {
+ "title": "从文本中提取实体",
+ "providerLabel": "提取Provider",
+ "modelLabel": "提取模型",
+ "textLabel": "待提取文本",
+ "textPlaceholder": "粘贴对话文本、笔记或任何内容以提取实体和关系...",
+ "extract": "提取",
+ "extracting": "提取中...",
+ "cancel": "取消",
+ "agentId": "Agent ID",
+ "agentIdPlaceholder": "留空为全局"
+ },
+ "graphView": {
+ "loading": "正在加载图谱...",
+ "empty": "没有可视化的实体",
+ "nodes": "{{count}} 个节点",
+ "edges": "{{count}} 条边"
+ }
+ },
+ "toast": {
+ "created": "记忆文档已创建",
+ "deleted": "记忆文档已删除",
+ "indexed": "所有文档已索引",
+ "failedCreate": "创建文档失败",
+ "failedDelete": "删除文档失败",
+ "failedIndex": "索引文档失败",
+ "entitySaved": "实体已保存",
+ "entitySaveFailed": "保存实体失败",
+ "entityDeleted": "实体已删除",
+ "entityDeleteFailed": "删除实体失败",
+ "extractionComplete": "提取完成",
+ "extractionFailed": "提取失败",
+ "traversalFailed": "遍历失败",
+ "unknownError": "未知错误",
+ "docCreated": "文档已创建",
+ "docCreateFailed": "创建文档失败",
+ "docUpdated": "文档已更新",
+ "docUpdateFailed": "更新文档失败",
+ "docDeleted": "文档已删除",
+ "docDeleteFailed": "删除文档失败",
+ "docIndexed": "文档已索引",
+ "docIndexFailed": "索引文档失败",
+ "allIndexed": "所有文档已索引",
+ "allIndexFailed": "索引所有文档失败",
+ "searchFailed": "搜索失败",
+ "docLoadFailed": "加载文档失败"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/providers.json b/ui/desktop/frontend/src/i18n/locales/zh/providers.json
new file mode 100644
index 00000000..f04cf097
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/providers.json
@@ -0,0 +1,179 @@
+{
+ "title": "Provider",
+ "description": "管理 LLM Provider",
+ "addProvider": "添加Provider",
+ "searchPlaceholder": "搜索Provider...",
+ "emptyTitle": "暂无Provider",
+ "emptyDescription": "添加您的第一个 LLM Provider以开始使用。",
+ "noMatchTitle": "未找到匹配Provider",
+ "noMatchDescription": "请尝试其他搜索词。",
+ "columns": {
+ "name": "名称",
+ "type": "类型",
+ "apiBase": "API 基础地址",
+ "apiKey": "API 密钥",
+ "status": "状态",
+ "actions": "操作"
+ },
+ "apiKey": {
+ "oauthToken": "OAuth 令牌",
+ "notSet": "未设置"
+ },
+ "card": {
+ "apiKeySet": "已设置API密钥",
+ "authenticated": "已认证",
+ "oauthLinked": "已链接OAuth"
+ },
+ "detail": {
+ "title": "提供商详情",
+ "advanced": "高级"
+ },
+ "form": {
+ "createTitle": "添加Provider",
+ "editTitle": "编辑Provider",
+ "name": "名称 *",
+ "namePlaceholder": "例如 openrouter",
+ "nameHint": "小写字母、数字、连字符",
+ "displayName": "显示名称",
+ "displayNamePlaceholder": "OpenRouter",
+ "providerType": "Provider类型 *",
+ "apiKey": "API 密钥",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeyEditPlaceholder": "保持不变或输入新密钥",
+ "apiKeySetHint": "API 密钥已设置。清除并输入新值以更改。",
+ "apiBase": "API 基础 URL",
+ "enabled": "已启用",
+ "cancel": "取消",
+ "close": "关闭",
+ "create": "创建",
+ "save": "保存",
+ "update": "更新",
+ "saving": "保存中...",
+ "creating": "创建中...",
+ "alreadyAdded": "(已添加)",
+ "nameFixed": "名称",
+ "configure": "配置 LLM Provider连接。",
+ "lowercaseHint": "小写字母、数字、连字符"
+ },
+ "delete": {
+ "title": "删除Provider",
+ "description": "确定要删除「{{name}}」吗?此操作无法撤销。",
+ "confirmLabel": "删除"
+ },
+ "types": {
+ "anthropic_native": "Anthropic",
+ "openai_compat": "OpenAI Compat",
+ "gemini_native": "Gemini",
+ "openrouter": "OpenRouter",
+ "groq": "Groq",
+ "deepseek": "DeepSeek",
+ "mistral": "Mistral",
+ "xai": "xAI",
+ "minimax_native": "MiniMax",
+ "cohere": "Cohere",
+ "perplexity": "Perplexity",
+ "chatgpt_oauth": "ChatGPT (OAuth)",
+ "yescale": "YesScale",
+ "acp": "ACP"
+ },
+ "acp": {
+ "description": "将兼容 ACP 的编程代理作为子进程进行编排。无需 API 密钥。",
+ "binary": "二进制文件",
+ "binaryPlaceholder": "claude",
+ "binaryHint": "ACP 代理二进制文件的路径或名称(例如 claude、codex、gemini)",
+ "args": "参数",
+ "argsPlaceholder": "--profile goclaw",
+ "argsHint": "传递给二进制文件的以空格分隔的参数",
+ "idleTTL": "空闲超时",
+ "idleTTLPlaceholder": "5m",
+ "idleTTLHint": "在此时间后终止空闲进程(例如 5m、1h)",
+ "permMode": "权限模式",
+ "permModeApproveAll": "全部批准",
+ "permModeApproveReads": "仅批准读取",
+ "permModeDenyAll": "全部拒绝",
+ "workDir": "工作目录",
+ "workDirPlaceholder": "/tmp/workspace",
+ "workDirHint": "代理子进程的工作目录(可选)"
+ },
+ "oauth": {
+ "checkingStatus": "正在检查认证状态...",
+ "authSuccessful": "认证成功!",
+ "activeProvider": "Provider",
+ "closingIn": "现已激活。{{count}} 秒后关闭...",
+ "authenticated": "已认证 — Provider",
+ "active": "活跃",
+ "modelPrefixHint": "在Agent配置中使用模型前缀",
+ "modelPrefixExample": "(例如 openai-codex/gpt-4o)。令牌自动刷新。",
+ "removeToken": "移除令牌",
+ "signInDesc": "使用您的 ChatGPT 账户登录,无需单独的 API 密钥即可使用订阅中的模型。",
+ "waiting": "等待认证中... 请在已打开的窗口中完成登录。",
+ "remoteVpsHint": "登录后,您的浏览器将尝试打开",
+ "remoteVpsError": "并显示「无法访问此页面」错误。这是正常的 — 从浏览器地址栏复制完整 URL 并粘贴到下方。",
+ "pasteUrlPlaceholder": "http://localhost:1455/auth/callback?code=...&state=...",
+ "submit": "提交",
+ "signInWithChatGPT": "使用 ChatGPT 登录",
+ "starting": "启动中...",
+ "remoteVps": "远程/VPS?",
+ "loggedOut": "已登出",
+ "loggedOutDesc": "OpenAI OAuth 令牌已移除",
+ "oauthFailed": "OAuth 失败",
+ "exchangeFailed": "交换失败",
+ "logoutFailed": "登出失败"
+ },
+ "cli": {
+ "description": "Claude CLI 使用本地",
+ "descriptionSuffix": "二进制文件。无需 API 密钥。",
+ "checkingAuth": "正在检查认证...",
+ "authenticatedAs": "已认证为",
+ "switchAccount": "切换账户?",
+ "switchAccountInstructions": "在服务器终端运行:",
+ "switchAccountRecheck": "然后点击",
+ "switchAccountRecheckSuffix": "重新检查。",
+ "notAuthenticated": "未认证",
+ "runOnServer": "在服务器终端运行:",
+ "recheckButton": "重新检查"
+ },
+ "detail": {
+ "advanced": "高级设置",
+ "identity": "身份",
+ "identityDesc": "Provider标识",
+ "connection": "连接",
+ "connectionDesc": "API端点配置",
+ "acpConfig": "ACP配置",
+ "acpConfigDesc": "Agent子进程设置",
+ "cliConfig": "Claude CLI",
+ "cliConfigDesc": "本地CLI认证",
+ "oauthConfig": "OAuth",
+ "oauthConfigDesc": "ChatGPT OAuth认证",
+ "nameReadonly": "Provider标识符,无法更改",
+ "providerType": "Provider类型",
+ "apiKeySection": "API密钥",
+ "apiKeySectionDesc": "认证凭证",
+ "statusSection": "状态",
+ "statusSectionDesc": "Provider可用性",
+ "enabledDesc": "Provider已激活并可供Agent使用",
+ "embeddingSection": "Embedding"
+ },
+ "embedding": {
+ "enable": "启用Embedding",
+ "enableDesc": "允许此Provider为记忆、技能和知识图谱生成文本嵌入",
+ "model": "Embedding模型",
+ "dimensions": "输出维度",
+ "dimensionsHint": "将输出截断为N维(如1536)。对于原生维度 > 1536的模型(如gemini-embedding-001 3072d)必须设置。",
+ "apiBase": "Embedding API地址",
+ "apiBasePlaceholder": "(与Provider相同)",
+ "apiBaseHint": "如果Embedding端点与Provider的主API地址不同,可在此覆盖",
+ "verify": "验证Embedding",
+ "verifyFailed": "Embedding验证失败",
+ "dimensionsMismatch": "{{count}}维 — 不符合要求的1536"
+ },
+ "toast": {
+ "created": "Provider已创建",
+ "createdDesc": "{{name}} 已添加",
+ "updated": "Provider已更新",
+ "deleted": "Provider已删除",
+ "failedCreate": "创建Provider失败",
+ "failedUpdate": "更新Provider失败",
+ "failedDelete": "删除Provider失败"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/sessions.json b/ui/desktop/frontend/src/i18n/locales/zh/sessions.json
new file mode 100644
index 00000000..6ea76314
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/sessions.json
@@ -0,0 +1,45 @@
+{
+ "title": "Session",
+ "description": "浏览对话Session",
+ "searchPlaceholder": "搜索Session...",
+ "emptyTitle": "暂无Session",
+ "emptyDescription": "开始聊天后Session将显示在此处。",
+ "noMatchTitle": "未找到匹配的Session",
+ "noMatchDescription": "请尝试其他搜索词。",
+ "columns": {
+ "session": "Session",
+ "agent": "Agent",
+ "context": "上下文",
+ "messages": "消息",
+ "updated": "更新时间"
+ },
+ "contextBar": {
+ "compacted": "已压缩 {{count}} 次"
+ },
+ "detail": {
+ "reset": "重置",
+ "delete": "删除",
+ "deleteTitle": "删除Session",
+ "deleteDescription": "这将永久删除此Session中的所有消息。",
+ "resetTitle": "重置Session",
+ "resetDescription": "这将清除所有消息但保留Session。",
+ "confirmDelete": "删除",
+ "confirmReset": "重置",
+ "noMessages": "此Session中没有消息",
+ "summary": "摘要",
+ "showMore": "显示更多",
+ "showLess": "收起",
+ "showDetails": "显示详情",
+ "hide": "隐藏",
+ "systemMessage": "系统消息",
+ "messages": "条消息"
+ },
+ "toast": {
+ "deleted": "Session已删除",
+ "deleteFailed": "删除Session失败",
+ "reset": "Session已重置",
+ "resetFailed": "重置Session失败",
+ "updated": "Session已更新",
+ "updateFailed": "更新Session失败"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/skills.json b/ui/desktop/frontend/src/i18n/locales/zh/skills.json
new file mode 100644
index 00000000..55e7ec92
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/skills.json
@@ -0,0 +1,122 @@
+{
+ "title": "Skill",
+ "description": "管理AgentSkill和能力",
+ "searchPlaceholder": "搜索Skill...",
+ "emptyTitle": "暂无Skill",
+ "emptyDescription": "尚未注册任何Skill。",
+ "noMatchTitle": "未找到匹配的Skill",
+ "noMatchDescription": "请尝试其他搜索词。",
+ "columns": {
+ "name": "名称",
+ "description": "描述",
+ "source": "来源",
+ "author": "作者",
+ "visibility": "可见性",
+ "status": "状态",
+ "actions": "操作"
+ },
+ "noDescription": "暂无描述",
+ "visibility": {
+ "clickToCycle": "点击更改可见性"
+ },
+ "delete": {
+ "title": "删除Skill",
+ "description": "确定要删除「{{name}}」吗?此操作无法撤销。",
+ "confirmLabel": "删除"
+ },
+ "upload": {
+ "title": "上传Skill",
+ "description": "上传包含带 YAML frontmatter (name, description, slug) 的 SKILL.md 的 ZIP 文件。",
+ "button": "上传",
+ "uploading": "上传中...",
+ "dropOrClick": "点击或拖放 .zip 文件",
+ "dropHere": "将 .zip 文件拖放到此处",
+ "onlyZip": "只接受 .zip 文件",
+ "cancel": "取消",
+ "done": "完成",
+ "tooLarge": "文件超过 20MB 限制",
+ "invalidZip": "无效的 ZIP 文件",
+ "noSkillMd": "ZIP 必须包含根目录的 SKILL.md",
+ "emptySkillMd": "SKILL.md 为空",
+ "noFrontmatter": "SKILL.md 缺少 YAML frontmatter",
+ "nameRequired": "SKILL.md frontmatter 中需要 name",
+ "invalidSlug": "slug 格式无效",
+ "failed": "上传失败",
+ "validating": "验证中...",
+ "validCount": "{{valid}} / {{total}} 有效",
+ "uploadCount": "上传 ({{count}})",
+ "successCount": "{{success}} / {{total}} 上传成功",
+ "remove": "移除文件"
+ },
+ "edit": {
+ "title": "编辑Skill",
+ "name": "名称",
+ "description": "描述",
+ "visibility": "可见性",
+ "tags": "标签",
+ "addTag": "添加标签...",
+ "add": "添加",
+ "cancel": "取消",
+ "save": "保存",
+ "saving": "保存中...",
+ "privateOption": "私有(仅所有者)",
+ "internalOption": "内部(已授权的Agent/用户)",
+ "publicOption": "公开(所有Agent)"
+ },
+ "system": "系统",
+ "tabs": {
+ "core": "核心",
+ "custom": "自定义"
+ },
+ "toggle": {
+ "enable": "启用Skill",
+ "disable": "禁用Skill",
+ "disabled": "已禁用"
+ },
+ "tenant": {
+ "enabled": "已启用",
+ "disabled": "已禁用",
+ "default": "默认",
+ "overrideHint": "此租户的技能覆盖设置",
+ "resetDefault": "恢复默认"
+ },
+ "deps": {
+ "rescan": "Rescan Deps",
+ "rescanning": "Scanning...",
+ "rescanSuccess": "Updated {{count}} skills",
+ "missing": "Missing: {{deps}}",
+ "install": "Install Dependencies",
+ "installing": "Installing...",
+ "installSuccess": "Dependencies installed",
+ "installPartial": "Some dependencies failed",
+ "installItem": "Install",
+ "itemSuccess": "Installed",
+ "itemError": "Failed",
+ "missingTitle": "Missing Dependencies",
+ "systemLabel": "System",
+ "pythonLabel": "Python",
+ "nodeLabel": "Node.js",
+ "statusActive": "Active",
+ "statusArchived": "Missing deps",
+ "runtimeMissing": "Runtime Prerequisites Missing",
+ "runtimeMissingDesc": "Core skills require Python and/or Node.js runtimes. Rebuild with ENABLE_PYTHON=true, ENABLE_NODE=true, or ENABLE_FULL_SKILLS=true, or install manually.",
+ "runtimeRequired": "Install runtimes first"
+ },
+ "detail": {
+ "title": "Skill详情",
+ "content": "内容",
+ "files": "文件",
+ "version": "版本:",
+ "current": "(当前)",
+ "noContent": "无可用内容。"
+ },
+ "toast": {
+ "updated": "技能已更新",
+ "updateFailed": "更新技能失败",
+ "deleted": "技能已删除",
+ "deleteFailed": "删除技能失败",
+ "rescanUpdated": "已更新 {{count}} 个技能",
+ "rescanNoChanges": "所有技能已是最新",
+ "rescanFailed": "重新扫描依赖失败"
+ }
+}
\ No newline at end of file
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/storage.json b/ui/desktop/frontend/src/i18n/locales/zh/storage.json
new file mode 100644
index 00000000..c8acd622
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/storage.json
@@ -0,0 +1,25 @@
+{
+ "title": "存储",
+ "description": "工作区文件浏览器",
+ "descriptionWithPath": "{{path}} — {{size}}",
+ "sizeCacheInfo": "存储大小缓存60分钟",
+ "unsupportedFile": "此文件类型不支持预览",
+ "download": "下载",
+ "delete": {
+ "fileTitle": "删除文件",
+ "folderTitle": "删除文件夹",
+ "description": "确定要删除 {{name}} 吗?",
+ "folderWarning": " 所有内容将被递归删除。",
+ "undone": " 此操作不可撤销。",
+ "deleting": "删除中...",
+ "confirmLabel": "删除"
+ },
+ "upload": {
+ "title": "上传到存储",
+ "description": "上传文件到数据目录"
+ },
+ "toast": {
+ "deleted": "文件已删除",
+ "deleteFailed": "删除文件失败"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/teams.json b/ui/desktop/frontend/src/i18n/locales/zh/teams.json
new file mode 100644
index 00000000..442aec62
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/teams.json
@@ -0,0 +1,83 @@
+{
+ "title": "团队",
+ "team": "团队",
+ "selectTeam": "从侧边栏选择一个团队",
+ "kanban": "看板",
+ "list": "列表",
+ "allStatuses": "全部",
+ "active": "进行中",
+ "completed": "已完成",
+ "createTask": "创建任务",
+ "noTasks": "暂无任务",
+ "id": "ID",
+ "subject": "标题",
+ "status": "状态",
+ "owner": "负责人",
+ "priority": "优先级",
+ "created": "创建时间",
+ "updated": "更新时间",
+ "description": "描述",
+ "result": "结果",
+ "progress": "进度",
+ "blockedBy": "被阻塞于",
+ "assignTo": "分配给",
+ "selectAgent": "选择代理...",
+ "delete": "删除",
+ "deleting": "删除中...",
+ "deleteSelected": "删除已选",
+ "cancel": "取消",
+ "create": "创建",
+ "creating": "创建中...",
+ "subjectPlaceholder": "任务标题...",
+ "descPlaceholder": "任务详情...",
+ "teamTasks": "团队",
+ "tasksActive": "进行中",
+ "createTeam": "创建团队",
+ "teamName": "团队名称",
+ "teamNamePlaceholder": "我的团队...",
+ "leadAgent": "团队负责人",
+ "selectLead": "选择负责人...",
+ "members": "成员",
+ "teamCreated": "团队已创建",
+ "createFailed": "创建团队失败",
+ "limitReached": "已达团队上限",
+ "noTeams": "暂无团队",
+ "selected": "已选",
+ "needMembers": "请至少选择1名团队成员",
+ "editionCompare": "GoClaw Lite vs Standard",
+ "editionUpgrade": "升级到 Standard 版(PostgreSQL)以获取完整功能",
+ "feature": "功能",
+ "edition": {
+ "group": {
+ "limits": "限制",
+ "core": "核心功能",
+ "standardOnly": "高级 (Standard)"
+ },
+ "agents": "代理",
+ "teams": "团队",
+ "teamMembers": "团队成员",
+ "sessions": "并发会话",
+ "chat": "聊天(流式、工具、媒体)",
+ "tools": "内置工具",
+ "mcpServers": "MCP 服务器",
+ "skills": "技能",
+ "memory": "记忆",
+ "cron": "定时任务",
+ "heartbeat": "代理心跳",
+ "storage": "文件存储",
+ "traces": "追踪",
+ "taskActions": "团队任务操作",
+ "channels": "频道(Telegram、Discord…)",
+ "skillManage": "技能自管理",
+ "knowledgeGraph": "知识图谱",
+ "vectorSearch": "向量搜索",
+ "rbac": "角色权限",
+ "multiTenant": "多租户",
+ "agentLinks": "代理链接",
+ "activityLogs": "活动日志",
+ "apiKeys": "API 密钥",
+ "poolProvider": "Pool Provider",
+ "secureCli": "安全 CLI",
+ "tenantUsers": "租户与用户管理"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/tools.json b/ui/desktop/frontend/src/i18n/locales/zh/tools.json
new file mode 100644
index 00000000..384d0684
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/tools.json
@@ -0,0 +1,211 @@
+{
+ "builtin": {
+ "categories": {
+ "browser": "浏览器",
+ "delegation": "委托",
+ "filesystem": "文件系统",
+ "media": "媒体",
+ "memory": "内存",
+ "messaging": "消息",
+ "runtime": "运行时",
+ "scheduling": "调度",
+ "sessions": "Session",
+ "skills": "Skill",
+ "subagents": "Agent",
+ "teams": "Team",
+ "web": "网页"
+ },
+ "categoryCount": "{{count}} 个分类",
+ "configuredVia": "通过配置页面配置",
+ "tenantOverride": "租户覆盖",
+ "tenantEnabled": "已为此租户启用",
+ "tenantDisabled": "已为此租户禁用",
+ "tenantDefault": "使用默认",
+ "resetToDefault": "恢复默认",
+ "unconfiguredWarning": "{{count}} 个工具需要配置 Provider 才能让 Agent 发挥全部能力。",
+ "unconfiguredAction": "配置",
+ "description": "管理系统内置工具。全局启用/禁用或配置设置。",
+ "emptyDescription": "尚未初始化任何内置工具。",
+ "emptyTitle": "暂无内置工具",
+ "jsonDialog": {
+ "cancel": "取消",
+ "cannotFormat": "无法格式化:JSON 无效",
+ "description": "以 JSON 格式编辑工具专属设置。保存后立即生效。",
+ "formatJson": "格式化 JSON",
+ "invalidJson": "无效的 JSON",
+ "invalidJsonSyntax": "JSON 语法无效",
+ "save": "保存",
+ "saving": "保存中...",
+ "title": "设置:{{name}}"
+ },
+ "kgSettings": {
+ "autoExtract": "写入内存时自动提取",
+ "autoExtractHint": "当Agent写入内存文件时自动提取实体。",
+ "cancel": "取消",
+ "description": "配置从内存写入中提取实体。需要支持结构化 JSON 输出的Provider/模型。",
+ "extractionModel": "提取模型",
+ "extractionProvider": "提取Provider",
+ "minConfidence": "最低置信度",
+ "minConfidenceHint": "低于此置信度分数的实体将被丢弃(0.0–1.0)。",
+ "modelTip": "用于提取的模型 ID。应支持结构化 JSON 输出。",
+ "providerTip": "用于从文本中提取实体和关系的 LLM Provider。",
+ "save": "保存",
+ "saving": "保存中...",
+ "title": "知识图谱设置"
+ },
+ "extractorChain": {
+ "title": "Web Fetch — 提取器链",
+ "description": "有序内容提取器列表。每个 URL 从上到下依次尝试,直到某个提取器返回高质量内容。",
+ "timeout": "超时",
+ "baseUrl": "基础 URL",
+ "save": "保存",
+ "saving": "保存中…",
+ "cancel": "取消",
+ "extractors": {
+ "defuddle": "Defuddle(Cloudflare Worker)",
+ "defuddleDesc": "通过 fetch.goclaw.sh 使用 Defuddle 库提取干净的 Markdown。",
+ "html-to-markdown": "内置 HTML→Markdown",
+ "html-to-markdownDesc": "进程内基于 DOM 的转换器,具备隐藏元素检测功能。"
+ }
+ },
+ "mediaChain": {
+ "addProvider": "添加Provider",
+ "cancel": "取消",
+ "description": "配置并排序Provider回退。拖动以重新排序 — 第一个启用的Provider将被优先尝试。",
+ "loadingModels": "加载中...",
+ "model": "模型",
+ "newProvider": "新Provider",
+ "noProviders": "未配置Provider。请在下方添加。",
+ "provider": "Provider",
+ "providerChainSuffix": "— Provider链",
+ "retries": "重试次数",
+ "save": "保存",
+ "saving": "保存中...",
+ "selectModel": "选择模型",
+ "selectProvider": "选择Provider",
+ "settings": "设置",
+ "timeout": "超时"
+ },
+ "noMatchDescription": "请尝试其他搜索词。",
+ "noMatchTitle": "未找到匹配工具",
+ "requires": "需要",
+ "requiresTooltip": "需要:{{list}}",
+ "deprecated": "已弃用",
+ "deprecatedTooltip": "此工具已弃用,不再可用",
+ "searchPlaceholder": "搜索工具...",
+ "settings": "设置",
+ "settingsDialog": {
+ "cancel": "取消",
+ "noFields": "此工具没有可配置的设置。",
+ "save": "保存",
+ "saving": "保存中...",
+ "title": "设置 — {{name}}",
+ "toast": {
+ "failed": "保存设置失败",
+ "saved": "设置已保存"
+ }
+ },
+ "title": "内置工具",
+ "toast": {
+ "failedToggle": "更新工具失败",
+ "toggled": "工具已更新"
+ },
+ "toolCount": "{{count}} 个工具",
+ "toolCountPlural": "{{count}} 个工具",
+ "descriptions": {
+ "read_file": "按路径读取Agent工作区中的文件内容",
+ "write_file": "将内容写入工作区中的文件,自动创建所需目录",
+ "list_files": "列出工作区中指定路径下的文件和目录",
+ "edit": "对现有文件应用搜索替换编辑,无需重写整个文件",
+ "exec": "在工作区中执行Shell命令并返回stdout/stderr",
+ "web_search": "使用搜索引擎(Brave或DuckDuckGo)在网上搜索信息",
+ "web_fetch": "获取网页或API端点并提取文本内容",
+ "memory_search": "使用语义相似度搜索Agent的长期记忆",
+ "memory_get": "按文件路径检索特定记忆文档",
+ "knowledge_graph_search": "搜索Agent知识图谱中的实体、关系和观察",
+ "read_image": "使用支持视觉的LLM Provider分析图像",
+ "read_document": "使用支持文档的LLM Provider分析文档(PDF、Word、Excel、PowerPoint、CSV等)",
+ "create_image": "使用图像生成Provider从文本提示生成图像",
+ "read_audio": "使用支持音频的LLM Provider分析音频文件(语音、音乐、声音)",
+ "read_video": "使用支持视频的LLM Provider分析视频文件",
+ "create_video": "使用AI从文本描述生成视频",
+ "create_audio": "使用AI从文本描述生成音乐或音效",
+ "tts": "将文本转换为自然发声的语音音频",
+ "browser": "自动化浏览器交互:导航页面、点击元素、填写表单、截屏",
+ "sessions_list": "列出所有渠道上的活跃聊天Session",
+ "session_status": "获取特定聊天Session的当前状态和元数据",
+ "sessions_history": "检索特定聊天Session的消息历史",
+ "sessions_send": "代表Agent向活跃聊天Session发送消息",
+ "message": "向已连接渠道(Telegram、Discord等)上的用户发送主动消息",
+ "cron": "使用cron表达式、定时或间隔调度或管理定期任务",
+ "spawn": "生成子Agent进行后台工作",
+ "skill_search": "按关键字或描述搜索可用Skill以找到相关能力",
+ "use_skill": "激活Skill以使用其专业能力(追踪标记)",
+ "team_tasks": "查看、创建、更新和完成团队任务板上的任务"
+ }
+ },
+ "custom": {
+ "columns": {
+ "actions": "操作",
+ "description": "描述",
+ "enabled": "已启用",
+ "name": "名称",
+ "scope": "范围",
+ "timeout": "超时"
+ },
+ "createTool": "创建工具",
+ "delete": {
+ "confirmLabel": "删除",
+ "description": "确定要删除「{{name}}」吗?此操作无法撤销。",
+ "title": "删除自定义工具"
+ },
+ "description": "管理Agent的自定义 Shell 工具",
+ "emptyDescription": "创建您的第一个自定义工具以开始使用。",
+ "emptyTitle": "暂无自定义工具",
+ "form": {
+ "agentId": "Agent ID(可选)",
+ "agentIdPlaceholder": "留空为全局范围",
+ "cancel": "取消",
+ "command": "命令 *",
+ "commandHint": "Shell 模板。使用 {{.key}} 作为参数占位符。",
+ "create": "创建",
+ "createTitle": "创建自定义工具",
+ "description": "描述",
+ "descriptionPlaceholder": "此工具的功能...",
+ "editTitle": "编辑工具",
+ "enabled": "已启用",
+ "errors": {
+ "invalidJson": "参数必须是有效的 JSON",
+ "nameRequired": "名称和命令为必填项",
+ "nameSlug": "名称必须是有效的 slug(小写字母、数字、连字符)"
+ },
+ "name": "名称 *",
+ "nameHint": "仅小写字母、数字和连字符",
+ "namePlaceholder": "my-tool",
+ "parameters": "参数(JSON Schema)",
+ "saving": "保存中...",
+ "timeout": "超时(秒)",
+ "update": "更新",
+ "workingDir": "工作目录",
+ "workingDirPlaceholder": "/path/to/dir"
+ },
+ "noDescription": "无描述",
+ "noMatchDescription": "请尝试其他搜索词。",
+ "noMatchTitle": "未找到匹配工具",
+ "scope": {
+ "agent": "Agent",
+ "global": "全局"
+ },
+ "searchPlaceholder": "搜索工具...",
+ "title": "自定义工具",
+ "toast": {
+ "created": "工具已创建",
+ "createdDesc": "{{name}} 已添加",
+ "deleted": "工具已删除",
+ "failedCreate": "创建工具失败",
+ "failedDelete": "删除工具失败",
+ "failedUpdate": "更新工具失败",
+ "updated": "工具已更新"
+ }
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/traces.json b/ui/desktop/frontend/src/i18n/locales/zh/traces.json
new file mode 100644
index 00000000..6e3dcffb
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/traces.json
@@ -0,0 +1,62 @@
+{
+ "cached": "已缓存",
+ "columns": {
+ "duration": "耗时",
+ "name": "名称",
+ "spans": "跨度",
+ "status": "状态",
+ "time": "时间",
+ "tokens": "令牌"
+ },
+ "description": "LLM 调用追踪和性能数据",
+ "detail": {
+ "channel": "Channel:",
+ "copy": "复制",
+ "copyTraceId": "复制追踪 ID",
+ "createdAt": "创建时间:",
+ "delegatedFrom": "委托来自:",
+ "duration": "耗时:",
+ "input": "输入",
+ "llmCalls": "LLM 调用",
+ "name": "名称:",
+ "notFound": "未找到追踪。",
+ "output": "输出",
+ "spans": "跨度:",
+ "spansCount": "跨度({{count}})",
+ "started": "开始时间:",
+ "status": "状态:",
+ "title": "追踪详情",
+ "tokens": "令牌:",
+ "toolCalls": "工具",
+ "export": "导出追踪",
+ "stopRun": "停止运行"
+ },
+ "emptyDescription": "暂无追踪记录。当Agent处理请求时将自动记录追踪。",
+ "emptyTitle": "暂无追踪",
+ "allAgents": "所有Agent",
+ "allChannels": "所有渠道",
+ "filter": "筛选",
+ "filterPlaceholder": "按Agent ID 筛选...",
+ "span": {
+ "cacheRead": "读取",
+ "cacheWrite": "写入",
+ "cached": "已缓存",
+ "copy": "复制",
+ "startTime": "开始:",
+ "endTime": "结束:",
+ "input": "输入:",
+ "model": "模型:",
+ "output": "输出:",
+ "thinking": "思考",
+ "tokens": "令牌:"
+ },
+ "title": "追踪",
+ "toast": {
+ "failedLoad": "加载追踪失败",
+ "abortSent": "已停止运行",
+ "abortFailed": "停止运行失败",
+ "abortNotFound": "运行已完成或未找到"
+ },
+ "unnamed": "未命名",
+ "stopRun": "停止运行"
+}
diff --git a/ui/desktop/frontend/src/i18n/vi.json b/ui/desktop/frontend/src/i18n/vi.json
new file mode 100644
index 00000000..6c13d397
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/vi.json
@@ -0,0 +1,27 @@
+{
+ "app": {
+ "name": "GoClaw",
+ "tagline": "Cổng AI Agent"
+ },
+ "sidebar": {
+ "agents": "Agent",
+ "teams": "Nhóm",
+ "newChat": "Cuộc trò chuyện mới",
+ "settings": "Cài đặt"
+ },
+ "chat": {
+ "placeholder": "Nhập tin nhắn...",
+ "send": "Gửi",
+ "stop": "Dừng",
+ "thinking": "Đang suy nghĩ...",
+ "toolCalling": "Đang gọi {tool}...",
+ "toolCompleted": "Hoàn thành",
+ "toolError": "Lỗi"
+ },
+ "agent": {
+ "online": "Trực tuyến",
+ "busy": "Đang bận",
+ "offline": "Ngoại tuyến",
+ "idle": "Không hoạt động"
+ }
+}
diff --git a/ui/desktop/frontend/src/index.css b/ui/desktop/frontend/src/index.css
new file mode 100644
index 00000000..b3f9e790
--- /dev/null
+++ b/ui/desktop/frontend/src/index.css
@@ -0,0 +1,169 @@
+@import "tailwindcss";
+
+@theme {
+ /* GoClaw Brand — OKLCH values from web dashboard */
+
+ /* Dark mode defaults (primary build) */
+ --color-surface-primary: oklch(0.15 0.02 45);
+ --color-surface-secondary: oklch(0.20 0.02 45);
+ --color-surface-tertiary: oklch(0.25 0.02 45);
+ --color-text-primary: oklch(0.94 0.01 80);
+ --color-text-secondary: oklch(0.78 0.015 50);
+ --color-text-muted: oklch(0.65 0.015 50);
+ --color-accent: oklch(0.68 0.18 38);
+ --color-accent-hover: oklch(0.72 0.18 38);
+ --color-border: oklch(0.30 0.02 45);
+ --color-user-bubble: oklch(0.50 0.17 38);
+ --color-agent-bubble: oklch(0.20 0.02 45);
+ --color-success: oklch(0.45 0.15 145);
+ --color-warning: oklch(0.65 0.15 75);
+ --color-error: oklch(0.65 0.23 25);
+ --color-idle: oklch(0.52 0.02 50);
+
+ /* Typography */
+ --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
+ --font-mono: 'JetBrains Mono', ui-monospace, monospace;
+
+ /* Radii — matches web (0.75rem) */
+ --radius-card: 12px;
+ --radius-button: 8px;
+ --radius-input: 6px;
+}
+
+/* Light mode */
+:root:not(.dark) {
+ --color-surface-primary: oklch(0.97 0.005 80);
+ --color-surface-secondary: oklch(1 0 0);
+ --color-surface-tertiary: oklch(0.94 0.01 80);
+ --color-text-primary: oklch(0.17 0.02 55);
+ --color-text-secondary: oklch(0.35 0.02 50);
+ --color-text-muted: oklch(0.45 0.02 50);
+ --color-accent: oklch(0.62 0.19 38);
+ --color-accent-hover: oklch(0.58 0.19 38);
+ --color-border: oklch(0.90 0.01 70);
+ --color-user-bubble: oklch(0.40 0.14 38);
+ --color-agent-bubble: oklch(0.94 0.01 80);
+ --color-success: oklch(0.55 0.2 145);
+ --color-warning: oklch(0.77 0.16 75);
+ --color-error: oklch(0.577 0.245 27.325);
+}
+
+.dark {
+ color-scheme: dark;
+ --color-surface-primary: oklch(0.15 0.02 45);
+ --color-surface-secondary: oklch(0.20 0.02 45);
+ --color-surface-tertiary: oklch(0.25 0.02 45);
+ --color-text-primary: oklch(0.94 0.01 80);
+ --color-text-secondary: oklch(0.78 0.015 50);
+ --color-text-muted: oklch(0.65 0.015 50);
+ --color-accent: oklch(0.68 0.18 38);
+ --color-accent-hover: oklch(0.72 0.18 38);
+ --color-border: oklch(0.30 0.02 45);
+ --color-user-bubble: oklch(0.50 0.17 38);
+ --color-agent-bubble: oklch(0.20 0.02 45);
+ --color-success: oklch(0.45 0.15 145);
+ --color-warning: oklch(0.65 0.15 75);
+ --color-error: oklch(0.65 0.23 25);
+}
+
+/* Floating panel design system */
+.canvas-dots {
+ background-image: radial-gradient(circle, oklch(0.26 0.02 45) 0.8px, transparent 0.8px);
+ background-size: 28px 28px;
+}
+:root:not(.dark) .canvas-dots {
+ background-image: radial-gradient(circle, oklch(0.86 0.01 70) 0.8px, transparent 0.8px);
+}
+.floating-panel {
+ background: var(--color-surface-secondary);
+ border-radius: 16px;
+ box-shadow: 0 2px 20px oklch(0 0 0 / 0.2);
+ border: 1px solid var(--color-border);
+}
+
+/* Base styles */
+body {
+ margin: 0;
+ background: var(--color-surface-primary);
+ color: var(--color-text-primary);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ overflow: hidden;
+}
+
+/* All interactive elements get pointer cursor */
+button, a, [role="switch"], [role="button"] {
+ cursor: pointer;
+}
+button:disabled {
+ cursor: not-allowed;
+}
+
+/* Scrollbar — thin, auto-hide */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: transparent transparent;
+}
+*:hover {
+ scrollbar-color: var(--color-surface-tertiary) transparent;
+}
+::-webkit-scrollbar {
+ width: 3px;
+ height: 3px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: transparent;
+ border-radius: 1.5px;
+}
+*:hover > ::-webkit-scrollbar-thumb,
+*:hover::-webkit-scrollbar-thumb {
+ background: var(--color-surface-tertiary);
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-text-muted);
+}
+
+/* Selection */
+::selection {
+ background: var(--color-accent);
+ color: white;
+}
+
+/* Wails drag region for frameless window */
+.wails-drag {
+ --wails-draggable: drag;
+}
+.wails-no-drag {
+ --wails-draggable: no-drag;
+}
+
+/* Toast slide-in animation */
+@keyframes toast-slide-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* Wobble animation for active tool calls — gentler than spin */
+@keyframes wobble {
+ 0%, 100% { transform: rotate(0deg); }
+ 25% { transform: rotate(-12deg); }
+ 75% { transform: rotate(12deg); }
+}
+.animate-wobble {
+ animation: wobble 1s ease-in-out infinite;
+}
+
+/* Fade-in for splash screen */
+@keyframes fade-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+.animate-fade-in {
+ animation: fade-in 0.6s ease-out;
+}
+
+/* Transitions are applied via Tailwind utilities (transition-colors, transition-opacity, etc.)
+ instead of a global * rule to avoid layout jank and performance overhead. */
diff --git a/ui/desktop/frontend/src/lib/api.ts b/ui/desktop/frontend/src/lib/api.ts
new file mode 100644
index 00000000..d530607a
--- /dev/null
+++ b/ui/desktop/frontend/src/lib/api.ts
@@ -0,0 +1,139 @@
+// HTTP API client for GoClaw REST endpoints
+
+class ApiError extends Error {
+ constructor(
+ message: string,
+ public status: number,
+ public code?: string,
+ ) {
+ super(message)
+ this.name = 'ApiError'
+ }
+}
+
+class ApiClient {
+ private baseUrl: string
+ private token: string
+
+ constructor(baseUrl: string, token: string) {
+ this.baseUrl = baseUrl.replace(/\/$/, '')
+ this.token = token
+ }
+
+ private headers(extra?: Record): Record {
+ return {
+ Authorization: `Bearer ${this.token}`,
+ 'Content-Type': 'application/json',
+ 'X-GoClaw-User-Id': 'system',
+ ...extra,
+ }
+ }
+
+ private async request(method: string, path: string, body?: unknown): Promise {
+ const url = `${this.baseUrl}${path}`
+ const res = await fetch(url, {
+ method,
+ headers: this.headers(),
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ })
+
+ if (!res.ok) {
+ let code: string | undefined
+ let message = res.statusText
+ try {
+ const json = (await res.json()) as { error?: { code?: string; message?: string } }
+ code = json.error?.code
+ message = json.error?.message ?? message
+ } catch {
+ // non-JSON error body
+ }
+ throw new ApiError(message, res.status, code)
+ }
+
+ if (res.status === 204) return undefined as T
+ return res.json() as Promise
+ }
+
+ async get(path: string): Promise {
+ return this.request('GET', path)
+ }
+
+ async post(path: string, body?: unknown): Promise {
+ return this.request('POST', path, body)
+ }
+
+ async put(path: string, body?: unknown): Promise {
+ return this.request('PUT', path, body)
+ }
+
+ async patch(path: string, body?: unknown): Promise {
+ return this.request('PATCH', path, body)
+ }
+
+ async delete(path: string): Promise {
+ return this.request('DELETE', path)
+ }
+
+ getBaseUrl(): string {
+ return this.baseUrl
+ }
+
+ /** Fetch a file with Bearer auth. Use for URLs without ?ft= token (e.g. media_refs). */
+ async fetchFile(url: string): Promise {
+ const fullUrl = url.startsWith('http') ? url : `${this.baseUrl}${url}`
+ return fetch(fullUrl, {
+ headers: { Authorization: `Bearer ${this.token}` },
+ })
+ }
+
+ /** Sign a file path, returning a URL with ?ft= token for unauthenticated access. */
+ async signFileUrl(filePath: string): Promise {
+ const res = await this.post<{ url: string }>('/v1/files/sign', { path: filePath })
+ return `${this.baseUrl}${res.url}`
+ }
+
+ async uploadFile(path: string, file: File): Promise {
+ const url = `${this.baseUrl}${path}`
+ const form = new FormData()
+ form.append('file', file)
+
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${this.token}`,
+ 'X-GoClaw-User-Id': 'system',
+ },
+ body: form,
+ })
+
+ if (!res.ok) {
+ let message = res.statusText
+ try {
+ const json = (await res.json()) as { error?: string | { message?: string } }
+ message = typeof json.error === 'string' ? json.error : json.error?.message ?? message
+ } catch { /* non-JSON */ }
+ throw new ApiError(message, res.status)
+ }
+ return res.json() as Promise
+ }
+}
+
+// Singleton
+let apiClient: ApiClient | null = null
+
+export function getApiClient(): ApiClient {
+ if (!apiClient) throw new Error('ApiClient not initialized — call initApiClient() first')
+ return apiClient
+}
+
+/** Safe check — returns true if the API client has been initialized. */
+export function isApiClientReady(): boolean {
+ return apiClient !== null
+}
+
+export function initApiClient(baseUrl: string, token: string): ApiClient {
+ apiClient = new ApiClient(baseUrl, token)
+ return apiClient
+}
+
+export { ApiClient, ApiError }
diff --git a/ui/desktop/frontend/src/lib/constants.ts b/ui/desktop/frontend/src/lib/constants.ts
new file mode 100644
index 00000000..8f3bfd6a
--- /dev/null
+++ b/ui/desktop/frontend/src/lib/constants.ts
@@ -0,0 +1,24 @@
+// Supported languages for the desktop app
+export const LANGUAGES = [
+ { value: 'en', label: 'EN', flag: '🇺🇸' },
+ { value: 'vi', label: 'VI', flag: '🇻🇳' },
+ { value: 'zh', label: 'ZH', flag: '🇨🇳' },
+] as const
+
+// Fallback timezone list if Intl.supportedValuesOf is unavailable
+export const COMMON_TIMEZONES = [
+ 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
+ 'America/Sao_Paulo', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Moscow',
+ 'Asia/Dubai', 'Asia/Kolkata', 'Asia/Bangkok', 'Asia/Saigon', 'Asia/Ho_Chi_Minh',
+ 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore', 'Asia/Hong_Kong',
+ 'Australia/Sydney', 'Pacific/Auckland',
+]
+
+// Returns all IANA timezones if browser supports it, otherwise fallback list
+export function getAllTimezones(): string[] {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const intlAny = Intl as any
+ return typeof intlAny.supportedValuesOf === 'function'
+ ? intlAny.supportedValuesOf('timeZone')
+ : COMMON_TIMEZONES
+}
diff --git a/ui/desktop/frontend/src/lib/format.ts b/ui/desktop/frontend/src/lib/format.ts
new file mode 100644
index 00000000..81957caf
--- /dev/null
+++ b/ui/desktop/frontend/src/lib/format.ts
@@ -0,0 +1,32 @@
+// Formatting utilities for display values
+
+export function formatTimestamp(ms: number): string {
+ return new Intl.DateTimeFormat(undefined, {
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(new Date(ms))
+}
+
+export function formatDate(ms: number): string {
+ return new Intl.DateTimeFormat(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ }).format(new Date(ms))
+}
+
+export function formatDuration(ms: number): string {
+ if (ms < 1000) return `${ms}ms`
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
+ return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`
+}
+
+export function formatTokens(n: number): string {
+ if (n < 1000) return String(n)
+ return `${(n / 1000).toFixed(1)}k`
+}
+
+export function truncate(text: string, maxLen: number): string {
+ if (text.length <= maxLen) return text
+ return text.slice(0, maxLen - 1) + '\u2026'
+}
diff --git a/ui/desktop/frontend/src/lib/media-cache.ts b/ui/desktop/frontend/src/lib/media-cache.ts
new file mode 100644
index 00000000..6a232252
--- /dev/null
+++ b/ui/desktop/frontend/src/lib/media-cache.ts
@@ -0,0 +1,88 @@
+import { getApiClient, isApiClientReady } from './api'
+
+/** Client-side blob cache for file URLs.
+ * Supports both ?ft= signed token URLs and Bearer-auth URLs (media_refs).
+ * Cache key = clean path (without ?ft=), value = blob ObjectURL with 5-min TTL. */
+
+const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes, matches backend FileTokenTTL
+
+interface CacheEntry {
+ objectUrl: string
+ expiresAt: number
+}
+
+const cache = new Map()
+const inflight = new Map>()
+
+/** Strip ?ft=... or &ft=... from URL, return clean path for cache key. */
+export function stripFt(url: string): string {
+ return url.replace(/[?&]ft=[^&\s)"'<>]*/g, '').replace(/[?&]$/, '')
+}
+
+/** Evict expired entries and revoke their ObjectURLs. */
+function evictExpired() {
+ const now = Date.now()
+ for (const [key, entry] of cache) {
+ if (entry.expiresAt <= now) {
+ URL.revokeObjectURL(entry.objectUrl)
+ cache.delete(key)
+ }
+ }
+}
+
+/** Get cached ObjectURL or fetch blob and cache it.
+ * Returns the blob ObjectURL on success, or the original signed URL on failure. */
+export async function getOrFetchUrl(signedUrl: string): Promise {
+ evictExpired()
+
+ const key = stripFt(signedUrl)
+ const cached = cache.get(key)
+ if (cached && cached.expiresAt > Date.now()) {
+ return cached.objectUrl
+ }
+
+ // Deduplicate concurrent fetches for the same key
+ const existing = inflight.get(key)
+ if (existing) return existing
+
+ const promise = (async () => {
+ try {
+ // For URLs without ?ft= token, sign first via API then fetch
+ let fetchUrl = signedUrl
+ if (!signedUrl.includes('ft=') && isApiClientReady()) {
+ // Extract absolute path from /v1/files/{path} URL
+ const match = signedUrl.match(/\/v1\/files\/(.+?)(?:\?|$)/)
+ if (match) {
+ fetchUrl = await getApiClient().signFileUrl('/' + decodeURIComponent(match[1]))
+ }
+ }
+ const res = await fetch(fetchUrl)
+ if (!res.ok) return signedUrl
+ const blob = await res.blob()
+ const objectUrl = URL.createObjectURL(blob)
+ cache.set(key, { objectUrl, expiresAt: Date.now() + CACHE_TTL_MS })
+ return objectUrl
+ } catch {
+ return signedUrl // graceful degradation
+ } finally {
+ inflight.delete(key)
+ }
+ })()
+
+ inflight.set(key, promise)
+ return promise
+}
+
+/** Append ?download=true to a URL for Content-Disposition: attachment. */
+export function toDownloadUrl(url: string): string {
+ return url + (url.includes('?') ? '&' : '?') + 'download=true'
+}
+
+/** Revoke all cached ObjectURLs (call on disconnect). */
+export function revokeAll() {
+ for (const entry of cache.values()) {
+ URL.revokeObjectURL(entry.objectUrl)
+ }
+ cache.clear()
+ inflight.clear()
+}
diff --git a/ui/desktop/frontend/src/lib/wails.ts b/ui/desktop/frontend/src/lib/wails.ts
new file mode 100644
index 00000000..1487709b
--- /dev/null
+++ b/ui/desktop/frontend/src/lib/wails.ts
@@ -0,0 +1,26 @@
+// Type-safe wrappers for Wails Go runtime bindings
+// Wails injects window.go at startup with bound Go methods
+
+declare global {
+ interface Window {
+ go: {
+ main: {
+ App: {
+ GetGatewayURL(): Promise
+ GetGatewayToken(): Promise
+ GetGatewayPort(): Promise
+ IsGatewayReady(): Promise
+ GetVersion(): Promise
+ }
+ }
+ }
+ }
+}
+
+export const wails = {
+ getGatewayURL: (): Promise => window.go.main.App.GetGatewayURL(),
+ getGatewayToken: (): Promise => window.go.main.App.GetGatewayToken(),
+ getGatewayPort: (): Promise => window.go.main.App.GetGatewayPort(),
+ isGatewayReady: (): Promise => window.go.main.App.IsGatewayReady(),
+ getVersion: (): Promise => window.go.main.App.GetVersion(),
+}
diff --git a/ui/desktop/frontend/src/lib/ws.ts b/ui/desktop/frontend/src/lib/ws.ts
new file mode 100644
index 00000000..a0449689
--- /dev/null
+++ b/ui/desktop/frontend/src/lib/ws.ts
@@ -0,0 +1,276 @@
+// WebSocket v3 client for GoClaw gateway RPC protocol
+
+export type FrameType = 'req' | 'res' | 'event'
+
+export interface RequestFrame {
+ type: 'req'
+ id: string
+ method: string
+ params?: Record
+}
+
+export interface ResponseFrame {
+ type: 'res'
+ id: string
+ ok: boolean
+ payload?: unknown
+ error?: {
+ code: string
+ message: string
+ details?: unknown
+ retryable?: boolean
+ retryAfterMs?: number
+ }
+}
+
+export interface EventFrame {
+ type: 'event'
+ event: string
+ payload: unknown
+ seq?: number
+}
+
+export type Frame = RequestFrame | ResponseFrame | EventFrame
+
+export type EventHandler = (payload: unknown) => void
+
+interface PendingRequest {
+ resolve: (payload: unknown) => void
+ reject: (error: Error) => void
+ timer: ReturnType
+ timeoutMs: number
+}
+
+const DEFAULT_TIMEOUT_MS = 30_000
+const CHAT_SEND_TIMEOUT_MS = 600_000
+const RECONNECT_BASE_MS = 1_000
+const RECONNECT_MAX_MS = 30_000
+
+export class WsClient {
+ private ws: WebSocket | null = null
+ private url: string
+ private token: string
+ private connected = false
+ private connecting = false
+ private pendingRequests = new Map()
+ private eventHandlers = new Map>()
+ private reconnectDelay = RECONNECT_BASE_MS
+ private reconnectTimer: ReturnType | null = null
+ private connectionChangeHandler?: (connected: boolean) => void
+ private closed = false
+ private queuedCalls: Array<() => void> = []
+ private connectRequestId: string | null = null
+
+ constructor(url: string, token: string) {
+ this.url = url
+ this.token = token
+ }
+
+ connect(): void {
+ if (this.closed || this.connecting || this.connected) return
+ this.connecting = true
+
+ console.info('[ws] connecting to', this.url)
+ this.ws = new WebSocket(this.url)
+ this.ws.onopen = () => this.handleOpen()
+ this.ws.onmessage = (e) => this.handleMessage(e.data as string)
+ this.ws.onclose = (e) => this.handleClose(e)
+ this.ws.onerror = () => console.warn('[ws] socket error')
+ }
+
+ private handleOpen(): void {
+ this.connecting = false
+ const id = this.nextId()
+ this.connectRequestId = id
+ this.sendRaw({
+ type: 'req',
+ id,
+ method: 'connect',
+ params: {
+ token: this.token,
+ user_id: 'system',
+ sender_id: 'desktop',
+ locale: localStorage.getItem('goclaw:language') || navigator.language.split('-')[0] || 'en',
+ protocol_version: 3,
+ },
+ })
+ }
+
+ private handleMessage(data: string): void {
+ let frame: Frame
+ try {
+ frame = JSON.parse(data) as Frame
+ } catch {
+ console.warn('[ws] invalid frame', data)
+ return
+ }
+
+ if (frame.type === 'res') {
+ this.handleResponse(frame)
+ } else if (frame.type === 'event') {
+ this.handleEvent(frame)
+ }
+ }
+
+ private handleResponse(frame: ResponseFrame): void {
+ // connect handshake response — match by tracked ID
+ if (this.connectRequestId && frame.id === this.connectRequestId) {
+ this.connectRequestId = null
+ if (frame.ok) {
+ this.onSessionConnected()
+ } else {
+ console.error('[ws] connect handshake failed', frame.error)
+ }
+ return
+ }
+
+ const pending = this.pendingRequests.get(frame.id)
+ if (!pending) return
+
+ clearTimeout(pending.timer)
+ this.pendingRequests.delete(frame.id)
+
+ if (frame.ok) {
+ pending.resolve(frame.payload)
+ } else {
+ const err = frame.error
+ const msg = err?.message ?? 'RPC error'
+ pending.reject(Object.assign(new Error(msg), { code: err?.code, retryable: err?.retryable }))
+ }
+ }
+
+ private handleEvent(frame: EventFrame): void {
+ const handlers = this.eventHandlers.get(frame.event)
+ if (!handlers) return
+ for (const h of handlers) {
+ try {
+ h(frame.payload)
+ } catch (err) {
+ console.error('[ws] event handler error', frame.event, err)
+ }
+ }
+ }
+
+ private onSessionConnected(): void {
+ console.info('[ws] session connected')
+ this.connected = true
+ this.reconnectDelay = RECONNECT_BASE_MS
+ this.connectionChangeHandler?.(true)
+
+ // flush queued calls
+ const queued = this.queuedCalls.splice(0)
+ for (const fn of queued) fn()
+ }
+
+ private handleClose(e: CloseEvent): void {
+ const wasConnected = this.connected
+ this.connected = false
+ this.connecting = false
+ this.ws = null
+
+ if (wasConnected) this.connectionChangeHandler?.(false)
+
+ // reject all pending requests
+ for (const [, pending] of this.pendingRequests) {
+ clearTimeout(pending.timer)
+ pending.reject(new Error('WebSocket disconnected'))
+ }
+ this.pendingRequests.clear()
+
+ if (!this.closed) {
+ console.info(`[ws] closed (code=${e.code}), reconnecting in ${this.reconnectDelay}ms`)
+ this.scheduleReconnect()
+ }
+ }
+
+ call(method: string, params?: Record, timeoutMs?: number): Promise {
+ const timeout = timeoutMs ?? (method === 'chat.send' ? CHAT_SEND_TIMEOUT_MS : DEFAULT_TIMEOUT_MS)
+
+ if (!this.connected) {
+ return new Promise((resolve, reject) => {
+ this.queuedCalls.push(() => {
+ this.call(method, params, timeoutMs).then(resolve, reject)
+ })
+ })
+ }
+
+ return new Promise((resolve, reject) => {
+ const id = this.nextId()
+ const timer = setTimeout(() => {
+ this.pendingRequests.delete(id)
+ reject(new Error(`RPC timeout: ${method}`))
+ }, timeout)
+
+ this.pendingRequests.set(id, { resolve, reject, timer, timeoutMs: timeout })
+ this.sendRaw({ type: 'req', id, method, params })
+ })
+ }
+
+ resetTimeout(requestId: string, timeoutMs?: number): void {
+ const pending = this.pendingRequests.get(requestId)
+ if (!pending) return
+ clearTimeout(pending.timer)
+ const ms = timeoutMs ?? pending.timeoutMs
+ pending.timer = setTimeout(() => {
+ this.pendingRequests.delete(requestId)
+ pending.reject(new Error('RPC streaming timeout'))
+ }, ms)
+ }
+
+ on(event: string, handler: EventHandler): () => void {
+ let handlers = this.eventHandlers.get(event)
+ if (!handlers) {
+ handlers = new Set()
+ this.eventHandlers.set(event, handlers)
+ }
+ handlers.add(handler)
+ return () => {
+ handlers!.delete(handler)
+ if (handlers!.size === 0) this.eventHandlers.delete(event)
+ }
+ }
+
+ onConnectionChange(handler: (connected: boolean) => void): void {
+ this.connectionChangeHandler = handler
+ }
+
+ close(): void {
+ this.closed = true
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
+ this.ws?.close()
+ }
+
+ private scheduleReconnect(): void {
+ this.reconnectTimer = setTimeout(() => {
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS)
+ this.connect()
+ }, this.reconnectDelay)
+ }
+
+ private sendRaw(frame: Frame): void {
+ this.ws?.send(JSON.stringify(frame))
+ }
+
+ private nextId(): string {
+ return crypto.randomUUID()
+ }
+
+ get isConnected(): boolean {
+ return this.connected
+ }
+}
+
+// Singleton
+let client: WsClient | null = null
+
+export function getWsClient(): WsClient {
+ if (!client) throw new Error('WsClient not initialized — call initWsClient() first')
+ return client
+}
+
+export function initWsClient(url: string, token: string): WsClient {
+ if (client) client.close()
+ client = new WsClient(url, token)
+ client.connect()
+ return client
+}
diff --git a/ui/desktop/frontend/src/main.tsx b/ui/desktop/frontend/src/main.tsx
new file mode 100644
index 00000000..db032b74
--- /dev/null
+++ b/ui/desktop/frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/ui/desktop/frontend/src/stores/agent-store.ts b/ui/desktop/frontend/src/stores/agent-store.ts
new file mode 100644
index 00000000..18897f78
--- /dev/null
+++ b/ui/desktop/frontend/src/stores/agent-store.ts
@@ -0,0 +1,36 @@
+import { create } from 'zustand'
+
+type AgentStatus = 'online' | 'busy' | 'offline' | 'idle'
+
+interface Agent {
+ id: string
+ key: string
+ name: string
+ model: string
+ status: AgentStatus
+ emoji?: string
+ busyDuration?: number // ms since busy started
+}
+
+interface AgentState {
+ agents: Agent[]
+ selectedAgentId: string | null
+
+ setAgents: (agents: Agent[]) => void
+ selectAgent: (id: string | null) => void
+ updateAgentStatus: (id: string, status: AgentStatus) => void
+}
+
+export const useAgentStore = create((set) => ({
+ agents: [],
+ selectedAgentId: null,
+
+ setAgents: (agents) => set({ agents }),
+ selectAgent: (id) => set({ selectedAgentId: id }),
+ updateAgentStatus: (id, status) =>
+ set((s) => ({
+ agents: s.agents.map((a) => (a.id === id ? { ...a, status } : a)),
+ })),
+}))
+
+export type { Agent, AgentStatus }
diff --git a/ui/desktop/frontend/src/stores/chat-store.ts b/ui/desktop/frontend/src/stores/chat-store.ts
new file mode 100644
index 00000000..b55cd7df
--- /dev/null
+++ b/ui/desktop/frontend/src/stores/chat-store.ts
@@ -0,0 +1,167 @@
+import { create } from 'zustand'
+
+interface ToolCall {
+ toolId: string
+ toolName: string
+ arguments: Record
+ state: 'calling' | 'completed' | 'error'
+ result?: string
+ error?: string
+}
+
+interface Message {
+ id: string
+ role: 'user' | 'assistant'
+ content: string
+ timestamp: number
+ // Assistant-only fields
+ thinkingText?: string
+ toolCalls?: ToolCall[]
+ media?: { type: string; url: string }[]
+ usage?: { inputTokens: number; outputTokens: number }
+}
+
+interface Activity {
+ phase: string // thinking, tool_exec, compacting, streaming, retrying, leader_processing
+ tool?: string
+ iteration?: number
+}
+
+interface ChatState {
+ messages: Message[]
+ isRunning: boolean
+ activity: Activity | null
+ currentRunId: string | null
+
+ addUserMessage: (content: string) => void
+ startRun: (runId: string) => void
+ appendChunk: (text: string) => void
+ appendThinking: (text: string) => void
+ addToolCall: (toolCall: Omit) => void
+ updateToolResult: (toolId: string, result: string, error?: string) => void
+ setActivity: (activity: Activity | null) => void
+ completeRun: (content: string, usage?: Message['usage'], media?: Message['media']) => void
+ failRun: (error: string) => void
+ setMessages: (messages: Message[]) => void
+ clear: () => void
+}
+
+export const useChatStore = create((set) => ({
+ messages: [],
+ isRunning: false,
+ activity: null,
+ currentRunId: null,
+
+ addUserMessage: (content) => {
+ const msg: Message = {
+ id: crypto.randomUUID(),
+ role: 'user',
+ content,
+ timestamp: Date.now(),
+ }
+ set((s) => ({ messages: [...s.messages, msg] }))
+ },
+
+ startRun: (runId) => {
+ const msg: Message = {
+ id: runId,
+ role: 'assistant',
+ content: '',
+ timestamp: Date.now(),
+ toolCalls: [],
+ }
+ set((s) => ({
+ messages: [...s.messages, msg],
+ isRunning: true,
+ currentRunId: runId,
+ activity: { phase: 'thinking' },
+ }))
+ },
+
+ appendChunk: (text) => {
+ set((s) => {
+ const msgs = [...s.messages]
+ const last = msgs[msgs.length - 1]
+ if (last?.role === 'assistant') {
+ msgs[msgs.length - 1] = { ...last, content: last.content + text }
+ }
+ return { messages: msgs }
+ })
+ },
+
+ appendThinking: (text) => {
+ set((s) => {
+ const msgs = [...s.messages]
+ const last = msgs[msgs.length - 1]
+ if (last?.role === 'assistant') {
+ msgs[msgs.length - 1] = {
+ ...last,
+ thinkingText: (last.thinkingText ?? '') + text,
+ }
+ }
+ return { messages: msgs }
+ })
+ },
+
+ addToolCall: (tc) => {
+ set((s) => {
+ const msgs = [...s.messages]
+ const last = msgs[msgs.length - 1]
+ if (last?.role === 'assistant') {
+ const toolCalls = [...(last.toolCalls ?? []), { ...tc, state: 'calling' as const }]
+ msgs[msgs.length - 1] = { ...last, toolCalls }
+ }
+ return { messages: msgs }
+ })
+ },
+
+ updateToolResult: (toolId, result, error) => {
+ set((s) => {
+ const msgs = [...s.messages]
+ const last = msgs[msgs.length - 1]
+ if (last?.role === 'assistant' && last.toolCalls) {
+ const toolCalls = last.toolCalls.map((tc) =>
+ tc.toolId === toolId
+ ? { ...tc, state: (error ? 'error' : 'completed') as ToolCall['state'], result, error }
+ : tc,
+ )
+ msgs[msgs.length - 1] = { ...last, toolCalls }
+ }
+ return { messages: msgs }
+ })
+ },
+
+ setActivity: (activity) => set({ activity }),
+
+ completeRun: (content, usage, media) => {
+ set((s) => {
+ const msgs = [...s.messages]
+ const last = msgs[msgs.length - 1]
+ if (last?.role === 'assistant') {
+ msgs[msgs.length - 1] = {
+ ...last,
+ content: content || last.content,
+ usage,
+ media,
+ }
+ }
+ return { messages: msgs, isRunning: false, activity: null, currentRunId: null }
+ })
+ },
+
+ failRun: (error) => {
+ set((s) => {
+ const msgs = [...s.messages]
+ const last = msgs[msgs.length - 1]
+ if (last?.role === 'assistant') {
+ msgs[msgs.length - 1] = { ...last, content: last.content || `Error: ${error}` }
+ }
+ return { messages: msgs, isRunning: false, activity: null, currentRunId: null }
+ })
+ },
+
+ setMessages: (messages) => set({ messages }),
+ clear: () => set({ messages: [], isRunning: false, activity: null, currentRunId: null }),
+}))
+
+export type { Message, ToolCall, Activity, ChatState }
diff --git a/ui/desktop/frontend/src/stores/session-store.ts b/ui/desktop/frontend/src/stores/session-store.ts
new file mode 100644
index 00000000..6c4712ab
--- /dev/null
+++ b/ui/desktop/frontend/src/stores/session-store.ts
@@ -0,0 +1,42 @@
+import { create } from 'zustand'
+
+interface Session {
+ key: string
+ agentId: string
+ title: string
+ lastMessageAt: number
+ messageCount: number
+}
+
+interface SessionState {
+ sessions: Session[]
+ activeSessionKey: string | null
+
+ setSessions: (sessions: Session[]) => void
+ setActiveSession: (key: string | null) => void
+ addSession: (session: Session) => void
+ updateSession: (key: string, updates: Partial) => void
+ removeSession: (key: string) => void
+}
+
+export const useSessionStore = create((set) => ({
+ sessions: [],
+ activeSessionKey: null,
+
+ setSessions: (sessions) => set({ sessions }),
+ setActiveSession: (key) => set({ activeSessionKey: key }),
+ addSession: (session) => set((s) => ({ sessions: [session, ...s.sessions] })),
+ updateSession: (key, updates) =>
+ set((s) => ({
+ sessions: s.sessions.map((sess) =>
+ sess.key === key ? { ...sess, ...updates } : sess,
+ ),
+ })),
+ removeSession: (key) =>
+ set((s) => ({
+ sessions: s.sessions.filter((sess) => sess.key !== key),
+ activeSessionKey: s.activeSessionKey === key ? null : s.activeSessionKey,
+ })),
+}))
+
+export type { Session }
diff --git a/ui/desktop/frontend/src/stores/toast-store.ts b/ui/desktop/frontend/src/stores/toast-store.ts
new file mode 100644
index 00000000..24dac8c2
--- /dev/null
+++ b/ui/desktop/frontend/src/stores/toast-store.ts
@@ -0,0 +1,43 @@
+import { create } from 'zustand'
+
+export interface Toast {
+ id: string
+ title: string
+ message?: string
+ variant: 'default' | 'success' | 'destructive' | 'warning'
+ duration?: number
+}
+
+interface ToastState {
+ toasts: Toast[]
+ addToast: (toast: Omit) => void
+ dismiss: (id: string) => void
+}
+
+let counter = 0
+
+export const useToastStore = create((set) => ({
+ toasts: [],
+ addToast: (toast) => {
+ const id = String(++counter)
+ set((s) => ({ toasts: [...s.toasts, { ...toast, id }] }))
+
+ const duration = toast.duration ?? 4000
+ setTimeout(() => {
+ set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }))
+ }, duration)
+ },
+ dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
+}))
+
+// Convenience functions for one-line usage across the app
+export const toast = {
+ success: (title: string, message?: string) =>
+ useToastStore.getState().addToast({ title, message, variant: 'success' }),
+ error: (title: string, message?: string) =>
+ useToastStore.getState().addToast({ title, message, variant: 'destructive' }),
+ warning: (title: string, message?: string) =>
+ useToastStore.getState().addToast({ title, message, variant: 'warning' }),
+ info: (title: string, message?: string) =>
+ useToastStore.getState().addToast({ title, message, variant: 'default' }),
+}
diff --git a/ui/desktop/frontend/src/stores/ui-store.ts b/ui/desktop/frontend/src/stores/ui-store.ts
new file mode 100644
index 00000000..0b0332c3
--- /dev/null
+++ b/ui/desktop/frontend/src/stores/ui-store.ts
@@ -0,0 +1,81 @@
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+
+export type AppView = 'chat' | 'settings' | 'team-board'
+export type SettingsTab = 'appearance' | 'providers' | 'agents' | 'mcp' | 'skills' | 'tools' | 'cron' | 'traces' | 'about'
+
+interface UiState {
+ theme: 'dark' | 'light'
+ locale: string
+ timezone: string
+ sidebarOpen: boolean
+ sidebarWidth: number
+ onboarded: boolean
+ activeView: AppView
+ settingsTab: SettingsTab
+ activeTeamId: string | null
+ toggleTheme: () => void
+ setLocale: (locale: string) => void
+ setTimezone: (tz: string) => void
+ toggleSidebar: () => void
+ setSidebarWidth: (width: number) => void
+ completeOnboarding: () => void
+ resetOnboarding: () => void
+ setActiveView: (view: AppView) => void
+ setSettingsTab: (tab: SettingsTab) => void
+ openSettings: (tab?: SettingsTab) => void
+ closeSettings: () => void
+ openTeamBoard: (teamId: string) => void
+}
+
+export const useUiStore = create()(
+ persist(
+ (set) => ({
+ theme: 'dark',
+ locale: 'en',
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ sidebarOpen: true,
+ sidebarWidth: 260,
+ onboarded: false,
+ activeView: 'chat',
+ activeTeamId: null,
+ settingsTab: 'appearance',
+ toggleTheme: () =>
+ set((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' })),
+ setLocale: (locale) =>
+ set({ locale }),
+ setTimezone: (tz) =>
+ set({ timezone: tz }),
+ toggleSidebar: () =>
+ set((s) => ({ sidebarOpen: !s.sidebarOpen })),
+ setSidebarWidth: (width) =>
+ set({ sidebarWidth: width }),
+ completeOnboarding: () =>
+ set({ onboarded: true }),
+ resetOnboarding: () =>
+ set({ onboarded: false }),
+ setActiveView: (view) =>
+ set({ activeView: view }),
+ setSettingsTab: (tab) =>
+ set({ settingsTab: tab }),
+ openSettings: (tab) =>
+ set((s) => ({ activeView: 'settings', settingsTab: tab ?? s.settingsTab })),
+ closeSettings: () =>
+ set({ activeView: 'chat' }),
+ openTeamBoard: (teamId) =>
+ set({ activeView: 'team-board', activeTeamId: teamId }),
+ }),
+ {
+ name: 'goclaw-ui',
+ partialize: (s) => ({
+ theme: s.theme,
+ locale: s.locale,
+ timezone: s.timezone,
+ sidebarOpen: s.sidebarOpen,
+ sidebarWidth: s.sidebarWidth,
+ onboarded: s.onboarded,
+ settingsTab: s.settingsTab,
+ }),
+ }
+ )
+)
diff --git a/ui/desktop/frontend/src/types/agent.ts b/ui/desktop/frontend/src/types/agent.ts
new file mode 100644
index 00000000..75e12469
--- /dev/null
+++ b/ui/desktop/frontend/src/types/agent.ts
@@ -0,0 +1,74 @@
+/** Agent data types matching Go internal/store/agent_store.go + web UI types */
+
+// --- Per-agent config types (matching Go config structs) ---
+
+export interface MemoryConfig {
+ enabled?: boolean
+ embedding_provider?: string
+ embedding_model?: string
+ max_results?: number
+ max_chunk_len?: number
+ chunk_overlap?: number
+ vector_weight?: number
+ text_weight?: number
+ min_score?: number
+}
+
+export interface CompactionConfig {
+ reserveTokensFloor?: number
+ maxHistoryShare?: number
+ minMessages?: number
+ keepLastMessages?: number
+ memoryFlush?: {
+ enabled?: boolean
+ softThresholdTokens?: number
+ }
+}
+
+// --- Main agent data ---
+
+export interface AgentData {
+ id: string
+ agent_key: string
+ display_name?: string
+ frontmatter?: string
+ owner_id: string
+ provider: string
+ model: string
+ context_window: number
+ max_tool_iterations: number
+ workspace: string
+ restrict_to_workspace: boolean
+ agent_type: 'open' | 'predefined'
+ is_default: boolean
+ status: string // "active" | "summoning" | "summon_failed" | "idle" | "running"
+ created_at?: string
+ updated_at?: string
+
+ // Per-agent JSONB configs (null/undefined = use global defaults)
+ memory_config?: MemoryConfig | null
+ compaction_config?: CompactionConfig | null
+ other_config?: Record | null
+ tenant_id?: string
+}
+
+export interface AgentInput {
+ agent_key: string
+ display_name?: string
+ provider: string
+ model: string
+ agent_type: 'open' | 'predefined'
+ is_default?: boolean
+ context_window?: number
+ max_tool_iterations?: number
+ memory_config?: MemoryConfig | null
+ other_config?: Record
+}
+
+/** Bootstrap file info from agents.files.list / agents.files.get */
+export interface BootstrapFile {
+ name: string
+ missing: boolean
+ size?: number
+ content?: string
+}
diff --git a/ui/desktop/frontend/src/types/builtin-tool.ts b/ui/desktop/frontend/src/types/builtin-tool.ts
new file mode 100644
index 00000000..93edc5ba
--- /dev/null
+++ b/ui/desktop/frontend/src/types/builtin-tool.ts
@@ -0,0 +1,12 @@
+export interface BuiltinToolData {
+ name: string
+ display_name: string
+ description: string
+ category: string
+ enabled: boolean
+ settings: Record
+ requires: string[] | null
+ metadata: Record
+ created_at: string
+ updated_at: string
+}
diff --git a/ui/desktop/frontend/src/types/cron.ts b/ui/desktop/frontend/src/types/cron.ts
new file mode 100644
index 00000000..65d9f789
--- /dev/null
+++ b/ui/desktop/frontend/src/types/cron.ts
@@ -0,0 +1,43 @@
+export interface CronJob {
+ id: string
+ name: string
+ agentId: string
+ enabled: boolean
+ schedule: CronSchedule
+ payload: CronPayload
+ state: CronState
+ deleteAfterRun: boolean
+ createdAtMs: number
+ updatedAtMs: number
+}
+
+export interface CronSchedule {
+ kind: 'every' | 'cron' | 'at'
+ everyMs?: number
+ expr?: string
+ tz?: string
+ atMs?: number
+}
+
+export interface CronPayload {
+ kind: string
+ message: string
+}
+
+export interface CronState {
+ nextRunAtMs?: number
+ lastRunAtMs?: number
+ lastStatus?: string
+ lastError?: string
+}
+
+export interface CronRunLog {
+ ts: number
+ jobId: string
+ status: string
+ error?: string
+ summary?: string
+ durationMs: number
+ inputTokens: number
+ outputTokens: number
+}
diff --git a/ui/desktop/frontend/src/types/mcp.ts b/ui/desktop/frontend/src/types/mcp.ts
new file mode 100644
index 00000000..22322916
--- /dev/null
+++ b/ui/desktop/frontend/src/types/mcp.ts
@@ -0,0 +1,55 @@
+export interface MCPServerData {
+ id: string
+ name: string
+ display_name: string
+ transport: 'stdio' | 'sse' | 'streamable-http'
+ command: string
+ args: string[] | null
+ url: string
+ headers: Record | null
+ env: Record | null
+ tool_prefix: string
+ timeout_sec: number
+ settings?: { require_user_credentials?: boolean }
+ enabled: boolean
+ created_by: string
+ agent_count?: number
+ created_at: string
+ updated_at: string
+}
+
+export interface MCPServerInput {
+ name: string
+ display_name?: string
+ transport: string
+ command?: string
+ args?: string[]
+ url?: string
+ headers?: Record
+ env?: Record
+ tool_prefix?: string
+ timeout_sec?: number
+ enabled?: boolean
+}
+
+export interface MCPAgentGrant {
+ id: string
+ server_id: string
+ agent_id: string
+ enabled: boolean
+ tool_allow: string[] | null
+ tool_deny: string[] | null
+ granted_by: string
+ created_at: string
+}
+
+export interface MCPToolInfo {
+ name: string
+ description?: string
+}
+
+export interface MCPTestResult {
+ success: boolean
+ tool_count?: number
+ error?: string
+}
diff --git a/ui/desktop/frontend/src/types/provider.ts b/ui/desktop/frontend/src/types/provider.ts
new file mode 100644
index 00000000..93e483c8
--- /dev/null
+++ b/ui/desktop/frontend/src/types/provider.ts
@@ -0,0 +1,22 @@
+export interface ProviderData {
+ id: string
+ name: string
+ display_name?: string
+ provider_type: string
+ api_base?: string
+ api_key?: string
+ enabled: boolean
+ settings?: Record
+ created_at?: string
+ updated_at?: string
+}
+
+export interface ProviderInput {
+ name: string
+ display_name?: string
+ provider_type: string
+ api_base?: string
+ api_key?: string
+ enabled?: boolean
+ settings?: Record